diff --git a/CLAUDE.md b/CLAUDE.md index fc4d5a1..7939312 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,23 +1,25 @@ # Maxima-Draconis — engineering reference for Claude agents -This is the **Maxima-Draconis fork** — the EA authentication and launch backend used by [Draconis](https://github.com/AA-EION/Draconis), a native macOS launcher for Titanfall 2 on CrossOver / Wine. This file is the living engineering reference for anyone picking up the repo cold. It covers architecture, known gotchas, diagnostics, and a running changelog. +This is the **Maxima-Draconis fork** — the EA authentication and launch backend used by [Draconis](https://github.com/AA-EION/Draconis), a native macOS launcher for Titanfall 2 on CrossOver / Wine. This file is the living engineering reference for anyone picking up the repo cold: architecture, gotchas, diagnostics, and a running changelog. + +If you're updating this file, the rule is: **state of the world first, history at the bottom**. Don't make new sessions read three months of changelog before learning what the code currently does. --- ## What Maxima is -Open-source replacement for the EA Desktop Launcher. **Not** a macOS-native app — `maxima-cli` / `maxima-bootstrap` / `maxima-service` are Windows binaries that run **inside the CrossOver bottle** alongside Titanfall 2. The only piece that runs on the macOS host is `MaximaHelper.app`, a tiny Swift background agent that bridges EA's `qrc://` OAuth redirect from the user's browser into the bottle. +Open-source replacement for the EA Desktop / Origin launcher. **Not** a macOS-native app — `maxima-cli` / `maxima-bootstrap` / `maxima-service` are Windows binaries that run **inside the CrossOver bottle** alongside Titanfall 2. The only piece that runs on the macOS host is `MaximaHelper.app`, a tiny Swift background agent that bridges EA's `qrc://` OAuth redirect from the user's browser into the bottle. The Draconis fork is tested *only* for Titanfall 2 on macOS via CrossOver. Other configurations may work but aren't supported here. ### Multi-OS compatibility principle -Even though the active maintenance target is macOS/CrossOver, **the code must remain compatible with the other OSes upstream supports** — Linux (native + musl) and native Windows. Concretely: +Even though the active maintenance target is macOS/CrossOver, **the code must remain compatible with the other OSes upstream supports** — Linux (native + musl) and native Windows: - All `#[cfg(unix)]`, `#[cfg(target_os = "linux")]`, `#[cfg(target_os = "macos")]`, `#[cfg(windows)]`, `#[cfg(not(windows))]` gates that exist in upstream must be preserved when editing the affected file. - Don't introduce hard `panic!()` or `unimplemented!()` on a code path that other OSes hit at runtime. - Don't add `#[cfg]`-gated dependencies that would skip building on other targets without a clear reason; if you need to, scope the gate as narrowly as possible. -- `maxima-ui` and `maxima-tui` are **upstream graphical/TUI frontends**. They are built and shipped in this fork's Windows installer (`maxima.exe`, `maxima-tui.exe`) — the UI is wired up for future use even though Draconis currently invokes only the CLI side. On Linux they are excluded from CI because `maxima-ui` transitively pulls `rustix 0.37` via `accesskit_unix → zbus → async-io`, which doesn't build on modern nightly. The Windows target uses a different rustix path (no unix backend) and compiles fine, so we ship them there. **Do not delete them from the workspace.** +- `maxima-ui` and `maxima-tui` are **upstream graphical / TUI frontends**. They are built and shipped in this fork's Windows installer (`maxima.exe`, `maxima-tui.exe`) — the UI is wired up for future use even though Draconis currently invokes only the CLI side. On Linux they are excluded from CI because `maxima-ui` transitively pulls `rustix 0.37` via `accesskit_unix → zbus → async-io`, which doesn't build on modern nightly. The Windows target uses a different rustix path (no unix backend) and compiles fine, so we ship them there. **Do not delete them from the workspace.** - The Linux CI job builds `maxima-cli` + `maxima-bootstrap` to make sure the cross-platform code paths actually compile on a non-macOS unix. The Windows CI job builds the three Draconis-relevant crates **and** the NSIS installer. If you touch `#[cfg(unix)]` or `#[cfg(windows)]` blocks, make sure those jobs still pass. In short: macOS/CrossOver is what we **test**, but the codebase is **portable** to the same targets upstream supports. @@ -34,13 +36,17 @@ macOS host │ (built from MaximaHelper/ in this repo) │ └── CrossOver bottle (Wine prefix) - └── Program Files (x86)/Maxima/ - ├── maxima-cli.exe — auth + launch CLI + └── Program Files/Maxima/ + ├── maxima-cli.exe — auth + launch CLI (also runs `serve` mode) ├── maxima-bootstrap.exe — link2ea:// / origin2:// / qrc:// handler ├── maxima-service.exe — background service (DLL injection, registry setup) - └── Uninstall.exe — NSIS uninstaller from MaximaSetup.exe + ├── maxima.exe — upstream GUI (shipped, not yet wired into Draconis) + ├── maxima-tui.exe — upstream TUI (shipped, not yet wired into Draconis) + └── Uninstall.exe — NSIS uninstaller ``` +> **Path note:** Wine on macOS uses `Program Files`, not `Program Files (x86)`, so the install lands at `drive_c/Program Files/Maxima/`. The NSIS script uses `$PROGRAMFILES` which resolves correctly under both layouts. + Build outputs: - `installer/MaximaSetup.exe` — NSIS bundle that installs everything in the bottle and registers the protocol handlers in Wine's registry. Cross-compiled on macOS via `mingw-w64` + `nsis`. @@ -52,21 +58,24 @@ Build outputs: ## Workspace inventory ``` -maxima-lib/ Core library — auth, launch, license, library, LSX, RTM, - OOA, cloudsync. All other crates depend on this. -maxima-cli/ CLI frontend — `maxima-cli launch `, login, - listGames, getGameBySlug, cloudSync, etc. Entry point - invoked by maxima-bootstrap. +maxima-lib/ Core library — auth, launch, license, library, LSX, + RTM, OOA, cloudsync, /authorize HTTP server, Steam + install helpers. All other crates depend on this. +maxima-cli/ CLI frontend — `maxima-cli launch ` (legacy + orchestrated launch), `maxima-cli serve` (passive + auth-only mode), plus utility subcommands. maxima-bootstrap/ Protocol handler binary — registered for link2ea://, origin2://, qrc:// in Wine's registry. Parses the URL, - validates the offer_id, and shells out to maxima-cli. + validates the offer_id, and either forwards to a + running Maxima via HTTP /authorize or spawns + maxima-cli launch as fallback. maxima-service/ Windows background service — registry setup, DLL injection for KYBER. Windows-only (no-op `main` on other targets). Not exercised in the Draconis flow. -maxima-tui/ Terminal UI (upstream, ratatui-based). Not built by - this fork's CI; preserved for upstream compat. -maxima-ui/ Graphical UI (upstream, eframe/egui). Not built by - this fork's CI; preserved for upstream compat. +maxima-tui/ Terminal UI (upstream, ratatui-based). Shipped in the + installer but not invoked by Draconis yet. +maxima-ui/ Graphical UI (upstream, eframe/egui). Shipped in the + installer but not invoked by Draconis yet. maxima-resources/ Shared assets — logo, translations. MaximaHelper/ Native macOS Swift app (build.sh + Info.plist + Sources/main.swift). Bridges qrc:// from the host @@ -79,173 +88,368 @@ images/ Repo images — banners, screenshots. upstream). ``` -Key entry points to know: - -| File | What it does | -|-----------------------------------------------|-----------------------------------------------------------| -| `maxima-cli/src/main.rs` | CLI argparse + subcommand dispatch | -| `maxima-bootstrap/src/main.rs` | Protocol URL parser + validator + `maxima-cli launch` invocation | -| `maxima-lib/src/core/launch.rs` | `start_game()` — license, cloud sync, env vars, spawn | -| `maxima-lib/src/core/auth/login.rs` | OAuth flow + `remid`-cookie fallback | -| `maxima-lib/src/lsx/request/license.rs` | Denuvo token fetch (env override: `MAXIMA_DENUVO_TOKEN`) | -| `maxima-lib/src/util/registry.rs` | Windows registry: install check + protocol registration | -| `maxima-lib/src/unix/wine.rs` | Wine detection, registry setup via `regedit /S` | -| `maxima-lib/src/util/dll_injector.rs` | KYBER DLL injection (Windows-only) | -| `MaximaHelper/Sources/main.swift` | NSApplicationDelegate that handles `qrc://` URLs | -| `installer/maxima-setup.nsi` | NSIS script, takes `/DBIN_DIR` for binary location | +Key entry points: + +| File | What it does | +|-----------------------------------------------|--------------------------------------------------------------------| +| `maxima-cli/src/main.rs` | CLI argparse + subcommand dispatch (Launch, Serve, ListGames, …) | +| `maxima-bootstrap/src/main.rs` | Protocol URL parser + auth-server probe + HTTP forward / spawn | +| `maxima-lib/src/auth_server.rs` | `GET /` + `POST /authorize?offer_id=X` over plain TCP, port 13219 | +| `maxima-lib/src/steam.rs` | `STEAM_GAMES` table, Steam install path discovery (registry + VDF) | +| `maxima-lib/src/core/launch.rs` | `start_game()` — license preflight, env vars, spawn the game | +| `maxima-lib/src/core/auth/login.rs` | OAuth flow + `remid`-cookie fallback for macOS/CrossOver | +| `maxima-lib/src/core/mod.rs` | `Maxima` struct, `start_lsx` (with probe), `start_auth_server` | +| `maxima-lib/src/lsx/connection.rs` | LSX socket lifecycle + ConnectionState (game_version, etc.) | +| `maxima-lib/src/lsx/service.rs` | LSX TCP listener on port 3216 + accept loop | +| `maxima-lib/src/lsx/request/license.rs` | Denuvo token fetch (env override: `MAXIMA_DENUVO_TOKEN`) | +| `maxima-lib/src/util/registry.rs` | Windows registry: install check + protocol registration | +| `maxima-lib/src/unix/wine.rs` | Wine detection, registry setup via `regedit /S` | +| `maxima-lib/src/util/dll_injector.rs` | KYBER DLL injection (Windows-only, UTF-16 paths) | +| `MaximaHelper/Sources/main.swift` | NSApplicationDelegate that handles `qrc://` URLs | +| `installer/maxima-setup.nsi` | NSIS script, takes `/DBIN_DIR` for binary location | --- -## Deltas vs upstream (`ArmchairDevelopers/Maxima`) - -Cumulative summary of everything this fork carries on top of upstream `master`. Use this to understand what's macOS/Draconis-specific vs. plain bug fixes that could be sent upstream. - -### Infrastructure (macOS/Draconis-specific) - -- **`MaximaHelper/`** — new native Swift macOS background agent. Replaces the upstream AppleScript "helper" with a properly bundle-signable binary that LaunchServices will honor for `qrc://`. Universal binary (arm64 + x86_64), built with `swiftc` from `MaximaHelper/build.sh`. Bundle id `com.armchairdevelopers.maxima.helper`, listens for `qrc://` and forwards to `http://127.0.0.1:31033/auth?...` inside the bottle. -- **`installer/maxima-setup.nsi` + `installer/build.sh`** — NSIS-based Windows installer that drops `maxima-cli.exe`, `maxima-bootstrap.exe`, `maxima-service.exe` into the bottle, registers `link2ea://`, `origin2://`, `qrc://` in Wine's registry, and adds start menu shortcuts. Cross-compiled on macOS via `mingw-w64` + `nsis`. Supports `/DBIN_DIR=` override to point at any cargo target dir. -- **`.github/workflows/release.yml`** — three-job release pipeline (macOS builds the helper, Windows builds the installer, Ubuntu collects artifacts and creates the GitHub release). Triggered on `v*` tags. -- **`.github/workflows/build-ci.yml`** — push CI matrix (Linux/Windows/macOS) running the build + sanity checks on every branch. -- **`.github/workflows/block-upstream-pr.yml`** — fires on `pull_request_target` and fails if anyone tries to open a PR against upstream from this fork by accident. +## Current architecture: two launch paths -### Code changes (could be sent upstream) +A bottle running this fork can authenticate games **two ways**. They use the same underlying `maxima-lib` code; the difference is whether Maxima is treated as a long-running auth service or as an on-demand orchestrator. -- **Bootstrap protocol-handler hardening** (`maxima-bootstrap/src/main.rs`) - - `link2ea://` and `origin2://` validate the offer_id against `Origin.OFR..` before invoking `maxima-cli`. Without this, a crafted URL like `link2ea://launchgame/--login=stolen_token` would have made `maxima-cli` interpret `--login` as a flag and bypass OAuth. **(Security)** - - `origin2://` now reads the real `offerIds` from the URL instead of the hardcoded `Origin.OFR.50.0002148` upstream had. Works for any EA title. **(Bug)** - - `qrc://` handler no longer panics on URLs missing `login_successful.html?` (was indexing `[1]` on a split vec without bounds checking). **(Bug)** - - `link2ea://` forwards `KYBER_INTERFACE_PORT` from the parent environment instead of hardcoding `3005`. **(Bug)** -- **`maxima-cli launch` Steam-only owner passthrough** (`maxima-cli/src/main.rs`) — if EA library lookup fails but the slug already matches the EA offer ID pattern, pass it through with a warning instead of bailing. Lets Steam-only TF2 owners launch without linking accounts. **(Bug/UX)** -- **`maxima-cli` `GetGameBySlug` subcommand was a no-op stub** — now actually prints slug/offer_id/content_id/display_name/installed. **(Bug)** -- **`maxima-cli` exhaustive library lookup** — beyond `base_slug` and `base_offer`, scans every game's `slug`/`offer_id`/`product.id`/`product.origin_offer_id`/`offer.content_id`/`product.product.id`. **(Feature, brought in by upstream `9437bcd`.)** -- **DLL injector wide-string support** (`maxima-lib/src/util/dll_injector.rs`) — switched `GetModuleHandleA`/`LoadLibraryA` to `GetModuleHandleW`/`LoadLibraryW` with UTF-16. Fixes injection on non-ASCII install paths. **(Bug, equivalent to upstream `fix/non-ascii-characters` branch.)** -- **Wine registry setup** (`maxima-lib/src/unix/wine.rs`) - - Added `HKEY_LOCAL_MACHINE\Software\Origin` bare key (some EA titles check this path without the `Electronic Arts\` prefix). - - `regedit` runs with `/S` (silent) — no longer blocks on a confirmation dialog in Wine. - - stderr is now piped **and** read, so Wine errors surface in `WineError::Command` instead of being swallowed. **(Bug, partial subset of upstream `fix/wine-registry-setup` branch — the part of that branch that *disabled* `link2ea`/`origin2` protocol registration was intentionally NOT taken because this fork needs them.)** -- **License env override** (`maxima-lib/src/lsx/request/license.rs`) — `MAXIMA_DENUVO_TOKEN` env var short-circuits the license request and returns the token directly. Useful for offline debugging. **(Feature, from upstream `feat/license-token-override`.)** -- **License-update parity** (`maxima-lib/src/core/launch.rs`) — `OnlineOffline` mode now calls `needs_license_update()` before re-requesting, matching `Online` mode. Avoids redundant license server hits. **(Bug, from upstream `fix/license-update-online-offline`.)** - -### Removals - -- The original AppleScript-based macOS helper. Replaced by the Swift `MaximaHelper.app` above. - ---- +### Path A: `serve` + bootstrap-forwarded launch *(preferred for Draconis / Steam)* -## CI - -Two workflows. Both use **Rust nightly** (required by `#![feature(slice_pattern)]` in `maxima-ui/src/main.rs` and similar feature gates elsewhere — this is inherited from upstream). +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Terminal 1: `maxima-cli.exe serve` (started by user / Draconis) │ +│ │ +│ maxima-cli │ +│ ├── log in (cached refresh token, or OAuth on first run) │ +│ ├── start_lsx() → TCP listen 127.0.0.1:3216 │ +│ └── start_auth_server() → TCP listen 127.0.0.1:13219 │ +│ (HTTP: GET / + POST /authorize?offer_id=X) │ +│ │ +│ maxima.playing() = None (no game launched yet) │ +└──────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────┐ +│ Steam / Draconis / user starts Titanfall2.exe │ +│ │ +│ Titanfall2.exe │ +│ ├── starts up │ +│ ├── DRM stub: "I need Origin / EA auth" │ +│ ├── emits link2ea://launchgame/Origin.OFR.50.0001456?… │ +│ └── EXITS — expects the link2ea handler to re-launch it with │ +│ EA auth context (EAGenericAuthToken, EALsxPort, …) │ +└──────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────┐ +│ Wine routes link2ea:// to maxima-bootstrap.exe │ +│ │ +│ maxima-bootstrap │ +│ ├── parses URL, validates Origin.OFR.. │ +│ ├── TCP probe 127.0.0.1:13219 with 200ms timeout │ +│ ├── alive → POST http://127.0.0.1:13219/authorize?offer_id=X │ +│ │ [&cmd_params=...] with 60s timeout │ +│ └── exits (logs outcome to %TEMP%/maxima_execution.log) │ +└──────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────┐ +│ `serve`'s auth_server handles POST /authorize │ +│ │ +│ handle_authorize(offer_id="Origin.OFR.50.0001456") │ +│ ├── auth_storage.logged_in() → must be true │ +│ ├── library.game_by_base_offer(offer_id) → must be Some(…) │ +│ ├── steam install-path lookup (path_override for Steam- │ +│ │ installed TF2; falls back to offer.execute_path otherwise) │ +│ └── launch::start_game(Online(offer_id), LaunchOptions{…}) │ +│ ├── request_and_save_license → .dlf on disk │ +│ ├── builds full EA-* env (EALsxPort, EAGenericAuthToken, │ +│ │ EAAccessTokenJWS, EALaunchEAID, ContentId, …) │ +│ ├── spawns bootstrap with base64(BootstrapLaunchArgs) │ +│ │ → bootstrap runs Titanfall2.exe with that env │ +│ └── maxima.playing = Some(ActiveGameContext) │ +│ → returns 200 OK {"status":"ok"} after the spawn is in flight │ +└──────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────┐ +│ Re-launched TF2 connects to serve's LSX │ +│ │ +│ Connection::new(serve.maxima_arc) │ +│ └── maxima.playing() = Some(ctx) (set by launch::start_game) │ +│ → standard active-launch branch │ +│ → request_license does real OOA fetch on TF2's request │ +│ → set_presence updates RTM │ +│ │ +│ LSX handshake → Challenge → GetProfile → … → game runs │ +└──────────────────────────────────────────────────────────────────────┘ +``` -### `build-ci.yml` — push CI +Key property: **`/authorize` does NOT just preflight the license — it +also spawns the game.** TF2's Origin DRM stub emits `link2ea://` and +exits, expecting whoever handles the URL to re-launch it. Bootstrap +forwards to `/authorize`, which calls `launch::start_game`, which +spawns a fresh TF2 with the full EA env in place. This is the same +code path the upstream UI's "Play" button takes — `serve` just lets +us reuse a single logged-in session across many launches instead of +re-bootstrapping from scratch each time. **`maxima.playing` ends up +`Some(...)` on the server, so the LSX flow goes down the standard +active-launch branch (not catornot's external-LSX branch).** -Fires on every push to any branch except `v*` tags. Matrix: Linux, Windows, macOS. +The `serve` loop also calls `maxima.update()` once per second, so +when the game exits the server detects it (`update_playing_status` +runs the cloud-save sync and clears `playing`), leaving the auth +server ready for the next launch. -| Job | What it builds | -|-----------------|-------------------------------------------------------------------------------| -| ubuntu-latest | `cargo build --release --target x86_64-unknown-linux-musl -p maxima-cli -p maxima-bootstrap` (skips UI/TUI — see "Multi-OS compatibility" above) | -| windows-latest | `cargo build --release` (full workspace, produces all 5 binaries — `maxima-cli.exe`, `maxima-bootstrap.exe`, `maxima-service.exe`, `maxima-tui.exe`, `maxima.exe`), then `makensis /DBIN_DIR="..\target\release"` | -| macos-latest | `bash MaximaHelper/build.sh --output ./dist --no-register`, then sanity check that the bundle layout is healthy and `Info.plist` declares `qrc://` | +### Path B: legacy `maxima-cli launch ` *(fallback when `serve` isn't running)* -What CI does **not** validate: +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Anything emits link2ea:// (or user runs `maxima-cli launch X`) │ +│ │ +│ bootstrap parses URL │ +│ ├── TCP probe 127.0.0.1:13219 │ +│ └── DEAD → spawns `maxima-cli.exe launch ` │ +└──────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────┐ +│ Fresh maxima-cli process: full orchestrated launch │ +│ │ +│ maxima-cli launch │ +│ ├── login (cached / OAuth) │ +│ ├── start_lsx() │ +│ │ ├── probe 127.0.0.1:3216 — anything listening? │ +│ │ │ YES (e.g. UI running) → skip our bind, defer to it │ +│ │ │ NO → bind ourselves │ +│ │ └── listening │ +│ ├── resolve slug → offer_id (EA library / Origin pattern / │ +│ │ STEAM_GAMES table) │ +│ ├── set SteamAppId / SteamGameId env vars if slug is numeric │ +│ ├── launch::start_game(LaunchOptions { steam_launch }) │ +│ │ ├── request_and_save_license → .dlf on disk │ +│ │ ├── set EAEntitlementSource / EAExternalSource / │ +│ │ │ EALaunchOwner = "Steam" or "EA" per steam_launch │ +│ │ ├── spawn bootstrap with base64(BootstrapLaunchArgs) │ +│ │ │ bootstrap then runs the game executable │ +│ │ └── maxima.playing = Some(ActiveGameContext) │ +│ └── poll loop until playing becomes None (game exits) │ +└──────────────────────────────────────────────────────────────────────┘ +``` -- `maxima-ui` and `maxima-tui` — they pull `rustix 0.37.28` (via `accesskit_unix → zbus 3 → async-process 1.8 → async-io 1.13`) which doesn't build on modern nightly because of the `rustc_attrs` namespace reservation. Excluded from CI to keep the workflow green; the crates themselves are unchanged from upstream. -- `MaximaSetup.exe` actually installing anything in a Wine bottle. We sanity-check size (>100KB) but never run it. -- `MaximaHelper.app`'s code signature — the helper is shipped linker-signed (adhoc) and Draconis re-signs it at consumption time with `codesign --force --deep --sign -` to seal the Info.plist. See "Signing gotcha" below. +Path B is what upstream Maxima does. It still works for the cases where the game **isn't** running yet at the moment `link2ea://` fires (e.g. a modern EA-Desktop title launched via a desktop shortcut that hands the launch off to EA Desktop). For Titanfall 2 launched via Steam it's the wrong path — TF2 is already running and waiting — but it's preserved as the bootstrap fallback so users who never start `serve` still get a working launch. -### `release.yml` — tag release +### Why the split -Fires on `v*` tags or `workflow_dispatch`. Three jobs: +Before this rewrite, every `link2ea://` invocation **fully re-bootstrapped Maxima**: -1. **`build-helper`** (macOS) — builds `MaximaHelper.app`, sanity-checks layout + Info.plist, zips with `--symlinks`, uploads `MaximaHelper.zip` artifact. -2. **`build-installer`** (Windows) — builds the three Draconis-relevant crates, runs `makensis`, sanity-checks installer size >100KB, uploads `MaximaSetup.exe` + a separate `maxima-binaries-win64` artifact with the loose `.exe`s. -3. **`release`** (Ubuntu) — downloads both artifacts and creates a non-prerelease GitHub release. Asset names are fixed: `MaximaHelper.zip` and `MaximaSetup.exe` (Draconis hardcodes these names in `Scripts/fetch-maxima-helper.sh` and `MaximaService.downloadAndInstall`, so do not rename). +1. Steam launches `Titanfall2.exe` directly (Steam does NOT emit link2ea here for TF2 — old game, predates EA Desktop). +2. TF2 starts, emits `link2ea://launchgame/Origin.OFR.50.0001456?platform=PCWIN`, and exits expecting a relaunch. +3. Wine → bootstrap → spawns a fresh `maxima-cli launch …` process. +4. That maxima-cli re-does OAuth login (or refreshes the cached token), restarts LSX, etc., then calls `launch::start_game` which spawns Titanfall2.exe again with EA env vars. +5. Works in principle, but: every launch pays the full Maxima startup cost; and on macOS/CrossOver the surface area kept tripping different failure modes (LSX port race, console-visibility, file-corruption-after-PI). -### `block-upstream-pr.yml` +Path A centralizes the auth-provider role in a long-running `serve` process. When `link2ea://` fires, bootstrap doesn't restart Maxima — it forwards to the running `serve` over HTTP, which already has cached login state. `serve` then calls the same `launch::start_game` path the upstream UI does (so the EA env vars end up on the re-spawned TF2 identically), but without paying for a fresh `maxima-cli` process startup each time. -Trivial guard that fires on `pull_request_target` and fails if the PR base is `ArmchairDevelopers/Maxima`. Prevents accidentally sending fork-specific changes upstream. +**An earlier draft of Path A skipped the game-spawn entirely** — the theory was that TF2 stayed alive polling LSX after emitting link2ea, so we just needed the auth server to refresh `.dlf` and TF2's polling would reconnect. Empirically TF2 *exits* after emitting link2ea (it expects EA Desktop to relaunch it), so a preflight-only `/authorize` leaves the game closed and the user sees "TF2 opens for a moment and then closes". The current design (spawn the game from `/authorize`) is the corrected version, aligned with upstream issue [#27](https://github.com/ArmchairDevelopers/Maxima/issues/27) ("Protocol handler should then use the obtained parameters to launch the game process"). --- -## End-to-end launch flow (the one that works) +## URI protocols Maxima owns -This is the **only** launch path that works on a Steam-owned, Wine-bottled TF2: +| Scheme | Registered by | Where | Handler does | +|--------------|----------------------------------------------------------|---------------|-----------------------------------------------------------------------| +| `qrc://` | `MaximaHelper.app` | macOS host | GETs `http://127.0.0.1:31033/auth?` inside the bottle | +| `qrc://` | `maxima-bootstrap.exe` | Wine registry | Same target as above (host handler wins when Draconis is installed) | +| `link2ea://` | `maxima-bootstrap.exe` | Wine registry | Probe + HTTP forward to `/authorize`, else spawn `maxima-cli launch` | +| `origin2://` | `maxima-bootstrap.exe` | Wine registry | Same as `link2ea://`. Reads real `offerIds` (no longer hardcoded BF2) | -``` -1. User clicks Launch in Draconis (vanilla mode) -2. Draconis runs Titanfall2.exe directly via the backend driver - (CrossOver: cxstart --bottle X Titanfall2.exe). - For Northstar mode Draconis runs steam.exe -applaunch 1237970 -northstar - instead (NorthstarLauncher.exe is broken — see below). -3. Titanfall2.exe wants EA auth, emits a URL: - link2ea://launchgame/Origin.OFR.50.0002694?platform=PCWIN&theme=... -4. Wine routes link2ea:// to maxima-bootstrap.exe (registered by MaximaSetup). -5. maxima-bootstrap parses the URL, takes segments[0] as the offer_id - (e.g. "Origin.OFR.50.0002694"), and shells out: - maxima-cli.exe launch -6. maxima-cli authenticates with EA. If the user's EA login needs the - browser OAuth redirect, EA redirects to a qrc:// URL. The bottle's - browser hands it to macOS, where MaximaHelper.app is the registered - handler for qrc:// — it forwards the query back into the bottle by - hitting http://127.0.0.1:31033/auth?. (maxima-service listens - there.) -7. maxima-cli resolves the license for the offer_id and TF2 gets its - auth ticket. Game runs. -``` +The `qrc://` listener on `127.0.0.1:31033` is **only up during an interactive OAuth login** (inside `core/auth/login.rs::begin_oauth_login_flow`). After the login completes that listener exits. It is **not** the same server as the `/authorize` HTTP endpoint, which lives on port `13219` and runs for the lifetime of `maxima-cli serve` / a UI session. -For Northstar mode the same auth chain still applies after Steam launches the game. +MaximaHelper.app's bundle id is `com.armchairdevelopers.maxima.helper`. **The Draconis fork's Info.plist must remain signed-sealed** — see "Signing gotcha" below. --- -## Why NorthstarLauncher.exe is *not* in the flow +## EA identifiers cheat sheet -`NorthstarLauncher.exe` in the TF2 directory **hard-codes a Win32 attempt to start Origin** (via a path to `Origin.exe`, not via `origin2://`). On macOS / Wine there is no Origin install, and our `origin2://` handler doesn't get a chance to intercept. Result: `[*] Starting Origin... [*] Waiting for Origin...` hangs forever. +| Thing | TF2 value | +|---------------------------|-----------------------------------------| +| Steam App ID | `1237970` (resolved via `STEAM_GAMES` table when EA library lookup fails) | +| EA Origin offer id | `Origin.OFR.50.0001456` (real TF2 offer id, NOT `0002694` / `0002148` which are Apex / Battlefront 2) | +| MaximaHelper bundle id | `com.armchairdevelopers.maxima.helper` | +| MaximaHelper qrc port | `127.0.0.1:31033` inside Wine | +| LSX port | `127.0.0.1:3216` (override via `MAXIMA_LSX_PORT`) | +| Authorize HTTP port | `127.0.0.1:13219` (override via `MAXIMA_AUTHORIZE_PORT`) | -Draconis works around this by launching Northstar mode via Steam's `-northstar` launch option (`steam.exe -applaunch 1237970 -northstar`), so Steam invokes `Titanfall2.exe` with the flag and Northstar's `wsock32` proxy hooks load. NorthstarLauncher.exe is never invoked. +--- -If you want to fix Northstar to work standalone here, the right place is to make Northstar's "start Origin" step use `origin2://` (so maxima-bootstrap can catch it). That's an upstream Northstar issue, not Maxima's. +## Deltas vs upstream `ArmchairDevelopers/Maxima` + +Everything below is on top of upstream `master` at `cbde5f0`. Categorized so we can tell what's macOS-specific from what could go upstream. + +### 1. New infrastructure (macOS / Draconis-specific) + +- **`MaximaHelper/`** — native Swift macOS background agent. Replaces upstream's AppleScript helper with a bundle-signable binary LaunchServices honors for `qrc://`. Universal arm64 + x86_64, built via `swiftc` from `MaximaHelper/build.sh`. Bundle id `com.armchairdevelopers.maxima.helper`; listens for `qrc://`, forwards query to `http://127.0.0.1:31033/auth?` inside the bottle. +- **`installer/`** — NSIS-based Windows installer (`maxima-setup.nsi`) and cross-compile script (`build.sh`). Drops `maxima-cli.exe`, `maxima-bootstrap.exe`, `maxima-service.exe`, `maxima.exe`, `maxima-tui.exe` into the bottle. Registers `link2ea://` / `origin2://` / `qrc://` in Wine's registry with backup/restore semantics for the pre-Maxima state. Cross-compiled on macOS via `mingw-w64` + `nsis`. Supports `/DBIN_DIR=` to override the cargo target dir. +- **`.github/workflows/`** — three workflows: `build-ci.yml` (push CI matrix Linux/Windows/macOS), `release.yml` (tag-triggered: builds the helper on macOS + the installer on Windows + assembles the GitHub release on Ubuntu), `block-upstream-pr.yml` (prevents accidentally PR-ing fork-specific changes upstream). +- **`.cargo/config.toml`** + **`rust-toolchain.toml`** — nightly pin (required by upstream's `#![feature(slice_pattern)]` etc.) and MinGW cross-compiler hookup. + +### 2. Code changes (most of these could go upstream) + +#### Bootstrap protocol handlers (`maxima-bootstrap/src/main.rs`) +- Implemented `link2ea://` (was `todo!()` upstream). +- `origin2://` reads real `offerIds` from the URL instead of hardcoded `Origin.OFR.50.0002148`. **(Generic — useful for every EA title.)** +- `qrc://` no longer panics on URLs missing `login_successful.html?` (was indexing `[1]` on a split vec without bounds checking). +- Both `link2ea` and `origin2` validate `offer_id` against `Origin.OFR..` or `<1..=10 digits>` before invoking anything — defends against `link2ea://launchgame/--login=stolen_token` flag injection. +- **NEW (Session 2026-05-18):** Both protocols probe `127.0.0.1:13219` and forward via HTTP `POST /authorize` when a Maxima auth server is running. Falls back to spawning `maxima-cli launch` only if no server answers. +- `KYBER_INTERFACE_PORT` forwarded from parent env (was hardcoded `3005`). +- Non-zero exits from spawned `maxima-cli` are surfaced as errors (used to be swallowed silently). +- All protocol-handler invocations append a line to `%TEMP%/maxima_execution.log` — bootstrap is a GUI-subsystem binary with no console, this is its only feedback channel. + +#### CLI runtime (`maxima-cli/src/main.rs`) +- **NEW (Session 2026-05-16):** `Mode::Serve { no_rtm }` subcommand — passive auth-only mode. Starts LSX + auth_server, optionally logs in to RTM for friends presence, then parks. See "Path A" above. +- **NEW:** Console + stdio rewire prologue so the CLI is visible when spawned by GUI-subsystem `maxima-bootstrap`. Calls `AllocConsole()` if no console attached, then `SetStdHandle(STD_*_HANDLE, CreateFileA("CONOUT$"|"CONIN$"))` so Rust's `println!` actually reaches the new window. +- **NEW:** Panic hook writing to `%LOCALAPPDATA%\Maxima\Logs\maxima-cli.panic.log` before unwinding — catches panics that fire before the regular logger is initialized. +- **NEW:** `main()` is plain `fn`, builds tokio runtime manually with `Builder::new_multi_thread().enable_all()`. The previous `#[tokio::main]` macro built the runtime before user code, which defeated the panic hook. +- **`Mode::Launch`** (legacy path B) now: + - Resolves slug via EA library lookup, then EA-offer passthrough, then `STEAM_GAMES` table fallback for Steam-only owners with unlinked accounts. + - Sets `SteamAppId` / `SteamGameId` / `SteamClientLaunch` / `SteamPath` env vars when slug matches `<1..=10 digits>` (Steam App ID pattern). + - Auto-injects `-noOriginStartup -multiple` launch args for Steam-launched games (Wine/Origin compatibility). + - Resolves Steam install path via `lookup_steam_game` + `resolve_steam_install_path` (registry + `libraryfolders.vdf` parse) when no `--game-path` is given. +- `Mode::GetGameBySlug` actually prints slug/offer_id/content_id/display_name/installed (was a no-op stub upstream). + +#### Steam helpers — new module (`maxima-lib/src/steam.rs`) +- Lifted from `maxima-cli/src/main.rs` so the auth server can use it too. Contains: + - `STEAM_GAMES` table (currently just TF2: app id `1237970` → `Origin.OFR.50.0001456`, `Titanfall2/Titanfall2.exe`). + - `lookup_steam_game(steam_app_id)`, `lookup_steam_game_by_offer(origin_offer_id)` (reverse lookup, used by `auth_server`). + - `resolve_steam_install_path(SteamGameEntry)` — Steam install discovery: registry (`HKLM\SOFTWARE\(Wow6432Node\)Valve\Steam\InstallPath`), then `Program Files (x86)\Steam` / `Program Files\Steam` defaults, then `libraryfolders.vdf` parse. **Windows only**; returns `None` on other targets (Wine builds use the cfg(windows) path). + - `EA_OFFER_ID_PATTERN`, `STEAM_APP_ID_PATTERN` regexes. + +#### Authorize HTTP server — new module (`maxima-lib/src/auth_server.rs`) +- Plain `tokio::net::TcpListener` + manual HTTP parsing (same pattern `core/auth/login.rs` uses for the OAuth callback — avoids pulling in `actix-web`). +- `GET /` → `200 OK` body `maxima-auth-server`. Bootstrap's liveness probe. +- `POST /authorize?offer_id=` → resolve offer, refresh `.dlf` via `request_and_save_license`, return `200 OK {"status":"ok"}`. **Does not spawn the game** — that's the architectural distinction from `Mode::Launch`. +- Errors map to HTTP status: `400` missing offer_id, `401` not logged in, `404` offer not in library or install path not found, `502` upstream EA / library failure. +- Default port `13219`; override with `MAXIMA_AUTHORIZE_PORT`. + +#### LSX server cooperation (`maxima-lib/src/core/mod.rs::Maxima::start_lsx`) +- Probes `127.0.0.1:` synchronously with 200ms timeout before binding. If a server is already listening (e.g. `serve` in another window, or the UI), logs and returns without trying to bind. +- Without this, the bootstrap-spawned `maxima-cli launch` would also bind 3216 (under Wine this can race the existing `serve` listener and steal the game's connection). + +#### LSX response handlers (`maxima-lib/src/lsx/`) +- **`request/license.rs`** — `playing()=None` case no longer panics on `unwrap()`. Returns an empty `attr_License` so the game falls back to its on-disk `.dlf` (which `/authorize` deposited just before TF2's polling reconnected). +- **`request/profile.rs::handle_set_presence_request`** — `playing()=None` returns `ErrorSuccess` without trying to broadcast presence (catornot patch). +- **`request/profile.rs::handle_profile_request`** — `attr_IsSubscriber` / `attr_IsSteamSubscriber` reflect `env::var("SteamAppId").is_ok()`. (Empirical: toggling this didn't fix the File-corruption symptom; left in because it's at least less wrong than hardcoded `false` when running under Steam.) +- **`request/challenge.rs`** — captures `Version` and `Title` from the client's challenge response into `ConnectionState`. +- **`request/game.rs::handle_all_game_info_request`** — `InstalledVersion` / `AvailableVersion` / `DisplayName` echo the captured Challenge values (fallback to upstream's hardcoded `1.0.1.3` / `Titanfall® 2 Deluxe Edition` if Challenge hasn't arrived yet). `EntitlementSource` is still hardcoded `"STEAM"` — see "Pending code quality items" below. +- **`request/progressive_install.rs`** — echoes `attr_ItemId` from the request instead of hardcoded `Origin.OFR.50.0001456`. +- **`connection.rs::Connection::new`** — accepts connections when `maxima.playing()=None` instead of rejecting with `LSXConnectionError::GameContext`. Ported from `catornot/Maxima@patch-external-lsx` (upstream PR #42 by p0358). PID lookup / Kyber injection is skipped in that branch since there's no `ActiveGameContext` to read from. + +#### Launch & env vars (`maxima-lib/src/core/launch.rs`) +- `LaunchOptions.steam_launch: bool` flips `EAEntitlementSource` / `EAExternalSource` / `EALaunchOwner` between `"EA"` and `"Steam"`. (Empirical: didn't fix File-corruption either; kept in because it's at least consistent with the surrounding env when launching via Steam.) +- `LaunchMode::Offline(_)` implemented (was `todo!()`). Looks up the offer, requires `path_override`, sets `EALaunchOfflineMode=true`. Draconis doesn't expose this yet. +- `path_override` skips `offer.is_installed()` (covers Steam-installed games EA Desktop has no record of). +- `LaunchMode::OnlineOffline(_)` now calls `needs_license_update()` before re-requesting, matching the `Online` branch. From upstream `fix/license-update-online-offline`. + +#### Auth / login (`maxima-lib/src/core/auth/login.rs`) +- `begin_oauth_login_flow` uses `tokio::select!` between the TCP listener and stdin. Users whose browser can't emit `qrc://` (macOS Safari blocking custom URL schemes, Wine-bottle browsers without registered handlers, etc.) can paste either a full OAuth redirect URL or just a `remid` cookie value and the flow extracts the auth code via a redirect probe. +- Multi-line on-screen hint walks the user through copying the `remid` cookie from EA's DevTools storage. + +#### Wine / Windows-side (`maxima-lib/src/unix/wine.rs`, `util/registry.rs`) +- `setup_wine_registry()` adds a bare `HKLM\Software\Origin` key (without `Electronic Arts\` prefix) that some EA titles check. +- `regedit` runs with `/S` (silent) so it doesn't block on a confirmation dialog under Wine. +- stderr is piped and concatenated into `WineError::Command` output instead of being swallowed. +- (Intentionally **not** taken from upstream `fix/wine-registry-setup`: the part that *disabled* `link2ea`/`origin2` protocol registration. We need them.) + +#### DLL injector (`maxima-lib/src/util/dll_injector.rs`) +- `GetModuleHandleA` / `LoadLibraryA` → `GetModuleHandleW` / `LoadLibraryW` with UTF-16. Fixes injection on non-ASCII install paths. Ported from upstream `fix/non-ascii-characters`. Windows-only file; benefits native Windows users equally. + +#### Logging (`maxima-lib/src/util/log.rs`) +- `init_logger_named(name)` variant — names the per-process log file (`maxima-cli.log` vs `maxima-bootstrap.log`). +- All logger output is mirrored to a file in addition to stdout. Default: `%LOCALAPPDATA%\Maxima\Logs\.log` on Windows, `$XDG_DATA_HOME/maxima/logs/.log` on unix. Override via `MAXIMA_LOG_FILE`. Each session writes a `===== maxima log session opened (pid=…) =====` header. + +#### Env-driven overrides +- `MAXIMA_DENUVO_TOKEN` — short-circuits `RequestLicense` in the LSX handler and returns this token verbatim. Useful for offline debugging. +- `MAXIMA_LSX_PORT` — overrides the LSX listen port (default 3216). +- `MAXIMA_AUTHORIZE_PORT` — overrides the authorize HTTP port (default 13219). +- `MAXIMA_LOG_FILE` — overrides the file logger destination. +- `MAXIMA_DISABLE_WINE_VERIFICATION` — skips the Wine / runtime version check at startup. + +### 3. Removed from upstream +- The original AppleScript-based macOS helper. Replaced by `MaximaHelper/Sources/main.swift`. +- Stale `todo.md` / `changes.md` tracking files. --- -## maxima-cli launch — Steam-only owner passthrough (FIXED) +## End-to-end flow (concrete walkthrough, Draconis vanilla + Steam-installed TF2) -`maxima-cli launch ` looks up the slug against the user's owned EA library before calling the license server: +This is the **currently recommended** flow on macOS/CrossOver. Use Path A from "Current architecture: two launch paths" above as the reference; this is the concrete instantiation. -```rust -// maxima-cli/src/main.rs — Mode::Launch block -// Tries: base_slug, base_offer, then exhaustive match across all known ID fields. -// If nothing matches AND the slug looks like a valid EA offer ID (Origin.OFR.X.Y), -// passes it through directly with a warning and lets EA's license server decide. -// Otherwise bails with a descriptive error pointing to https://www.ea.com. +``` +1. User clicks Launch in Draconis (vanilla mode). +2. Draconis runs Titanfall2.exe directly via cxstart --bottle "Titanfall 2". + (For Northstar mode Draconis runs `steam.exe -applaunch 1237970 + -northstar` instead; the same authentication chain still applies + once Steam starts the game with the Northstar hooks loaded.) +3. Titanfall2.exe starts. Its Origin DRM stub checks for a running EA + launcher. None found, so it emits the protocol URL: + link2ea://launchgame/Origin.OFR.50.0001456?platform=PCWIN&theme=tf2 + TF2 then begins polling 127.0.0.1:3216 (its hardcoded LSX port) and + stays alive until something answers. +4. Wine routes the link2ea:// URL to maxima-bootstrap.exe. +5. maxima-bootstrap parses the URL, validates the offer_id shape, then: + 5a. Probes 127.0.0.1:13219 (auth server). 200ms timeout. + 5b. If alive → POSTs http://127.0.0.1:13219/authorize?offer_id=… + with a 60s timeout, then exits. + 5c. If dead → spawns `maxima-cli.exe launch Origin.OFR.50.0001456` + (the upstream Path B behavior) and waits for it to finish. +6. (Path A) The running maxima-cli serve handles the authorize POST: + - Confirms it's still logged in (auth_storage.logged_in()). + - Confirms the offer is in the EA library. + - Resolves the Steam install path for path_override + (lookup_steam_game_by_offer + resolve_steam_install_path). + - Calls launch::start_game(LaunchMode::Online(offer_id), …): + · request_and_save_license → writes …/EA Services/License/ + .dlf + · sets EALsxPort / EAGenericAuthToken / EAAccessTokenJWS / + EALaunchEAID / ContentId / … env vars + · spawns bootstrap (Mode::Launch) which spawns Titanfall2.exe + with that env + · maxima.playing = Some(ActiveGameContext) + - Returns 200 OK to the original bootstrap (the one that handled + the link2ea:// URL). That bootstrap exits. +7. The newly-spawned TF2 has the full EA env and connects to LSX on + 127.0.0.1:3216 — serve's listener. Connection::new sees + playing()=Some(ctx), takes the standard active-launch branch. +8. LSX handshake completes: + Challenge → ChallengeAccepted (captures game version + title) + GetConfig / GetProfile / GetSetting / GetGameInfo / + GetAllGameInfo / IsProgressiveInstallationAvailable / … + RequestLicense → real OOA fetch, returns Denuvo token. +9. TF2 has its license, has its LSX session, runs normally. +10. When the game eventually exits, serve's update_playing_status + notices the bootstrap child returned, runs the cloud-save sync + (if enabled and the offer has cloud saves), and clears + maxima.playing. serve stays running for the next launch. ``` -**Previously broken for Steam-only owners** whose EA account doesn't have TF2 linked — `maxima-cli` would log in fine but bail with `"No owned offer found for 'Origin.OFR.50.0002694'"`. This is now fixed: if the slug matches `Origin\.OFR\.\d+\.\d+` and the library lookup fails, it falls through with a warning instead of an error. - -The user-side fix (linking Steam ↔ EA at https://www.ea.com) still removes the warning and is recommended for full LSX functionality. - -There's also `--login ` mode (`maxima-cli launch --login ...`) which treats the slug as a content id and skips the library lookup entirely — but it disables online LSX and uses a dummy persona name. +When `serve` is NOT running, step 5 takes branch 5c and a fresh `maxima-cli launch` process re-does the full bootstrap (login + LSX + launch::start_game). Same end state — the game is spawned with EA env vars — but every link2ea pays the full Maxima startup cost. -Stale Draconis releases (≤ v0.3.9) called `maxima-cli launch 1237970` directly, where `1237970` is the *Steam* app id, not an EA slug — the library lookup obviously didn't match anything. v0.4.0 of Draconis stopped doing this: the only path that reaches `maxima-cli` is via `link2ea://`, where the slug is the real EA offer id. +**Operationally: start `serve` before launching TF2.** Both paths end in `launch::start_game`; `serve` just amortizes login across launches. --- -## URI protocols Maxima owns +## Why NorthstarLauncher.exe is *not* in the flow + +`NorthstarLauncher.exe` in the TF2 directory **hard-codes a Win32 attempt to start Origin** (via a path to `Origin.exe`, not via `origin2://`). On macOS / Wine there is no Origin install, and our `origin2://` handler doesn't get a chance to intercept. Result: `[*] Starting Origin... [*] Waiting for Origin...` hangs forever. -| Scheme | Registered by | Where | Handler does | -|--------------|----------------------|---------------|----------------------------------------------------------| -| `qrc://` | `MaximaHelper.app` | macOS host | GETs `http://127.0.0.1:31033/auth?` inside bottle | -| `qrc://` | maxima-bootstrap.exe | Wine registry | same target (host handler is preferred when Draconis runs)| -| `link2ea://` | maxima-bootstrap.exe | Wine registry | extracts offer_id, runs `maxima-cli launch ` | -| `origin2://` | maxima-bootstrap.exe | Wine registry | extracts real `offerIds` from URL, runs `maxima-cli launch ` | +Draconis works around this by launching Northstar mode via Steam's `-northstar` launch option (`steam.exe -applaunch 1237970 -northstar -noOriginStartup -multiple`), so Steam invokes `Titanfall2.exe` with the flag and Northstar's `wsock32` proxy hooks load. `NorthstarLauncher.exe` is never invoked. -**Note on `origin2://`:** The upstream handler hardcoded `Origin.OFR.50.0002148` (Star Wars Battlefront 2). This fork now reads the `offerIds` query parameter from the URL and uses that, making `origin2://` generic across all EA games. +If you want to fix Northstar to work standalone here, the right place is to make Northstar's "start Origin" step use `origin2://` (so maxima-bootstrap can catch it). That's an upstream Northstar issue, not Maxima's. -MaximaHelper.app's bundle id is `com.armchairdevelopers.maxima.helper`. **The Draconis fork's Info.plist must remain signed-sealed** — see signing issue below. +Credit to [catornot](https://github.com/catornot) for documenting the `-noOriginStartup` requirement and contributing the external-LSX patch in the first place. --- ## Signing gotcha (relevant when packaging MaximaHelper) -The upstream zipped `MaximaHelper.app` is shipped **linker-signed only**: +The upstream zipped `MaximaHelper.app` ships **linker-signed only**: ``` codesign -dv MaximaHelper.app @@ -267,14 +471,37 @@ If you ever change how `MaximaHelper.app` is signed at release time in this repo --- -## EA identifiers cheat sheet +## CI -| Thing | TF2 value | -|---------------------------|-----------------------------------------| -| Steam App ID | `1237970` (Steam-only — do **not** pass to maxima-cli) | -| EA Origin offer id | `Origin.OFR.50.0002694` (extracted from link2ea://) | -| MaximaHelper bundle id | `com.armchairdevelopers.maxima.helper` | -| MaximaHelper qrc port | `127.0.0.1:31033` inside Wine | +Two workflows. Both use **Rust nightly** (required by `#![feature(slice_pattern)]` in `maxima-ui/src/main.rs` and similar feature gates elsewhere — inherited from upstream). + +### `build-ci.yml` — push CI + +Fires on every push to any branch except `v*` tags. Matrix: Linux, Windows, macOS. + +| Job | What it builds | +|-----------------|-------------------------------------------------------------------------------------------------------------------------| +| ubuntu-latest | `cargo build --release --target x86_64-unknown-linux-musl -p maxima-cli -p maxima-bootstrap` (skips UI/TUI) | +| windows-latest | `cargo build --release` (full workspace → all 5 binaries), then `makensis /DBIN_DIR="..\target\release"` | +| macos-latest | `bash MaximaHelper/build.sh --output ./dist --no-register`, then sanity check that `Info.plist` declares `qrc://` | + +What CI does **not** validate: + +- `maxima-ui` / `maxima-tui` on Linux — pull `rustix 0.37` via `accesskit_unix → zbus 3 → async-process 1.8 → async-io 1.13`, which doesn't build on modern nightly because of `rustc_attrs` namespace reservation. +- `MaximaSetup.exe` actually installing into a Wine bottle. We sanity-check size (>100KB) but never run it. +- `MaximaHelper.app`'s code signature — it ships linker-signed (adhoc) and Draconis re-signs it at consumption time with `codesign --force --deep --sign -`. + +### `release.yml` — tag release + +Fires on `v*` tags or `workflow_dispatch`. Three jobs: + +1. **`build-helper`** (macOS) — builds `MaximaHelper.app`, sanity-checks layout + Info.plist, zips with `--symlinks`, uploads `MaximaHelper.zip`. +2. **`build-installer`** (Windows) — builds the full workspace, runs `makensis`, sanity-checks installer size > 100KB, uploads `MaximaSetup.exe` + a separate `maxima-binaries-win64` artifact with the loose `.exe`s. +3. **`release`** (Ubuntu) — downloads both artifacts and creates a non-prerelease GitHub release. Asset names are fixed: `MaximaHelper.zip` and `MaximaSetup.exe` (Draconis hardcodes these names in `Scripts/fetch-maxima-helper.sh` and `MaximaService.downloadAndInstall`, so do not rename). + +### `block-upstream-pr.yml` + +Trivial guard that fires on `pull_request_target` and fails if the PR base is `ArmchairDevelopers/Maxima`. Prevents accidentally sending fork-specific changes upstream. --- @@ -316,16 +543,42 @@ Common offenders: mounted `Draconis-vX.dmg` (`/Volumes/Draconis [N]/...`), Xcode ### Is maxima-bootstrap actually being invoked? -Inside the bottle, maxima-bootstrap appends to `%TEMP%/maxima_execution.log` on every invocation (see `maxima-bootstrap/src/main.rs`). On a CrossOver bottle that's typically `~/Library/Application Support/CrossOver/Bottles//drive_c/users/crossover/Temp/maxima_execution.log`. If this file isn't growing when a launch is attempted, the protocol handler registration is broken and TF2's `link2ea://` is going nowhere. +Inside the bottle, maxima-bootstrap appends to `%TEMP%/maxima_execution.log` on every invocation. On a CrossOver bottle that's typically `~/Library/Application Support/CrossOver/Bottles//drive_c/users/crossover/Temp/maxima_execution.log`. If this file isn't growing when a launch is attempted, the protocol handler registration is broken and TF2's `link2ea://` is going nowhere. + +### Is the auth server up? Did bootstrap forward? + +When `serve` is running, the maxima-cli log file (`%LOCALAPPDATA%\Maxima\Logs\maxima-cli.log`) should contain `Authorize HTTP server listening on 127.0.0.1:13219`. When bootstrap forwards a request, the `maxima_execution.log` line is: + +``` +Forwarding link2ea offer=Origin.OFR.50.0001456 to auth server at http://127.0.0.1:13219/authorize?offer_id=… +Auth server accepted link2ea authorize for Origin.OFR.50.0001456 (body: {"status":"ok"}) +``` + +If you see `No auth server on 127.0.0.1:13219; falling back to maxima-cli launch …` instead, `serve` isn't running (or it crashed) and bootstrap fell through to Path B. + +### Quick port probe from inside the bottle + +```cmd +:: Bottle PowerShell / cmd +Test-NetConnection 127.0.0.1 -Port 3216 :: LSX +Test-NetConnection 127.0.0.1 -Port 13219 :: Authorize HTTP +``` + +Or from the macOS host (works because Wine forwards ports to the host loopback): + +```bash +nc -zv 127.0.0.1 3216 +nc -zv 127.0.0.1 13219 +``` ### Steam vs vanilla launch contract (Draconis ↔ here) Draconis v0.4.0+: - Vanilla launch: runs `Titanfall2.exe` directly. The binary's own Steam DRM stub self-relaunches via `steam://run/1237970` if needed; the EA path triggers `link2ea://` which reaches maxima-bootstrap. -- Northstar launch: runs `steam.exe -applaunch 1237970 -novid -northstar`. Steam routes through TF2, the Northstar hooks load, EA auth still goes via link2ea:// → maxima-bootstrap → maxima-cli. +- Northstar launch: runs `steam.exe -applaunch 1237970 -novid -northstar -noOriginStartup -multiple`. Steam routes through TF2, the Northstar hooks load, EA auth still goes via link2ea:// → maxima-bootstrap. -Draconis never calls `maxima-cli.exe` directly anymore. If you see `maxima-cli launch 1237970` in any log, it's from an old Draconis (≤ v0.3.9). +Draconis never calls `maxima-cli.exe` directly. If you see `maxima-cli launch 1237970` in any log, it's from an old Draconis (≤ v0.3.9) — they shouldn't exist in v0.4.0+ flows. --- @@ -341,7 +594,7 @@ GET https://api.github.com/repos/AA-EION/Maxima-Draconis/releases/latest → xcodegen + xcodebuild bundles it into Draconis.app ``` -So a new MaximaHelper release flows into the next Draconis build automatically as long as the asset is named `MaximaHelper.zip` and `MaximaSetup.exe` (for the in-bottle installer). +A new MaximaHelper release flows into the next Draconis build automatically as long as the assets are named `MaximaHelper.zip` and `MaximaSetup.exe`. Tag the release as `vX.Y.Z` (lowercase v). The bottle installer is downloaded by Draconis on demand via `MaximaService.downloadAndInstall` — it fetches the latest release's `MaximaSetup.exe`, copies it into the bottle's `drive_c/windows/Temp/`, runs it silently with `/S`. @@ -350,11 +603,22 @@ Tag the release as `vX.Y.Z` (lowercase v). The bottle installer is downloaded by ## Working on this repo ```bash -bash MaximaHelper/build.sh # build the macOS helper -bash installer/build.sh # cross-compile MaximaSetup.exe (mingw + nsis) -cargo build --release --target x86_64-pc-windows-gnu -p maxima-cli -cargo build --release --target x86_64-pc-windows-gnu -p maxima-bootstrap -cargo build --release --target x86_64-pc-windows-gnu -p maxima-service +# Cross-compile a single binary for Windows +cargo +nightly build --release --target x86_64-pc-windows-gnu -p maxima-cli +cargo +nightly build --release --target x86_64-pc-windows-gnu -p maxima-bootstrap +cargo +nightly build --release --target x86_64-pc-windows-gnu -p maxima-service + +# Or build the full workspace (UI + TUI + lib + all) +cargo +nightly build --release --target x86_64-pc-windows-gnu + +# Build the macOS helper +bash MaximaHelper/build.sh + +# Cross-compile the full installer (mingw + nsis) +bash installer/build.sh # → installer/MaximaSetup.exe + +# Quick cargo check (faster than build) — useful during refactors +cargo check --target x86_64-pc-windows-gnu -p maxima-lib -p maxima-cli -p maxima-bootstrap ``` Anything that affects the Draconis integration — protocol handler registration, offer_id resolution, Info.plist contents in MaximaHelper, `MaximaSetup.exe`'s install location — is worth flagging in the release notes so Draconis can adapt. @@ -369,220 +633,206 @@ Evaluated all 14 upstream branches. Only these were complete and merged-ready: |--------|---------------------| | `feat/license-token-override` | ✅ Already merged (commit `6ab4631`) | | `fix/license-update-online-offline` | ✅ Already merged (commit `246bc53`) | -| `fix/non-ascii-characters` | ✅ Applied in this session | +| `fix/non-ascii-characters` | ✅ Applied 2026-05-14 | | `fix/wine-registry-setup` | ✅ Partially applied (registry additions + silent regedit; the part that disabled link2ea/origin2 was intentionally skipped) | +| `catornot/Maxima@patch-external-lsx` | ✅ Applied 2026-05-15, defensive coverage extended in `license.rs` on 2026-05-18 | The remaining branches (`feat/server`, `feature/umu-launcher`, `feat/new-ci`, etc.) are either stale (6–20 months old), have unresolved conflicts, or are WIP with no clear completion signal. Do not merge them without a full review. --- -## Changelog - -### Session 2026-05-14 (post-v0.2.0 fixes) - -#### Fixed — log output invisible when `maxima-cli` is spawned from `maxima-bootstrap` -`maxima-bootstrap` is built with `#![windows_subsystem = "windows"]` (release builds) so it has no console of its own. When Wine routes a `link2ea://` URL to it and bootstrap spawns `maxima-cli.exe`, the child inherits the GUI parent's empty stdio — `println!()` from the logger went nowhere and users saw no auth/launch progress. Two-part fix: - -1. **`maxima-cli` now calls `AllocConsole()` at the very start of `main` if no console is attached** (Windows-only, `winapi::um::consoleapi`). When invoked from `cmd.exe` the existing console is reused; when invoked from bootstrap a new window pops up. Restores the v0.1.0-and-earlier behavior the user expected. -2. **The shared logger (`maxima-lib/src/util/log.rs`) now mirrors all output to a file** in addition to stdout. Default location: `%LOCALAPPDATA%\Maxima\Logs\.log` on Windows, `$XDG_DATA_HOME/maxima/logs/.log` on unix. Overridable via `MAXIMA_LOG_FILE`. Failure to open the file is non-fatal — stdout-only is still acceptable. Each session writes a `===== maxima log session opened (pid=...) =====` header so distinct invocations are easy to skim. - -The combination means: console is the primary UX, file is the durable forensics trail. **Cross-OS impact**: the `AllocConsole` shim is `#[cfg(windows)]`; file logging is portable. - -#### Fixed — installer ships incomplete app (missing UI / TUI) -v0.2.0 restricted the Windows build to `-p maxima-cli -p maxima-bootstrap -p maxima-service`, which silently dropped `maxima.exe` (UI) and `maxima-tui.exe` (TUI) from the installer. The `.nsi` had `File /nonfatal` lines for both so the install kept working, but users got a stripped-down app. Verified that both crates compile cleanly on the Windows target (the `rustix 0.37` block is unix-only — `accesskit_unix → zbus → async-io` is gated `#[cfg(unix)]`, never compiled on Windows). Windows CI and `release.yml` now do `cargo build --release` (full workspace). Linux keeps the `-p` restriction. - -### Session 2026-05-14 (CI + release pipeline) - -#### Fixed — `.github/workflows/build-ci.yml` -CI had been red on every commit since the workflow was added (5+ master pushes, none green). Two unrelated breakages: -- **Linux**: `cargo build --release` built the whole workspace, which pulled `maxima-ui → eframe → egui-winit → accesskit_winit → accesskit_unix → zbus 3.15 → async-process 1.8 → async-io 1.13 → rustix 0.37.28`. Recent nightlies (1.97.0-nightly) reserved the `rustc_*` attribute namespace, so rustix 0.37 fails to compile. Restricted Linux to `-p maxima-cli -p maxima-bootstrap`, which only pull rustix 0.38/1.x via tempfile/tokio. Also dropped the X11/xkbcommon apt deps that were only needed by `maxima-ui`. **Cross-OS impact**: none — the excluded crates still live in the workspace and a downstream user on a working toolchain can still `cargo build -p maxima-ui` locally. -- **Windows**: NSIS script defaulted to `${BIN_DIR}=..\target\x86_64-pc-windows-gnu\release\` but the runner compiles MSVC (`target/release/`). Passed `/DBIN_DIR="..\target\release"` through. Also restricted to the three Draconis-relevant crates. - -#### Added — `.github/workflows/build-ci.yml` macOS job -Added a third matrix entry that builds `MaximaHelper.app` via `MaximaHelper/build.sh` and validates the bundle layout + that `Info.plist` declares `CFBundleURLTypes` with `qrc://`. Catches helper regressions on every PR instead of only at tag time. Rust/protoc/rust-cache steps are gated `if: runner.os != 'macOS'` so the macOS job stays a pure Swift build. - -#### Fixed — `.github/workflows/release.yml` -The release pipeline had the same `cargo build --release` problem and would have failed silently on the next tag. Restricted the Windows job to the Draconis-relevant crates, added installer-size sanity check, added helper-bundle sanity check, and now uploads loose Windows binaries as a separate artifact (debug aid). - -### Session 2026-05-14 (code fixes) - -#### Fixed — `maxima-lib/src/util/dll_injector.rs` -DLL injection broke on non-ASCII installation paths (e.g. usernames or bottle paths with accented characters). Root cause: `GetModuleHandleA` / `LoadLibraryA` only accept ANSI strings. Fixed by switching to `GetModuleHandleW` / `LoadLibraryW` with UTF-16 wide strings, matching the `fix/non-ascii-characters` upstream branch. **Cross-OS impact**: file is Windows-only (`use winapi::...`); change benefits native Windows users equally. - -#### Fixed — `maxima-lib/src/unix/wine.rs` -Two issues in `setup_wine_registry()`: -1. Missing `HKEY_LOCAL_MACHINE\Software\Origin` bare key — some games check for this path without the `Electronic Arts\` prefix and would fail to recognise Origin as installed. -2. `regedit` was called without the `/S` (silent) flag, causing it to show a confirmation dialog that blocked the launch flow silently in Wine. Also added `Stdio::piped()` for stderr and `read+append` for stderr-to-`output_str`, so Wine errors surface in `WineError::Command` instead of disappearing. - -**Cross-OS impact**: file is unix-only (`#[cfg(unix)]`). The change benefits Linux + macOS/CrossOver equally; native Windows doesn't compile this file. - -#### Fixed — `maxima-bootstrap/src/main.rs` -The `origin2://` protocol handler had `Origin.OFR.50.0002148` (Star Wars Battlefront 2) hardcoded, making it useless for any other game. Also used wrong CLI syntax (`--mode launch --offer-id X` doesn't exist in this version of maxima-cli). Fixed to read the real `offerIds` from the URL query string and call `maxima-cli launch `. The handler now works generically for any EA title that emits `origin2://`. **Cross-OS impact**: code is portable (no `#[cfg]` gates); benefits Linux/Windows users of maxima-bootstrap who register `origin2://` natively. +## Open issues -#### Fixed — `maxima-cli/src/main.rs` -`maxima-cli launch Origin.OFR.X.Y` would bail with `"No owned offer found"` for Steam-only owners whose EA library is empty (TF2 not linked). Added offer_id passthrough: if all library lookups fail but the slug matches the `Origin.OFR.\d+\.\d+` pattern, Maxima passes it directly to the license server with a warning. Users are directed to link accounts at https://www.ea.com for the cleanest experience. **Cross-OS impact**: portable code; benefits any platform. - -#### Fixed — `maxima-cli/src/main.rs` -`GetGameBySlug` subcommand was a no-op stub (body commented out, returned `Ok(())`). Now prints slug, offer ID, content ID, display name, and installed status. - -#### Security — `maxima-bootstrap/src/main.rs` (CLI flag injection) -The `link2ea://` and `origin2://` handlers passed the URL-derived `offer_id` directly as a positional argument to `maxima-cli launch`. Because that segment comes from an attacker-controlled URL, a crafted link like `link2ea://launchgame/--login=stolen_token?platform=PCWIN` would have made `maxima-cli` interpret `--login` as a real flag (it exists in the CLI and treats the value as an EA access token, bypassing the OAuth flow). Added `is_valid_ea_offer_id()` strict validation (`Origin.OFR..`) before invoking the subprocess. Rejects log to `maxima_execution.log` for diagnostics. +### "Engine Error: File corruption detected" after `IsProgressiveInstallationAvailableResponse` -#### Fixed — `maxima-bootstrap/src/main.rs` (panic in qrc:// handler) -The `qrc://` handler did `arg.split("login_successful.html?").collect::>()[1]` — indexing `[1]` panics if the marker is absent. Replaced with `splitn(2, ...).get(1)` and a graceful early return. +**Status as of 2026-05-18.** Symptom-level workaround landed via the split-brain architecture; root cause still not isolated. -### Session 2026-05-15 (Steam App ID launch support + LSX fixes) — PR #4 +**Symptom.** When `maxima-cli launch` (Path B) is the LSX server for a Steam-launched TF2, the game completes Challenge → ChallengeAccepted → GetConfig → GetProfile → GetSetting → GetGameInfo → GetAllGameInfo → IsProgressiveInstallationAvailable, then closes the LSX connection and shows the "File corruption detected" engine error. Never reaches `RequestLicense` or `GetAuthCode`. -#### Fixed — Bootstrap rejecting Steam App IDs (root cause of "Maxima never appears") +**What was ruled out (toggled, symptom unchanged).** +- `IsSubscriber=false` ↔ `true` +- `IsSteamSubscriber=false` ↔ `true` +- `InstalledVersion="0"` ↔ real version captured at Challenge +- `IsProgressiveInstallationAvailableResponse.ItemId` hardcoded ↔ echoed +- `EAExternalSource="EA"` ↔ `"Steam"` env var +- Northstar `wsock32.dll` proxy removed +- ItemId echoed vs hardcoded -`is_valid_ea_offer_id()` only accepted `Origin.OFR..` patterns. When Steam launches an EA game via `link2ea://launchgame/1237970?platform=steam`, the offer segment is the numeric Steam App ID — which was being rejected, so maxima-bootstrap silently exited without invoking maxima-cli. Added `is_valid_steam_app_id()` (non-empty, ≤10 chars, all ASCII digits) and made `is_valid_ea_offer_id()` accept both forms. +**First mitigation attempt (early Session 2026-05-18) — abandoned.** The original `/authorize` was preflight-only (refresh `.dlf`, no game spawn) on the theory that TF2 stayed running after emitting link2ea. Empirically TF2 *exits* and waits for the launcher to re-spawn it, so this design produced a new symptom: "TF2 opens for a moment and then closes" without ever reaching the file-corruption point. Reverted. -#### Added — Steam App ID → EA offer ID lookup table in maxima-cli +**Current design (late Session 2026-05-18).** `/authorize` calls `launch::start_game` — same code path the upstream UI's "Play" button takes. The flow ends up identical to Path B from the EA-side perspective (full env vars, license preflight, `maxima.playing=Some(ctx)`, standard active-launch LSX branch), just with cached login state. -When `maxima-cli launch 1237970` is called (Steam App ID), the new `STEAM_GAMES` table maps it to the real EA offer ID (`Origin.OFR.50.0001456` for TF2), resolves the install path from `HKLM\SOFTWARE\Valve\Steam\InstallPath` + `libraryfolders.vdf`, sets `SteamAppId` / `SteamGameId` / `SteamClientLaunch` / `SteamPath` env vars, and injects `-noOriginStartup -multiple` launch args. The `steam_launch: bool` flag is passed through `LaunchOptions` to `start_game()`. +**The user has not yet confirmed this resolves the file-corruption symptom in TF2.** Pending feedback. If the symptom returns, it's the same root cause we were debugging before (LSX response is not the issue — toggling the response fields didn't help in earlier sessions). -#### Fixed — `is_installed()` check failing for Steam-only games +**Remaining hypotheses, in rough order of plausibility.** +1. **Steam DRM IPC.** TF2's Steam wrapper calls `SteamAPI_Init()` which needs `steam.exe` running and a valid Steam session. If Steam isn't actively running, init fails and TF2 reports the failure as file-corruption (known misleading error). The "UI is open" baseline that the user reports works on Windows might just be coincident with them having Steam running for unrelated reasons. Verify by checking the bottle's process list for `steam.exe` during a working vs failing run. +2. **`.dlf` mismatch via hardware-hash.** When Path B runs `request_and_save_license` with `playing=Some`, the OOA license is bound to a hardware hash computed inside maxima-cli's process. If TF2's own internal validation computes a different hash (different process-time WMI reads, version-2 vs version-4 hash composition), the `.dlf` signature won't validate. Path A also calls `request_and_save_license`, but the LSX side returns an empty token under `playing()=None` so TF2 isn't told to validate via LSX. Validate by exporting `MAXIMA_DENUVO_TOKEN` to short-circuit license fetch entirely. +3. **A local file check tied to a missing registry / file artifact.** Possibly `C:\Program Files (x86)\Origin Games\Titanfall2\__Installer\` or some EA-Desktop-only marker file. Not investigated. -`start_game()` was calling `offer.is_installed()` which checks the EA library. Steam-installed TF2 is not in the EA library, so the check always failed with `LaunchError::NotInstalled`. Fixed by skipping the check when `path_override` is set. +### Update 2026-05-18 (later) — Origin in-game login window + still corrupting -#### Fixed — `EAExternalSource` / `EALaunchOwner` / `EAEntitlementSource` env vars +Once the bootstrap → /authorize → launch::start_game chain was wired end-to-end (with the OPAQUE→JWS fallback below), the user reports: -When `steam_launch` is true, these env vars are now set to `"Steam"` instead of `"EA"`. TF2 reads them to verify the launch context matches the entitlement source reported by LSX. +- TF2 actually launches now (no more "opens for a moment and closes"). +- TF2 then shows the **in-game Origin login window** (the deprecated EA launcher's embedded SSO prompt) asking for credentials. +- After logging in with EA credentials, TF2 proceeds and shows "Engine Error: File corruption detected" — same symptom as before. -#### Fixed — `GetAllGameInfo` returning wrong version (triggering "File corruption detected") +This is real progress: the LSX flow now completes the Challenge handshake (`Game Connected - Name: Titanfall2, Offer ID: Origin.OFR.50.0001456, Multiplayer Id: 1039093, Version: 9.12.1.3` lands in the log). What we don't yet know is which subsequent LSX request triggers the corruption error — the LSX request/response logs were `debug!` so they're invisible at default INFO level. -`GetAllGameInfoResponse` hardcoded `InstalledVersion="0"` and `AvailableVersion="1.0.1.3"`. TF2's current build is `9.12.1.3` — the mismatch triggered its tamper-detection dialog. Fixed by capturing the real version from the LSX challenge response (`message.version`) into `ConnectionState.game_version` and echoing it back in `GetAllGameInfoResponse`. `game_title` is also captured and echoed in `attr_DisplayName`. +Two distinct issues now: -#### Fixed — `IsSubscriber` / `IsSteamSubscriber` inconsistency +**Issue A — embedded Origin login window appears.** TF2's Origin DRM stub doesn't accept our SSO env vars (`EAGenericAuthToken` / `EAAccessTokenJWS` / `EALaunchUserAuthToken`) and falls back to its built-in login dialog. Root cause: EA's `nucleus_auth_exchange` rejects our JWS→OPAQUE swap with a redirect to `signin.ea.com/p/juno/login?fid=…` (treated as `AuthError::InvalidRedirect`). We added a `match`-with-fallback in `launch::start_game::LaunchMode::Online` so we pass the JWS access token through as `EALaunchUserAuthToken` instead of bailing — that's the pre-PR-#34 upstream behavior and it lets the launch proceed. The cost is that TF2's Origin SDK doesn't trust the JWS as if it were OPAQUE and shows its own login. Manual login through that window works as a workaround. -`GetProfileResponse` hardcoded both subscriber fields to `false`. When `EntitlementSource="STEAM"`, TF2 treats `IsSteamSubscriber=false` as a contradiction and triggers the tamper check. These fields are now set based on whether `SteamAppId` is set in the environment. +Likely root cause of the OPAQUE rejection: EA's auth service wants a session cookie from a recent SSO flow (which EA Desktop carries from its embedded browser). Our reqwest client is cookie-less and stateless, so EA treats the exchange as untrusted. Fixing this properly would require either persisting EA cookies across `maxima-cli` runs or pre-fetching the OPAQUE token at login time and caching it. -#### Fixed — `IsProgressiveInstallationAvailableResponse` hardcoded ItemId +**Issue B — "File corruption" after manual Origin login.** Same symptom as the prior session. Diagnostic this session: promoted the `Received/Queuing LSX Message` logs from `debug!` → `info!` in `lsx/connection.rs`, and changed `service.rs::"LSX connection closed"` to include the underlying error. The next test should produce a full LSX request/response trace + a real close reason, so we can see precisely which LSX request TF2 sends last before disconnecting. -Response hardcoded `attr_ItemId="Origin.OFR.50.0001456"`. Now echoes back `request.attr_ItemId`, making the handler correct for any game. +Pending validation steps (next session): -#### Fixed — `maxima-bootstrap` non-zero exit codes swallowed +1. **Capture the full LSX trace** — re-run with the latest binaries; the log should now show every request/response in `maxima-cli.log`. We expect to see the same sequence the prior session documented ending at `IsProgressiveInstallationAvailable`, or possibly stopping at an earlier request now that the EA env-var context is different. +2. **`MAXIMA_DENUVO_TOKEN` test** — set the env var on the `serve` process before invoking and re-launch TF2. If the symptom disappears, it's `.dlf` hash. If it persists, it's something else (Steam DRM IPC or local file integrity). +3. **Steam-running test** — open `steam.exe` inside the bottle (just the client UI) before clicking Play on TF2. If TF2 then works, Steam DRM IPC is the root cause. -After `wait().await`, the exit code was never checked. Added logging for non-zero exit codes to `maxima_execution.log`. +Do not delete this section until the user confirms TF2 runs end-to-end. -#### Ported — External LSX connections (Northstar / Steam launch) +**Next diagnostic steps if Path A doesn't fix it:** +1. Check that `…/EA Services/License/Origin.OFR.50.0001456_<...>.dlf` exists and is non-empty after a `serve` + Steam launch attempt. +2. Try the same launch with `MAXIMA_DENUVO_TOKEN=` set on the `serve` instance. +3. Diff the LSX trace from a working Windows session against the failing macOS session, especially `GetAllGameInfoResponse` fields. -Ported `catornot/Maxima@patch-external-lsx` (upstream PR #42 by p0358): when the game was not started through maxima-cli (`maxima.playing()` is `None`), `Connection::new()` now accepts the connection with a warning instead of returning `LSXConnectionError::GameContext`. `handle_set_presence_request` also handles the `playing() == None` case gracefully. This enables Northstar and any externally-launched game to maintain an LSX connection. +Do not delete this section until the user confirms TF2 launches reliably end-to-end. --- -## Open issues being investigated - -### "Engine Error: File corruption detected" after `IsProgressiveInstallationAvailableResponse` - -**Status as of 2026-05-15.** TF2 launched from Steam via Maxima completes authentication and gets through the LSX handshake up to `IsProgressiveInstallationAvailableResponse`, then closes the LSX connection and shows the error dialog. The game never reaches `RequestLicense` or `GetAuthCode`. - -**Full LSX sequence observed:** -``` -Challenge → ChallengeAccepted -GetConfig → GetConfigResponse -GetProfile → GetProfileResponse (IsSubscriber=true, IsSteamSubscriber=true) -GetSetting → GetSettingResponse -GetGameInfo → GetGameInfoResponse -GetAllGameInfo → GetAllGameInfoResponse (InstalledVersion=9.12.1.3, EntitlementSource=STEAM) -IsProgressiveInstallationAvailable → IsProgressiveInstallationAvailableResponse -[connection closed — no RequestLicense or GetAuthCode] -``` - -**What has been tried and ruled out:** -- Northstar hooks (`wsock32.dll`) — removed, same result -- `IsSubscriber=false` / `IsSteamSubscriber=false` — fixed to true, same result -- `InstalledVersion="0"` → real version — fixed, same result -- `ItemId` hardcoded vs echoed — fixed, same result -- `EAExternalSource=EA` vs `Steam` — tried both, same result +## Pending code quality items -**Hypothesis:** TF2 may be performing a local file-integrity check independently of LSX (DRM stub reading the install manifest or a `.dlf` file) before completing the LSX flow. The "File corruption detected" message may not be LSX-driven at all. The game closes LSX *after* the check fails, not *because* LSX responded incorrectly. +Tracked from PR #4 (Gemini review) and reaffirmed during the Session 2026-05-18 audit. Medium-priority. Address before publishing a release that other macOS users will rely on. -**Next steps when resuming:** -1. Check if `C:\ProgramData\Electronic Arts\EA Services\License\` contains a valid `.dlf` for `Origin.OFR.50.0001456`. -2. Check if `C:\Program Files (x86)\Origin Games\Titanfall2\__Installer\` or equivalent Steam path has a manifest that TF2 validates. -3. Try running with `MAXIMA_DENUVO_TOKEN` env override to skip the license server and see if the error still appears. -4. Compare what EA Desktop sends in `GetAllGameInfo` for a working installation — specifically `FullGamePurchased`, `FullGameReleased`, `HasExpiration`, and `UpToDate`. +1. **Blocking I/O inside async** — `std::fs::read_to_string` on `libraryfolders.vdf` runs on a tokio worker without `spawn_blocking`. Low impact (VDF is small, ms-scale) but technically a yield-point hazard. Fix in `maxima-lib/src/steam.rs::resolve_steam_install_path`. +2. **Hardcoded Steam install fallback** — `C:\Program Files (x86)\Steam` and `C:\Program Files\Steam` are tried unconditionally after the registry. Should be removed once we trust the registry lookup is reliable inside Wine. `maxima-lib/src/steam.rs`. +3. **`attr_EntitlementSource` still hardcoded `"STEAM"`** — `GetAllGameInfoResponse` always returns `"STEAM"` regardless of launch path. Should reflect `LaunchOptions.steam_launch` (when known) or default to `"EA"` for non-Steam EA games. `maxima-lib/src/lsx/request/game.rs`. +4. **`SteamAppId` env var used as IPC** — `GetProfile` reads `std::env::var("SteamAppId")` to decide `IsSubscriber`. Cleaner: add a `steam_launch: bool` field to `ConnectionState`, populate it from `ActiveGameContext` at connection init (or from a per-request hint), and read it from `state`. `maxima-lib/src/lsx/connection.rs` + `request/profile.rs`. +5. **`Mode::Launch` and `Mode::Serve` coexistence** — when both `launch` and `serve` run simultaneously in the same bottle, `launch`'s `start_lsx` probe correctly defers to `serve`'s LSX, but it still spawns the game and sets `playing=Some(...)` on its own `maxima_arc`. The game's traffic still goes to `serve` (good), but `launch`'s state is then stale (`playing` set but no LSX traffic to update it). Cosmetic; not a correctness issue. +6. **No retry / health-check loop in bootstrap's forward path** — if `/authorize` returns a transient 5xx (e.g. EA license server hiccup), bootstrap surfaces the error directly. TF2 will keep polling LSX regardless, so a retry from the user side works, but a 1-retry in bootstrap would be friendlier. --- -## Pending code quality items (from PR #4 Gemini review) - -These are medium-priority, not blocking. Address in a follow-up PR. +## Known remaining gaps -1. **Blocking I/O in async** — `std::fs::read_to_string` (libraryfolders.vdf parse) is called inside the `startup` async fn. Replace with `tokio::fs::read_to_string` or wrap in `tokio::task::spawn_blocking`. (`maxima-cli/src/main.rs`) -2. **Hardcoded SteamPath fallback** — `C:\Program Files (x86)\Steam` is used as fallback when registry lookup fails. Should use the resolved path from the registry lookup instead. (`maxima-cli/src/main.rs` — `resolve_steam_install_path`) -3. **`attr_EntitlementSource` hardcoded to `"STEAM"`** — `GetAllGameInfoResponse` always returns `"STEAM"`. Should be dynamic: `"STEAM"` when `steam_launch=true`, `"EA"` otherwise, to avoid tamper-check failures in non-Steam EA games. (`maxima-lib/src/lsx/request/game.rs`) -4. **`SteamAppId` env var used as IPC in LSX handler** — `GetProfile` reads `std::env::var("SteamAppId")` to decide `IsSubscriber`. This relies on global process state. Cleaner approach: add a `steam_launch: bool` field to `ConnectionState`, populate it from `ActiveGameContext` at connection init, and read it from `state` in the handler. (`maxima-lib/src/lsx/connection.rs`, `maxima-lib/src/lsx/request/profile.rs`) +- **`maxima-tui` / `maxima-ui`** — built and shipped in the installer, but Draconis doesn't invoke them. The UI doesn't have `/authorize` wired up yet (would be a one-liner in `bridge_thread`); for now only `maxima-cli serve` provides the auth server. If we want a graphical "Maxima is running" indicator on macOS/CrossOver, that's the obvious next step. +- **`origin2://` without an `offerIds` param** — the handler passes an empty string and the auth server returns 400. A better fallback (e.g. reading `productId`, or per-game hardcoded table) is a future improvement. +- **DLL injection on macOS / CrossOver** — `maxima-service`'s injector is Windows-only by design. Wine doesn't support `CreateRemoteThread`-style injection. The service is installed by NSIS but its injection path is never exercised in the Draconis flow. +- **Cloud saves, downloads, friends** — implemented upstream and present in the codebase, but untested in the Draconis / CrossOver configuration. +- **Offline mode after first launch** — `LaunchMode::Offline` path exists but Draconis doesn't expose it. License cache lives at `C:/ProgramData/Electronic Arts/EA Services/License/.dlf` and is valid for approximately two weeks. +- **`STEAM_GAMES` table is TF2-only** — `lookup_steam_game(steam_app_id)` only has an entry for `1237970`. Other EA-on-Steam titles would not resolve via the fallback. Extend per title we validate. +- **No registry-driven UI-vs-CLI auth provider selector** — the user previously proposed `HKLM\Software\Maxima\AuthProvider = "UI"|"CLI"` that bootstrap would read when no auth server is running. Not implemented; the current fallback path simply spawns `maxima-cli launch` unconditionally. Becomes meaningful once we want bootstrap to auto-start `serve` if it can't find one running. +- **Auth-server endpoint not on UI yet** — `maxima.exe` doesn't bring up `/authorize`. If a user runs the UI without `serve`, bootstrap falls through to Path B (spawn). Easy fix; just hasn't been wired. +- **TF2's LSX-polling timeout, if any, is undocumented.** Path A relies on TF2 retrying indefinitely while bootstrap forwards. If TF2 has a finite timeout (we suspect it doesn't but haven't measured), `serve` cold-starts could miss the window. --- -## Open issues being investigated - -### v0.2.1 — nothing appears on TF2 launch from Steam (UI/TUI/CLI all invisible) - -**Reported 2026-05-14.** User on v0.2.1 (latest, with `AllocConsole` fix in `maxima-cli` and full UI/TUI in installer). When launching TF2 from Steam, no Maxima window appears at all — not the CLI console, not the UI, not the TUI. In earlier versions the CLI window at least popped up. Diagnostics from the user are still pending. - -**Code audit conducted 2026-05-14. Findings, in priority order (each independently capable of producing the reported symptom):** - -#### Finding 1 — NSIS `SetRegView` chaos (most likely root cause) +## Operator recipes -[installer/maxima-setup.nsi:173](installer/maxima-setup.nsi:173) sets `SetRegView 64` for the `HKLM\SOFTWARE\WOW6432Node\Origin` writes and **never resets it** before the HKCR protocol writes at lines ~186-201 or the `BackupProtocol` calls at lines ~176-178. Consequences: +### First-time setup in a fresh CrossOver bottle -- 32-bit Wine consumers (TF2, Origin, anything emitting `link2ea://` from a 32-bit process) resolve HKCR through the **32-bit view** (`HKLM\Software\Classes\Wow6432Node\...`). They never see the entries v0.2.1 wrote under the 64-bit view. -- `BackupProtocol` (lines 58-79) and `RestoreProtocol` (lines 83-107) inherit whatever view leaked in from the caller — i.e. they back up the 64-bit view and miss any 32-bit-view registrations entirely. On a v0.2.0→v0.2.1 upgrade this means the v0.2.0 entries (written under whatever view the old `.nsi` used — most likely 32-bit by default for a 32-bit NSIS installer) are never read, never overwritten, never even noticed. -- Possible end state after upgrade: v0.2.1's "Maxima" entries are in 64-bit view; TF2 still resolves via 32-bit view and finds v0.2.0's stale entries (or nothing at all if the old uninstaller fired). Either way the dispatch is unpredictable and on at least some setups Wine ends up with no working handler. - -**Fix:** In [installer/maxima-setup.nsi](installer/maxima-setup.nsi): -- Add `SetRegView 32` (or `SetRegView default`) immediately before line 186 (HKCR writes) and before line 176-178 (`BackupProtocol` calls). -- Inside both `BackupProtocol` and `RestoreProtocol` macros, set the view explicitly at entry — ideally back up *both* views and document the choice. -- In `BackupProtocol`, also guard against backing up Maxima's own values: read the existing `\shell\open\command` first, and if it points at `maxima-bootstrap.exe`, set `_existed=0` (treat as "no prior handler") so the uninstaller will simply delete the keys instead of restoring stale Maxima paths. - -#### Finding 2 — `AllocConsole()` does not reattach stdio - -[maxima-cli/src/main.rs:140-150](maxima-cli/src/main.rs:140) calls `AllocConsole()` but never reattaches `STD_OUTPUT_HANDLE` / `STD_ERROR_HANDLE` / Rust's stdio FDs. When bootstrap (GUI subsystem) spawns maxima-cli, the child inherits invalid stdio handles. `AllocConsole` creates a window but does NOT redirect existing handles — meaning the console pops up but `println!` and the `log.rs` writer at line 89 write to dead pipes. User sees a blank/silent console, or (depending on timing) no console at all. - -**Fix:** After `AllocConsole()` succeeds, do `SetStdHandle(STD_OUTPUT_HANDLE, CreateFileW(L"CONOUT$", GENERIC_READ|GENERIC_WRITE, ...))` for stdout and stderr, and call `freopen("CONOUT$", "w", ...)` equivalents for the C runtime (Rust's stdout/stderr are buffered on top of these FDs). Without this, AllocConsole is decorative only. - -#### Finding 3 — `#[tokio::main]` runs before `ensure_console_attached` - -[maxima-cli/src/main.rs:155](maxima-cli/src/main.rs:155): `#[tokio::main]` desugars to runtime construction *before* user code. Any panic in IOCP/thread-pool init under Wine kills the process before AllocConsole. Same risk applies to `init_logger_named`, which runs after `Args::parse()` at line 246. - -**Fix:** Convert `main` to a plain `fn main()` that (1) calls `ensure_console_attached()`, (2) installs a panic hook that writes to `%LOCALAPPDATA%\Maxima\Logs\maxima-cli.panic.log`, (3) calls `init_logger_named()`, (4) parses args, (5) builds the tokio runtime manually with `tokio::runtime::Runtime::new()` and `block_on(startup())`. This guarantees console + file logger exist before *anything* fallible runs. - -#### Finding 4 — `Args::parse()` before logger init - -If clap exits because of a malformed argv (or because the `MAXIMA_LAUNCH_ARGS` env var is mis-encoded), the error message goes to stderr — which is the unattached pipe. User sees nothing, no log line on disk. Init the logger first, then parse args. - -#### Finding 5 — Bootstrap swallows non-zero exit codes +```bash +# 1. Install MaximaSetup.exe inside the bottle (Draconis does this automatically; +# if doing it by hand, copy the .exe from a release and run it). +# The installer registers link2ea://, origin2://, qrc:// in Wine's registry +# and drops the binaries in C:\Program Files\Maxima\. + +# 2. On the macOS host, install / register MaximaHelper.app for qrc://. Draconis +# does this with `codesign --force --deep --sign -` + `NSWorkspace`. + +# 3. Inside the bottle, run maxima-cli once interactively to do OAuth login. +maxima-cli.exe +# → "Welcome to Maxima!" menu → Launch Game (any) → browser opens → log in → +# redirect comes back via qrc:// → MaximaHelper forwards → :31033 captures the +# auth code → token stored. +# (Or: paste a `remid` cookie value at the stdin prompt if the browser is stuck.) +``` -[maxima-bootstrap/src/main.rs:271](maxima-bootstrap/src/main.rs:271): `child.spawn()?.wait().await?` only propagates an error on `wait()` itself; a non-zero exit from maxima-cli returns `Ok(ExitStatus)` and bootstrap logs "Result: Success". If maxima-cli crashes or auth fails, bootstrap pretends everything worked. Not the root cause but blinds diagnostics — every other finding becomes invisible because we can't tell whether maxima-cli even ran. +After that the bottle has a persistent token. You never need to log in interactively again until it expires (months). -**Fix:** After `wait().await?`, check `status.success()` and log the exit code to `maxima_execution.log` if non-zero. +### Run `serve` and play -#### Ruled out +```bash +# Terminal 1 (inside the bottle): +maxima-cli.exe serve +# Expected console lines (and the same go to %LOCALAPPDATA%\Maxima\Logs\maxima-cli.log): +# LSX server listening on port 3216 +# Authorize HTTP server listening on 127.0.0.1:13219 +# Subscribed to N friends for presence (omit with --no-rtm) +# Serving LSX. Launch your game externally; press Ctrl-C to stop. +``` -- `is_valid_ea_offer_id("Origin.OFR.50.0002694")` returns `true` — leading zeros are fine. -- `url::Url::parse("link2ea://launchgame/Origin.OFR.50.0002694?...").path_segments()` correctly yields `["Origin.OFR.50.0002694"]` (the action is the host, not a path segment). `segments[0]` is the offer id, as intended. -- `LOG_FILE` mutex deadlock — not a realistic failure mode; lock scope is short and not reentrant. -- `winapi` features `consoleapi` / `wincon` are correctly enabled in [maxima-cli/Cargo.toml:22](maxima-cli/Cargo.toml:22). +Then launch TF2 any way you want: +- Draconis (vanilla or Northstar) +- Steam → Library → Titanfall 2 → Play +- `cxstart --bottle "Titanfall 2" -- "C:\\Program Files\\…\\Titanfall2.exe"` -#### Recommended order of work when picking this up +When TF2 emits `link2ea://`, bootstrap forwards to the running `serve` and exits; TF2's LSX polling reaches `serve`'s listener; auth completes. -1. Ship Finding 5 (bootstrap exit-code logging) first — it's a one-liner and makes every subsequent change debuggable. -2. Then Finding 1 (NSIS `SetRegView` reset + Maxima-ownership guard in `BackupProtocol`). High likelihood of being the user-facing root cause. -3. Then Findings 2+3+4 together (rewrite the prologue of `maxima-cli/main.rs`: plain main, panic hook, logger before args, manual tokio runtime, stdio reattach after AllocConsole). -4. Have the user share the contents of `maxima_execution.log` and `%LOCALAPPDATA%\Maxima\Logs\maxima-cli.log` after the fixes ship — those should now be populated and tell us whether the symptom is gone or whether a new layer is exposed. +### Fallback: no `serve` running -Do not delete this section until the user confirms TF2 launches with Maxima visible again. +`maxima-cli.exe launch Origin.OFR.50.0001456` (or any Steam App ID Maxima knows about, e.g. `1237970`) drops you into Path B. This is what bootstrap auto-spawns when `/authorize` doesn't answer. --- -## Known remaining gaps - -- **`maxima-tui` / `maxima-ui`**: The UI crates exist and compile but are not wired into the Draconis flow at all. They are upstream components that may be useful in a future Draconis "standalone mode" but need significant work to be production-ready. -- **`origin2://` without an `offerIds` param**: If the URL has no `offerIds` the handler now passes an empty string to `maxima-cli`, which will fail gracefully but not helpfully. A better fallback (e.g. reading from query params `productId` or hardcoding a per-game table) is a future improvement. -- **DLL injection on macOS / CrossOver**: `maxima-service`'s DLL injector is Windows-only by design. CrossOver / Wine does not support `CreateRemoteThread` injection. The service is installed by the NSIS installer but its injection path is never exercised in the Draconis flow. -- **Cloud saves, downloads, friends**: All implemented upstream and present in the codebase, but untested in the Draconis / CrossOver configuration. -- **Offline mode after first launch**: The `LaunchMode::Offline` path exists but Draconis does not yet expose it in the UI. License cache lives at `C:/ProgramData/Maxima/Licenses/.dlf` and is valid for approximately two weeks. -- **Steam-only games table is TF2-only**: `STEAM_GAMES` in `maxima-cli` only has an entry for TF2 (`1237970`). Other EA games launched via Steam App ID would not be recognized. Extend the table or add a dynamic fallback for other titles. +## Changelog (most recent first) + +History of significant changes since this fork was forked. Not a substitute for `git log` but useful for "when did X land" questions. + +### 2026-05-18 — split-brain auth: bootstrap as router, `/authorize` as service (with launch) + +The whole "Path A" infrastructure landed in this session, replacing the previous attempt where the bootstrap-spawned `maxima-cli launch` would try to coexist with `serve` and lose the LSX-port race under Wine. + +- **New module `maxima-lib/src/auth_server.rs`** (~250 lines). Plain `tokio::net::TcpListener` + manual HTTP parse. `GET /` → liveness probe; `POST /authorize?offer_id=X[&cmd_params=…]` → call `launch::start_game` (license preflight + EA env vars + spawn game + set `maxima.playing=Some`). Default port 13219. Initially shipped as preflight-only (no spawn); reworked mid-session after empirical evidence showed TF2 exits after emitting `link2ea://` and needs to be re-launched, not just authenticated. Now aligned with upstream issue #27's design intent. +- **New module `maxima-lib/src/steam.rs`** (~180 lines). Lifted `STEAM_GAMES`, `lookup_steam_game`, `resolve_steam_install_path`, `EA_OFFER_ID_PATTERN`, `STEAM_APP_ID_PATTERN` out of `maxima-cli/src/main.rs`. Added `lookup_steam_game_by_offer` (reverse: `Origin.OFR.…` → entry) because `/authorize` receives offer IDs, not Steam App IDs. +- **`Maxima::start_auth_server`** in `maxima-lib/src/core/mod.rs`. Companion to `start_lsx`. Reads `MAXIMA_AUTHORIZE_PORT` for override. +- **`maxima-bootstrap/src/main.rs`** rewrite of `link2ea://` + `origin2://` handlers: + - Deduplicated into a single `handle_protocol_authorize(offer_id, cmd_params, protocol_name)` helper. + - Probes 127.0.0.1:13219 with `std::net::TcpStream::connect_timeout(200ms)`. + - If alive: `reqwest::Client::post(http://…/authorize?offer_id=…&cmd_params=…)` with 60s timeout. 2xx → success, 4xx/5xx → surface as error (no fallthrough spawn — server made a deliberate decision). + - If dead: spawn `maxima-cli.exe launch ` (legacy Path B preserved). + - New `log_event` helper writes structured lines to `%TEMP%/maxima_execution.log`. +- **`maxima-cli/src/main.rs::serve_lsx`** now calls `maxima.start_auth_server` after `start_lsx`. Best-effort: failure logs a warning and `serve` keeps going with LSX alone. The park loop ticks `maxima.update()` once per second so `update_playing_status` can detect game exit and run cloud-save sync. +- **`maxima-cli/Cargo.toml`** dropped `winreg` (only used by Steam helpers, now in `maxima-lib`). +- **`maxima-lib/Cargo.toml`** added `urlencoding`. +- **`maxima-bootstrap`** imports `AUTHORIZE_PORT` from `maxima-lib` instead of duplicating the constant. + +### 2026-05-16 — `serve` mode + `start_lsx` probe + defensive license.rs + +- **`Mode::Serve { no_rtm }`** subcommand added to `maxima-cli`. Long-running auth-only mode: logs in, starts LSX, optionally RTM, parks indefinitely. +- **`Maxima::start_lsx`** now probes `127.0.0.1:` with a 200ms TCP timeout before binding. If a server is already listening it logs and returns without binding. Prevents the bootstrap-spawned `maxima-cli launch` from racing the existing `serve` for the LSX socket. +- **`maxima-lib/src/lsx/request/license.rs`** — `playing().as_ref().unwrap()` replaced with `let Some(playing) = … else { return empty-token }`. Mirrors the pattern `handle_set_presence_request` already had since the catornot patch. With this, externally-launched games (Steam direct, Northstar) that hit `RequestLicense` get a graceful empty-token response instead of crashing the spawned LSX task. +- Added `maxima-cli serve` operator recipe to CLAUDE.md. + +### 2026-05-15 — Steam App ID launch support + LSX response fixes (PR #4) + +- **Bootstrap** — accept Steam App IDs (pure numeric) in addition to `Origin.OFR..`. Previously rejected, so Steam-launched titles silently no-op'd. +- **`maxima-cli`** — `STEAM_GAMES` table maps Steam App ID → Origin offer ID + install subdir; auto-discovers Steam install via registry + `libraryfolders.vdf`; sets `SteamAppId` / `SteamGameId` / `SteamClientLaunch` / `SteamPath` env vars; auto-injects `-noOriginStartup -multiple` launch args. +- **`launch::start_game`** — skip `offer.is_installed()` check when `path_override` is supplied. Adds conditional `"Steam"` vs `"EA"` for `EAEntitlementSource` / `EAExternalSource` / `EALaunchOwner`. +- **`GetAllGameInfoResponse`** — captures real `Version` and `Title` from the LSX Challenge handshake (was hardcoded `"0"` / `"1.0.1.3"` / `Titanfall® 2 Deluxe Edition`). +- **`GetProfileResponse`** — `attr_IsSubscriber` / `attr_IsSteamSubscriber` reflect `env::var("SteamAppId")` presence. +- **`IsProgressiveInstallationAvailableResponse`** — echoes the request's `attr_ItemId` instead of hardcoded TF2 offer. +- **`handle_set_presence_request`** — graceful no-op when `playing()=None` (catornot patch, applied here). +- **`Connection::new`** — accepts external LSX connections (catornot patch). +- **Bootstrap exit codes** — non-zero exits from `maxima-cli` now propagate as errors instead of being logged as "Success". + +### 2026-05-14 — console visibility, NSIS registry view, full installer (PRs #1–#3) + +- **`maxima-cli`** — `AllocConsole()` + `SetStdHandle("CONOUT$" / "CONIN$")` so the CLI is actually visible when bootstrap (GUI subsystem) spawns it. Panic hook to `%LOCALAPPDATA%\Maxima\Logs\maxima-cli.panic.log`. Plain `fn main()` + manual tokio runtime so the panic hook is installed before anything fallible. `init_logger_named` for per-binary log filenames. +- **`maxima-bootstrap`** — `link2ea://` URL parsing implemented (was `todo!()`); `origin2://` reads real `offerIds` from URL (was hardcoded BF2 offer); `qrc://` no longer panics on missing marker; offer-id shape validation defends against `--login=` flag injection. +- **`maxima-lib/src/util/log.rs`** — always-on file sink in addition to stdout; default `%LOCALAPPDATA%\Maxima\Logs\.log`. +- **NSIS installer** (`installer/maxima-setup.nsi`) — full rewrite. `SetRegView` properly reset before HKCR writes (avoids 32-vs-64-bit view collision). `BackupProtocol` guards against backing up Maxima's own values. Cross-compiled via `mingw-w64` + `nsis` from macOS. +- **CI** — `build-ci.yml` matrix expanded to Linux+Windows+macOS; Linux restricted to `-p maxima-cli -p maxima-bootstrap` (UI/TUI excluded due to rustix 0.37 incompatibility on nightly); Windows builds full workspace + NSIS. `release.yml` builds helper on macOS + installer on Windows + assembles GitHub release on Ubuntu. +- **`maxima-lib/src/util/dll_injector.rs`** — `GetModuleHandleW` / `LoadLibraryW` + UTF-16 paths (from upstream `fix/non-ascii-characters`). +- **`maxima-lib/src/unix/wine.rs`** — bare `HKLM\Software\Origin` registry entry, `regedit /S`, stderr captured. +- **`maxima-lib/src/core/launch.rs`** — `OnlineOffline` mode calls `needs_license_update` (from upstream `fix/license-update-online-offline`); `LaunchMode::Offline` implemented (was `todo!()`). +- **`maxima-lib/src/lsx/request/license.rs`** — `MAXIMA_DENUVO_TOKEN` env override (from upstream `feat/license-token-override`). +- **`maxima-cli launch`** — Steam-only owner passthrough (warn + try anyway when slug matches `Origin.OFR..` but EA library doesn't know it); `GetGameBySlug` subcommand body restored (was a no-op stub upstream). + +### Earlier — initial fork + +Native Swift `MaximaHelper.app` replacing the upstream AppleScript helper; NSIS installer cross-compiled via mingw + nsis; release CI; PR template + upstream-PR guard; CLAUDE.md / README scope-narrowed to macOS/CrossOver + TF2. diff --git a/README.md b/README.md index 7d47dc9..0eb417a 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@

Maxima-Draconis

- EA authentication and launch backend for Draconis. THIS IS IN BETA, IT DOES NOT WORK CURRENTLY + EA authentication and launch backend for Draconis — Titanfall 2 on macOS via CrossOver / Wine.

@@ -17,9 +17,11 @@ --- > [!WARNING] -> This is a **fork** of [ArmchairDevelopers/Maxima](https://github.com/ArmchairDevelopers/Maxima) primarily maintained for [Draconis](https://github.com/AA-EION/Draconis) on macOS/CrossOver. The code is still portable to the other OSes upstream supports (native Windows + Linux), but only the macOS/CrossOver path is actively tested. If you want a vanilla build on Linux or native Windows, the upstream repo may be a better fit. +> **This is a beta. Titanfall 2 doesn't reliably launch yet under this configuration** — the LSX/authorize plumbing works end-to-end but TF2 still shows "Engine Error: File corruption detected" in our reference test environment. The likely root cause (Steam DRM IPC or hardware-hash mismatch in the `.dlf` license file) is documented in [`CLAUDE.md`](./CLAUDE.md) and we're actively iterating. +> +> This fork is a fork of [ArmchairDevelopers/Maxima](https://github.com/ArmchairDevelopers/Maxima), primarily maintained for [Draconis](https://github.com/AA-EION/Draconis) on macOS/CrossOver. The code is still portable to the other OSes upstream supports (native Linux + Windows), but only the macOS/CrossOver path is actively tested. If you want vanilla Maxima on Linux or native Windows, **the upstream repo is a better fit.** -**Maxima-Draconis is an open-source (Mostly Vibecoded, Cringe, I know) replacement for the EA Desktop Launcher.** It handles the EA authentication handshake and license resolution that EA-published games require at startup. On macOS, it runs entirely **inside a CrossOver or Wine bottle** — it is a Windows application, not a native Mac app. The only Mac-native piece is `MaximaHelper.app`, a lightweight background agent that bridges EA's `qrc://` OAuth redirect from your browser into the bottle. +**Maxima-Draconis is an open-source replacement for the EA Desktop / Origin launcher.** It handles the EA authentication handshake and license resolution that EA-published games (specifically: Titanfall 2) require at startup. On macOS, it runs entirely **inside a CrossOver or Wine bottle** — it is a Windows application, not a native Mac app. The only Mac-native piece is `MaximaHelper.app`, a lightweight background agent that bridges EA's `qrc://` OAuth redirect from your browser into the bottle. --- @@ -32,72 +34,130 @@ macOS host │ └── MaximaHelper.app ← bridges qrc:// OAuth from browser → Wine │ └── CrossOver bottle - ├── maxima-bootstrap.exe ← catches link2ea:// and origin2:// URIs - ├── maxima-cli.exe ← authenticates with EA, resolves the license - └── Titanfall2.exe + ├── maxima-cli.exe ← `serve` mode: long-running auth (LSX + /authorize) + ├── maxima-bootstrap.exe ← protocol handler shim (link2ea:// / origin2:// / qrc://) + ├── maxima-service.exe ← background Windows service (DLL injection, currently unused on Wine) + └── Titanfall2.exe ← the game itself, launched directly by Steam or Draconis ``` -**Launch sequence:** +--- + +## Architecture (in one paragraph) -1. Draconis starts `Titanfall2.exe` (or `steam.exe -applaunch 1237970 -northstar` for Northstar). -2. The game emits `link2ea://launchgame/Origin.OFR.50.0002694?...` to request EA auth. -3. Wine routes that URI to `maxima-bootstrap.exe` (registered by the installer). -4. `maxima-bootstrap` calls `maxima-cli launch Origin.OFR.50.0002694`. -5. `maxima-cli` logs into EA (OAuth via browser if needed — `MaximaHelper.app` handles the `qrc://` redirect back into Wine), fetches the Denuvo license token, and feeds it to the game via LSX. -6. Titanfall 2 launches. +`maxima-cli serve` runs in the bottle as a long-lived auth provider. It owns two ports: **LSX on 3216** (what the game uses for auth) and **HTTP `/authorize` on 13219** (what `maxima-bootstrap` forwards to when a `link2ea://` or `origin2://` URL fires). Old EA-on-Steam games like Titanfall 2 emit `link2ea://` and **exit**, expecting whoever handles the URL to re-launch them with the EA auth context populated. Our flow: Steam/Draconis starts `Titanfall2.exe`, TF2 emits `link2ea://`, Wine routes to `maxima-bootstrap.exe`. Bootstrap probes `:13219`, forwards the offer ID to `/authorize`, and exits. The server (a) refreshes the OOA license `.dlf` on disk, (b) sets the EA-* env vars TF2 needs, and (c) spawns a fresh `Titanfall2.exe` with that environment via `launch::start_game` — the same code path the upstream UI's Play button uses. TF2 then connects to the LSX server, completes the handshake, and runs. If `serve` isn't running, bootstrap falls back to the legacy upstream behavior (spawn `maxima-cli launch ` which does the full bootstrap from cold). -For Northstar mode the same auth chain fires after Steam starts the game. +This split is in line with the design described in upstream issue [#27 "Support launching Maxima from Epic/Steam"](https://github.com/ArmchairDevelopers/Maxima/issues/27): a long-running auth server + a thin protocol-handler shim. There's a deeper writeup with sequence diagrams in [`CLAUDE.md`](./CLAUDE.md). --- ## What this fork adds over upstream +### macOS / CrossOver infrastructure + +| Change | Detail | +|--------|--------| +| **`MaximaHelper.app`** | Native Swift background agent for macOS — replaces upstream's AppleScript helper. Properly bundle-signable so LaunchServices honors its `qrc://` URL handler claim. Universal arm64 + x86_64. | +| **NSIS installer** | `installer/maxima-setup.nsi` + cross-build via `mingw-w64` + `nsis` from macOS. Registers `link2ea://`, `origin2://`, `qrc://` in Wine's registry with proper backup/restore semantics; drops all five `.exe` binaries into the bottle's `Program Files\Maxima`. | +| **CI / release pipeline** | `.github/workflows/release.yml` — three-job tag-driven pipeline: macOS builds `MaximaHelper.app`, Windows builds `MaximaSetup.exe` and the loose binaries, Ubuntu assembles the GitHub release. Draconis pulls the assets automatically at build time. | +| **`.github/workflows/build-ci.yml`** | Three-OS push CI (Linux / Windows / macOS) keeping all three code paths honest. | +| **`.github/workflows/block-upstream-pr.yml`** | Guard against accidentally PR-ing fork-specific changes into upstream. | + +### Code changes (could be sent upstream) + | Change | Detail | |--------|--------| -| **`MaximaHelper.app`** | Native Swift background agent for macOS — replaces the old AppleScript helper. Properly bundle-signed so LaunchServices registers `qrc://`. | -| **NSIS installer** | Cross-compiled from macOS via `mingw-w64` + `nsis`. Registers all three protocol handlers (`link2ea://`, `origin2://`, `qrc://`) inside Wine. | -| **Release CI** | GitHub Actions workflow that builds and publishes `MaximaHelper.zip` + `MaximaSetup.exe` — Draconis fetches these automatically at build time. | -| **Steam-only owner passthrough** | When the EA library lookup fails but the slug is already a valid offer ID (`Origin.OFR.X.Y`), Maxima now passes it directly to EA's license server instead of bailing. Useful when TF2 is owned through Steam only and the accounts aren't linked. | -| **`origin2://` fix** | The upstream handler hardcoded the Star Wars Battlefront 2 offer ID. Fixed to read the actual `offerIds` from the URL — any EA title can now use `origin2://`. | -| **Wine registry** | Added the bare `HKEY_LOCAL_MACHINE\Software\Origin` key (without `Electronic Arts\` prefix) that some games require. `regedit` now runs silently (`/S`) with stderr captured, so Wine errors appear in logs instead of hanging silently. | -| **DLL injector wide strings** | Fixed `GetModuleHandleA` / `LoadLibraryA` → `GetModuleHandleW` / `LoadLibraryW`. DLL injection no longer breaks on non-ASCII installation paths. | +| **`maxima-cli serve` mode** | Long-running auth-only subcommand. Starts LSX + the new `/authorize` HTTP server, optionally logs into RTM for friends presence, parks until Ctrl-C. Decouples authentication from game-spawning. | +| **`maxima-lib/src/auth_server.rs`** | New HTTP server: `GET /` (liveness probe) and `POST /authorize?offer_id=X[&cmd_params=…]` (license refresh + EA env var setup + game spawn via `launch::start_game`). Aligned with upstream issue #27's "long-running auth provider + thin protocol handler" design. Port 13219, overridable via `MAXIMA_AUTHORIZE_PORT`. | +| **Bootstrap: probe + forward** | `link2ea://` and `origin2://` handlers now probe the auth server before doing anything. If it's up, they forward via HTTP and exit. If it's down, they fall back to the upstream behavior (`maxima-cli launch `). Deduplicated into a single helper that handles both protocols. | +| **`Maxima::start_lsx` probe** | Before binding port 3216, checks if anyone else is listening. If yes, defers to them. Prevents the bootstrap-spawned `maxima-cli launch` from racing the existing `serve` for the LSX socket under Wine. | +| **Defensive LSX handlers** | `handle_license_request`, `handle_set_presence_request` no longer panic when `maxima.playing()` is None (i.e. game launched externally, not via Maxima). Returns sensible defaults so TF2's polling reconnects gracefully. Extension of `catornot/Maxima@patch-external-lsx` to the license path. | +| **Steam App ID support** | `STEAM_GAMES` table (currently TF2-only) + `resolve_steam_install_path` (registry + `libraryfolders.vdf` parser). Lets `maxima-cli launch 1237970` work for Steam-only owners whose EA account isn't linked. | +| **Real game version in `GetAllGameInfo`** | Captures `Version` from the LSX Challenge handshake and echoes it in `GetAllGameInfoResponse.InstalledVersion` / `AvailableVersion` (was hardcoded `1.0.1.3`). | +| **Bootstrap protocol-handler hardening** | All three protocols (`link2ea://`, `origin2://`, `qrc://`) now validate input shapes and defend against CLI flag injection (`--login=stolen_token` etc.). `origin2://` reads real `offerIds` from the URL instead of upstream's hardcoded Battlefront 2 offer. | +| **`maxima-cli` visibility under bootstrap** | `AllocConsole()` + `SetStdHandle("CONOUT$" / "CONIN$")` so the CLI is actually readable when bootstrap (GUI subsystem) spawns it. Panic hook to a dedicated log file. | +| **Persistent file logger** | Every binary mirrors its log to `%LOCALAPPDATA%\Maxima\Logs\.log` in addition to stdout. Each session writes a `===== maxima log session opened (pid=...) =====` header. Overridable via `MAXIMA_LOG_FILE`. | +| **Wine registry parity** | Added bare `HKLM\Software\Origin` registry entry that some EA titles check; `regedit` runs silently (`/S`); stderr captured. | +| **DLL injector wide-strings** | `GetModuleHandleA` / `LoadLibraryA` → `GetModuleHandleW` / `LoadLibraryW`. Handles non-ASCII install paths. From upstream `fix/non-ascii-characters`. | +| **License env override** | `MAXIMA_DENUVO_TOKEN` short-circuits the LSX `RequestLicense` handler and returns the token directly. Useful for offline debugging. From upstream `feat/license-token-override`. | +| **License-update parity** | `OnlineOffline` mode now calls `needs_license_update()` before re-requesting, matching `Online` mode. From upstream `fix/license-update-online-offline`. | +| **Offline mode** | `LaunchMode::Offline` implemented (was `todo!()` upstream). Looks up the offer from the library and sets `EALaunchOfflineMode=true`. Not yet exposed in Draconis UI. | + +### Removed + +- The original AppleScript-based macOS helper. Replaced by `MaximaHelper.app`. --- -## Setup (manual — Draconis automates this) +## Quickstart (manual — Draconis automates this) -> If you are using Draconis v0.4.0+, you don't need to do any of this manually. Draconis downloads `MaximaSetup.exe` from the latest release of this repo and installs it into your bottle automatically. +> If you're using Draconis v0.4.0+, you don't need to run any of this manually. Draconis downloads `MaximaSetup.exe` and `MaximaHelper.zip` from the latest release of this repo and installs them automatically. -**Prerequisites:** Xcode Command Line Tools (`xcode-select --install`), `brew install mingw-w64 nsis`. +**Prerequisites (macOS):** Xcode Command Line Tools (`xcode-select --install`), `brew install mingw-w64 nsis`, Rust nightly with the Windows GNU target (`rustup target add --toolchain nightly x86_64-pc-windows-gnu`). ```bash -# 1. Build MaximaHelper.app (runs on macOS host, bridges qrc:// OAuth) +# 1. Build MaximaHelper.app (host-side qrc:// bridge) bash MaximaHelper/build.sh -# 2. Cross-compile the Windows installer +# 2. Cross-compile MaximaSetup.exe (mingw-w64 + nsis) bash installer/build.sh -# → produces installer/MaximaSetup.exe +# → installer/MaximaSetup.exe + +# 3. Run MaximaSetup.exe inside your CrossOver bottle. +# It drops maxima-cli, maxima-bootstrap, maxima-service, maxima.exe (UI), +# maxima-tui.exe (TUI), and registers the protocol handlers in Wine's registry. -# 3. Run MaximaSetup.exe inside your CrossOver bottle -# It installs maxima-cli, maxima-bootstrap, maxima-service, -# and registers link2ea://, origin2://, and qrc:// in Wine's registry. +# 4. Inside the bottle, run maxima-cli once interactively to do OAuth login. +# This stores a refresh token so subsequent `serve` runs are non-interactive. +maxima-cli.exe +# → Login Game (any) → browser opens → log in → MaximaHelper forwards +# the redirect back into the bottle → token saved. ``` --- +## How to run it + +Once setup is done, the recommended flow for Draconis / TF2 is: + +```bash +# Terminal 1 — leave this running: +maxima-cli.exe serve +# Look for: +# LSX server listening on port 3216 +# Authorize HTTP server listening on 127.0.0.1:13219 +# Serving LSX. Launch your game externally; press Ctrl-C to stop. + +# Then launch the game any way you like: +# - Draconis → Play (vanilla or Northstar) +# - Steam → Library → Titanfall 2 → Play +# - cxstart --bottle "Titanfall 2" -- "C:\\…\\Titanfall2.exe" +``` + +When TF2 emits `link2ea://`, bootstrap probes the auth server, forwards the offer ID, exits. `serve` refreshes the `.dlf` license. TF2's polling loop reconnects to `serve`'s LSX and completes the auth handshake. + +**Fallback (no `serve` running):** `maxima-cli.exe launch Origin.OFR.50.0001456` or `maxima-cli.exe launch 1237970` (Steam App ID — uses the `STEAM_GAMES` table) does the orchestrated upstream-style launch. This is what bootstrap auto-spawns when `/authorize` doesn't answer. + +--- + ## Building from source ```bash -# Windows binaries (cross-compiled on macOS) -cargo build --release --target x86_64-pc-windows-gnu -p maxima-cli -cargo build --release --target x86_64-pc-windows-gnu -p maxima-bootstrap -cargo build --release --target x86_64-pc-windows-gnu -p maxima-service +# Single binary (Windows cross-compile) +cargo +nightly build --release --target x86_64-pc-windows-gnu -p maxima-cli +cargo +nightly build --release --target x86_64-pc-windows-gnu -p maxima-bootstrap +cargo +nightly build --release --target x86_64-pc-windows-gnu -p maxima-service + +# Full workspace (UI + TUI + lib + all) +cargo +nightly build --release --target x86_64-pc-windows-gnu # macOS helper bash MaximaHelper/build.sh -# Full installer (bundles all .exe files) +# Full installer (bundles all .exe files into MaximaSetup.exe) bash installer/build.sh + +# Fast cargo check during development +cargo check --target x86_64-pc-windows-gnu -p maxima-lib -p maxima-cli -p maxima-bootstrap ``` --- @@ -106,55 +166,106 @@ bash installer/build.sh Northstar works with Maxima, but requires two things: -**1. Launch via Steam, not via `NorthstarLauncher.exe`.** +**1. Launch via Steam, not via `NorthstarLauncher.exe`.** `NorthstarLauncher.exe` hard-codes a call to `Origin.exe` which doesn't exist in Wine. Pass the `-northstar` flag to Steam instead so it invokes `Titanfall2.exe` directly with the Northstar hooks loaded: ``` -steam.exe -applaunch 1237970 -northstar +steam.exe -applaunch 1237970 -northstar -noOriginStartup -multiple ``` Draconis already does this automatically. -**2. Add `-noOriginStartup` to your Northstar launch arguments.** -Without it, Northstar tries to start Origin at launch, which hangs forever in Wine since there is no Origin install. The correct set of arguments is: +**2. Add `-noOriginStartup -multiple` to your Northstar launch arguments.** +Without `-noOriginStartup`, Northstar tries to start Origin at launch, which hangs forever in Wine since there is no Origin install. `-multiple` lets the game launch even when Steam thinks another instance is already running (avoids a race during the link2ea handoff). -``` --noOriginStartup -multiple -northstar -``` +Thanks to [catornot](https://github.com/catornot) for identifying this and for contributing the external-LSX patch that makes online play work in this fork. See [catornot/flightcore-ng#wine_run.rs](https://github.com/catornot/flightcore-ng/blob/221e4444b6f1813c2401deed9f21d95494bad1ed/flightcore-ng-core/src/dev/wine/wine_run.rs#L23-L31) for reference. + +--- + +## Status / known limitations + +### What works + +- `maxima-cli serve` brings up LSX + the authorize HTTP server reliably inside the bottle. +- `maxima-bootstrap` correctly forwards `link2ea://` / `origin2://` to a running `serve` and falls back to spawning `maxima-cli launch` when nothing's listening. +- OAuth login (browser → `MaximaHelper.app` → `qrc://` → bottle) completes end-to-end. +- The `remid` cookie paste fallback works for browsers where `qrc://` is blocked. +- `STEAM_GAMES` table + Steam install discovery works for Titanfall 2. +- License preflight + `.dlf` write to `…/EA Services/License/` is exercised by every authorize call. +- Cross-compiles and CI green on Linux + Windows + macOS. + +### What's NOT confirmed working + +- **End-to-end TF2 launch on macOS/CrossOver**. With everything wired up correctly, TF2 still trips its `Engine Error: File corruption detected` check in our test environment. The split-brain (`serve` + `/authorize`) architecture was the latest attempt to sidestep this; user-side verification is still pending. The remaining hypotheses (Steam DRM IPC, `.dlf` hardware-hash mismatch, a local file-integrity check we haven't isolated) are documented in [`CLAUDE.md`](./CLAUDE.md) under "Open issues". + +### Known limitations -Thanks to [catornot](https://github.com/catornot) for identifying this requirement and for contributing the external LSX connection patch that makes Northstar online play work in this fork. See [catornot/flightcore-ng](https://github.com/catornot/flightcore-ng/blob/221e4444b6f1813c2401deed9f21d95494bad1ed/flightcore-ng-core/src/dev/wine/wine_run.rs#L23-L31) for reference. +- **Steam-only TF2 owners**: If your TF2 EA license isn't linked to your EA account (Steam-only ownership), the EA library lookup fails and Maxima falls back to the `STEAM_GAMES` static table. For the cleanest experience, link your accounts at [ea.com](https://www.ea.com) — takes about 30 seconds and resolves the warning permanently. +- **Offline mode**: Implemented but not exposed in Draconis UI. License files live at `C:/ProgramData/Electronic Arts/EA Services/License/` and are valid for roughly two weeks. +- **`NorthstarLauncher.exe`**: Incompatible with this setup. Northstar mode works via `steam.exe -applaunch 1237970 -northstar`. +- **`maxima-tui` / `maxima-ui`**: Shipped in the installer but Draconis doesn't invoke them. The UI hasn't been wired up to the `/authorize` HTTP endpoint yet — only `maxima-cli serve` provides it for now. +- **DLL injection on Wine**: `maxima-service`'s injector is Windows-only by design — Wine doesn't support `CreateRemoteThread` injection. The service is installed by NSIS but its injection path is never exercised in the Draconis flow. +- **`STEAM_GAMES` table is TF2-only**: Other EA-on-Steam titles would not resolve via the fallback. Extend `maxima-lib/src/steam.rs` per title you want to support. +- **Cloud saves, downloads, friends**: Implemented upstream and present in the codebase, but untested in the Draconis / CrossOver configuration. + +For pending technical debt items, see ["Pending code quality items" in CLAUDE.md](./CLAUDE.md). --- -## Known limitations +## Diagnostics + +If something isn't working, check these in order: + +```bash +# 1. Is MaximaHelper.app registered for qrc:// on the host? +swift -e 'import AppKit; let u = URL(string: "qrc://probe")!; \ + print(NSWorkspace.shared.urlForApplication(toOpen: u)?.path ?? "NONE")' + +# 2. Is the helper bundle properly signed (not just linker-signed)? +codesign -dv /Applications/Draconis.app/Contents/Resources/MaximaHelper.app 2>&1 \ + | grep -E '(Identifier|Info.plist|Sealed Resources)' +# Want: Identifier=com.armchairdevelopers.maxima.helper, Info.plist=bound, Sealed Resources version=2 + +# 3. Inside the bottle: is serve listening on both ports? +nc -zv 127.0.0.1 3216 # LSX +nc -zv 127.0.0.1 13219 # Authorize HTTP + +# 4. Did bootstrap actually run? Check %TEMP%/maxima_execution.log +# (on CrossOver: ~/Library/Application Support/CrossOver/Bottles//drive_c/users/crossover/Temp/maxima_execution.log) +# Look for "Forwarding link2ea offer=... to auth server" or "No auth server on...; falling back". + +# 5. What did serve say? Check %LOCALAPPDATA%\Maxima\Logs\maxima-cli.log +# Look for "Authorize request for offer '...'", "Refreshing OOA license for content_id=...", "New LSX connection: ...". +``` -- **Steam-only TF2 owners**: If your TF2 EA license isn't linked to your EA account (it's Steam-only), Maxima will warn and attempt a passthrough. For the cleanest experience, link your accounts at [ea.com](https://www.ea.com). Linking takes about 30 seconds and resolves the warning permanently. -- **Offline mode**: Implemented in the code and works after a successful first online launch. Draconis does not yet expose it in the UI. License files live at `C:/ProgramData/Maxima/Licenses/` and are valid for roughly two weeks. -- **NorthstarLauncher.exe**: Incompatible with this setup — it hard-codes a call to `Origin.exe` which doesn't exist in Wine. Northstar mode works fine via `steam.exe -applaunch 1237970 -northstar`, which is how Draconis does it. +More detailed diagnostic recipes are in [`CLAUDE.md`](./CLAUDE.md#diagnostics). --- ## Project layout ``` -maxima-lib/ Core library — auth, launch, license, library lookup -maxima-cli/ CLI frontend — authenticates and launches games -maxima-bootstrap/ Windows bootstrap — handles link2ea:// / origin2:// / qrc:// +maxima-lib/ Core library — auth, launch, license, library lookup, + LSX server, /authorize HTTP server, Steam helpers +maxima-cli/ CLI frontend — `serve` mode, `launch` mode, utilities +maxima-bootstrap/ Protocol handler shim — probe + forward, fallback spawn maxima-service/ Background Windows service — registry setup, DLL injection -maxima-tui/ Terminal UI (upstream, not used by Draconis) -maxima-ui/ Graphical UI (upstream, not used by Draconis) -maxima-resources/ Shared assets (icons, translations) -MaximaHelper/ macOS Swift app — bridges qrc:// from host into Wine + (Wine-incompatible, not exercised by Draconis) +maxima-tui/ Terminal UI (upstream, shipped but not invoked yet) +maxima-ui/ Graphical UI (upstream, shipped but not invoked yet) +maxima-resources/ Shared assets — logo, translations +MaximaHelper/ macOS Swift app — bridges qrc:// from host into the bottle installer/ NSIS script + cross-build script (macOS → Windows .exe) +.github/workflows/ build-ci.yml, release.yml, block-upstream-pr.yml ``` --- ## Upstream -This fork tracks [ArmchairDevelopers/Maxima](https://github.com/ArmchairDevelopers/Maxima) closely. Changes specific to Draconis / macOS / CrossOver are kept in this fork; generic fixes are submitted upstream when appropriate. +This fork tracks [ArmchairDevelopers/Maxima](https://github.com/ArmchairDevelopers/Maxima) closely. Changes specific to Draconis / macOS / CrossOver stay in this fork; generic fixes get sent upstream when appropriate (and indeed, this fork has cherry-picked several already-merged or in-progress upstream branches — see ["Upstream branch survey" in CLAUDE.md](./CLAUDE.md)). -**Original creators:** +**Original Maxima creators:** - [Sean Kahler](https://github.com/battledash) — creator of Maxima - [Nick Whelan](https://github.com/headassbtw) — UI maintainer - [Paweł Lidwin](https://github.com/imLinguin) — core maintainer @@ -162,7 +273,7 @@ This fork tracks [ArmchairDevelopers/Maxima](https://github.com/ArmchairDevelope **This fork used by:** [AA-EION/Draconis](https://github.com/AA-EION/Draconis) **Contributors to this fork:** -- [catornot](https://github.com/catornot) — external LSX connection patch enabling Northstar online play, and identifying the `-noOriginStartup` launch argument required for Wine +- [catornot](https://github.com/catornot) — `patch-external-lsx` upstream branch (the basis for our LSX-defensive handlers); `-noOriginStartup` flag documentation. --- diff --git a/installer/maxima-setup.nsi b/installer/maxima-setup.nsi index 5be7d4c..2216693 100644 --- a/installer/maxima-setup.nsi +++ b/installer/maxima-setup.nsi @@ -152,7 +152,7 @@ !define PRODUCT_NAME "Maxima" !define PRODUCT_PUBLISHER "Armchair Developers" !define PRODUCT_WEB_SITE "https://github.com/ArmchairDevelopers/Maxima" -!define PRODUCT_VERSION "0.4.0" +!define PRODUCT_VERSION "0.5.0" !define PRODUCT_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCT_NAME}" !define PRODUCT_UNINST_ROOT_KEY "HKLM" diff --git a/maxima-bootstrap/Cargo.toml b/maxima-bootstrap/Cargo.toml index fbeef76..eb7736f 100644 --- a/maxima-bootstrap/Cargo.toml +++ b/maxima-bootstrap/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "maxima-bootstrap" description = "Maxima handler for custom EA protocols" -version = "0.4.0" +version = "0.5.0" authors = ["Sean Kahler "] edition = "2021" diff --git a/maxima-bootstrap/src/main.rs b/maxima-bootstrap/src/main.rs index 8b52d4b..59eeebb 100644 --- a/maxima-bootstrap/src/main.rs +++ b/maxima-bootstrap/src/main.rs @@ -10,6 +10,7 @@ use thiserror::Error; use tokio::process::Command; use base64::{engine::general_purpose, Engine}; +use maxima::auth_server::AUTHORIZE_PORT; use maxima::core::launch::BootstrapLaunchArgs; use maxima::util::native::NativeError; #[cfg(windows)] @@ -71,6 +72,172 @@ fn is_valid_steam_app_id(s: &str) -> bool { !s.is_empty() && s.len() <= 10 && s.chars().all(|c| c.is_ascii_digit()) } +/// Append a one-liner to `%TEMP%/maxima_execution.log`. Bootstrap is a +/// GUI subsystem binary so it has no console of its own — this file is +/// the only feedback channel for what happened during a protocol-handler +/// invocation. Best-effort; failures are silently ignored. +fn log_event(line: &str) { + let path = std::env::temp_dir().join("maxima_execution.log"); + if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&path) { + use std::io::Write; + let _ = writeln!( + file, + "[{:?}] {}", + std::time::SystemTime::now(), + line + ); + } +} + +/// Quick TCP probe — does the `/authorize` HTTP server look reachable? +/// Used before paying for a full reqwest round-trip. +/// +/// Uses tokio's async `TcpStream::connect` wrapped in `timeout` so it +/// doesn't block the executor thread. (`std::net::TcpStream::connect_timeout` +/// inside an async fn parks a worker for up to the timeout duration, +/// which we don't want.) +async fn auth_server_alive(port: u16) -> bool { + let addr = format!("127.0.0.1:{}", port); + matches!( + tokio::time::timeout( + std::time::Duration::from_millis(200), + tokio::net::TcpStream::connect(&addr), + ) + .await, + Ok(Ok(_)) + ) +} + +/// Hand a `link2ea://` or `origin2://` URL off to whichever Maxima +/// already speaks `/authorize`, or fall back to the legacy +/// `maxima-cli launch` spawn if nothing's listening. +/// +/// The fall-back path preserves the upstream behavior (and the `link2ea` +/// flow Draconis used before `serve`-mode existed), so this rewrite +/// doesn't regress users who never type `maxima-cli serve` — they just +/// don't get the benefit of the always-on auth server. +/// +/// See [`maxima::auth_server`] in `maxima-lib` for the server side. +async fn handle_protocol_authorize( + offer_id: &str, + cmd_params: Option, + protocol_name: &'static str, +) -> Result { + // SECURITY: refuse anything that doesn't match the EA offer ID + // shape. URLs like `link2ea://launchgame/--login=stolen_token` + // would otherwise inject a flag into the maxima-cli invocation + // below (or, worse, into the HTTP forward). + if !is_valid_ea_offer_id(offer_id) { + log_event(&format!( + "REJECTED malformed {} offer_id: {:?}", + protocol_name, offer_id + )); + return Ok(false); + } + + let port = std::env::var("MAXIMA_AUTHORIZE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(AUTHORIZE_PORT); + + if auth_server_alive(port).await { + // Forward to the running Maxima. The server will refresh the + // `.dlf`, set the EA-* env vars, and spawn the game executable + // via `launch::start_game` — that's the chain TF2's Origin + // DRM stub expects when it emits `link2ea://` and exits. + let mut url = format!( + "http://127.0.0.1:{}/authorize?offer_id={}", + port, + urlencoding::encode(offer_id) + ); + if let Some(ref params) = cmd_params { + // Re-encode the param value (URL we got it from might have + // used `+` for space or other quirks). The server URL-decodes + // on its end. + url.push_str("&cmd_params="); + url.push_str(&urlencoding::encode(params)); + } + log_event(&format!( + "Forwarding {} offer={} to auth server at {}", + protocol_name, offer_id, url + )); + + // Long timeout: the very first call after `serve` boots may + // hit `request_and_save_license` which makes an EA license- + // server round-trip (typically <2s, but Wine + spotty network + // can push it higher). + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build()?; + let resp = client.post(&url).send().await?; + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + if status.is_success() { + log_event(&format!( + "Auth server accepted {} authorize for {} (body: {})", + protocol_name, offer_id, body + )); + return Ok(true); + } + // Server is alive but rejected the request. Don't fall back to + // spawning `maxima-cli launch` — that would just re-attempt the + // same operation through a different code path and produce a + // duplicate side-effect (a second TF2 process) without resolving + // the underlying problem (not logged in, offer not in library). + log_event(&format!( + "Auth server rejected {} authorize for {} ({}, body: {})", + protocol_name, offer_id, status, body + )); + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "Maxima authorize for {} failed with HTTP {}: {}", + offer_id, status, body + ), + ) + .into()); + } + + // No `/authorize` server reachable. Fall back to the legacy path: + // spawn `maxima-cli launch ` which will start LSX, log in + // if needed, do its own license preflight, and spawn the game via + // `launch::start_game`. This is what bootstrap did before the + // auth-server existed; it stays here so users who never run + // `maxima-cli serve` (or whose `serve` hasn't started yet) still get + // a working launch path. + log_event(&format!( + "No auth server on 127.0.0.1:{}; falling back to maxima-cli launch for {} offer={}", + port, protocol_name, offer_id + )); + + let mut child = Command::new(current_exe()?.with_file_name("maxima-cli.exe")); + + if let Ok(port) = std::env::var("KYBER_INTERFACE_PORT") { + child.env("KYBER_INTERFACE_PORT", port); + } + if let Some(params) = cmd_params { + let decoded = urlencoding::decode(¶ms) + .map(|c| c.into_owned()) + .unwrap_or(params); + child.env("MAXIMA_LAUNCH_ARGS", decoded.replace("\\\"", "\"")); + } + + child.args(["launch", offer_id]); + let status = child.spawn()?.wait().await?; + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "maxima-cli ({}) exited non-zero: code={:?}", + protocol_name, + status.code() + ), + ) + .into()); + } + Ok(true) +} + #[derive(Error, Debug)] pub(crate) enum RunError { #[error(transparent)] @@ -242,125 +409,40 @@ async fn run(args: &[String]) -> Result { if arg.starts_with("link2ea") { // link2ea://launchgame/?platform=

&theme= // link2ea://resume/?... + // + // The offer id is the first path segment after the action. let url = Url::parse(arg)?; - - // The offer ID is the first path segment after the host/action let segments: Vec<&str> = url .path_segments() .map(|c| c.collect()) .unwrap_or_default(); - if segments.is_empty() { return Ok(false); } - - // segments[0] is the offer ID (e.g. "Origin.OFR.50.0002694") let offer_id = segments[0]; - - // SECURITY: refuse anything that doesn't match the EA offer ID shape. - // A URL like link2ea://launchgame/--login=stolen_token would otherwise - // inject a flag into the maxima-cli invocation below. - if !is_valid_ea_offer_id(offer_id) { - let temp_dir = std::env::temp_dir(); - let debug_log = temp_dir.join("maxima_execution.log"); - if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&debug_log) { - use std::io::Write; - let _ = writeln!(file, "REJECTED malformed link2ea offer_id: {:?}", offer_id); - } - return Ok(false); - } - - let mut child = Command::new(current_exe()?.with_file_name("maxima-cli.exe")); - - // Forward environment variables from parent process - if let Ok(port) = std::env::var("KYBER_INTERFACE_PORT") { - child.env("KYBER_INTERFACE_PORT", port); - } - - // Extract any command params from the query string - if let Some(query) = url.query() { - let params = querystring::querify(query); - if let Some((_, cmd_params)) = params.iter().find(|(k, _)| *k == "cmdParams") { - child.env( - "MAXIMA_LAUNCH_ARGS", - urlencoding::decode(cmd_params) - .unwrap_or_default() - .into_owned() - .replace("\\\"", "\""), - ); - } - } - - child.args(["launch", offer_id]); - let status = child.spawn()?.wait().await?; - - // Propagate non-zero exits as errors so handle_launch_args logs - // them to maxima_execution.log and maxima_bootstrap_error.log via - // the existing centralized error-reporting path. Previously we - // logged manually and still returned Ok(true), which made failures - // look like successes in the log. - if !status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("maxima-cli (link2ea) exited non-zero: code={:?}", status.code()), - ) - .into()); - } - - return Ok(true); + let cmd_params = url.query().and_then(|q| { + querystring::querify(q) + .into_iter() + .find(|(k, _)| *k == "cmdParams") + .map(|(_, v)| v.to_string()) + }); + return handle_protocol_authorize(offer_id, cmd_params, "link2ea").await; } if arg.starts_with("origin2") { // origin2://game/launch?offerIds=&cmdParams=&... let url = Url::parse(arg)?; let query = querystring::querify(url.query().unwrap_or_default()); - let offer_id = query + let offer_id: String = query .iter() .find(|(k, _)| *k == "offerIds") - .map(|(_, v)| *v) + .map(|(_, v)| v.to_string()) .unwrap_or_default(); - - // SECURITY: same validation as link2ea:// — offer_id comes from an - // attacker-controlled URL and must not be allowed to start with `--`. - if !is_valid_ea_offer_id(offer_id) { - let temp_dir = std::env::temp_dir(); - let debug_log = temp_dir.join("maxima_execution.log"); - if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&debug_log) { - use std::io::Write; - let _ = writeln!(file, "REJECTED malformed origin2 offer_id: {:?}", offer_id); - } - return Ok(false); - } - - let mut child = Command::new(current_exe()?.with_file_name("maxima-cli.exe")); - - // Forward optional cmdParams as launch args - if let Some((_, cmd_params)) = query.iter().find(|(k, _)| *k == "cmdParams") { - child.env( - "MAXIMA_LAUNCH_ARGS", - urlencoding::decode(cmd_params)? - .into_owned() - .replace("\\\"", "\""), - ); - } - - // Forward KYBER port if present in parent environment - if let Ok(port) = std::env::var("KYBER_INTERFACE_PORT") { - child.env("KYBER_INTERFACE_PORT", port); - } - - child.args(["launch", offer_id]); - let status = child.spawn()?.wait().await?; - - if !status.success() { - return Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("maxima-cli (origin2) exited non-zero: code={:?}", status.code()), - ) - .into()); - } - - return Ok(true); + let cmd_params = query + .iter() + .find(|(k, _)| *k == "cmdParams") + .map(|(_, v)| v.to_string()); + return handle_protocol_authorize(&offer_id, cmd_params, "origin2").await; } if arg.starts_with("qrc") { diff --git a/maxima-cli/Cargo.toml b/maxima-cli/Cargo.toml index 2a84aa7..da89bc0 100644 --- a/maxima-cli/Cargo.toml +++ b/maxima-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maxima-cli" -version = "0.4.0" +version = "0.5.0" authors = ["Sean Kahler "] edition = "2021" @@ -20,7 +20,6 @@ futures = "0.3.30" [target.'cfg(windows)'.dependencies] winapi = { version = "0.3.9", features = [ "memoryapi", "handleapi", "synchapi", "wincon", "consoleapi", "processenv", "fileapi", "winbase", "winnt" ] } -winreg = "0.50.0" is_elevated = "0.1.2" [build-dependencies] diff --git a/maxima-cli/src/main.rs b/maxima-cli/src/main.rs index 9ac3a7f..cf52221 100644 --- a/maxima-cli/src/main.rs +++ b/maxima-cli/src/main.rs @@ -6,7 +6,7 @@ use lazy_static::lazy_static; use log::{debug, error, info, warn}; use regex::Regex; -use std::{path::PathBuf, sync::Arc, time::Instant}; +use std::{path::PathBuf, time::Instant}; #[cfg(windows)] use is_elevated::is_elevated; @@ -20,7 +20,7 @@ use maxima::{ use maxima::{ content::{ downloader::ZipDownloader, - manager::{QueuedGame, QueuedGameBuilder}, + manager::QueuedGameBuilder, ContentService, }, core::{ @@ -43,155 +43,12 @@ use maxima::{ }, ooa, rtm::client::BasicPresence, + steam::{lookup_steam_game, resolve_steam_install_path, EA_OFFER_ID_PATTERN, STEAM_APP_ID_PATTERN}, util::{log::init_logger_named, native::take_foreground_focus, registry::check_registry_validity}, }; lazy_static! { static ref MANUAL_LOGIN_PATTERN: Regex = Regex::new(r"^(.*):(.*)$").unwrap(); - // Matches a well-formed EA offer ID like "Origin.OFR.50.0002694" - static ref EA_OFFER_ID_PATTERN: Regex = Regex::new(r"^Origin\.OFR\.\d+\.\d+$").unwrap(); - // Matches a Steam App ID emitted by `link2ea://launchgame/?platform=steam` - static ref STEAM_APP_ID_PATTERN: Regex = Regex::new(r"^\d{1,10}$").unwrap(); -} - -/// Hardcoded fallback table for EA-published games available on Steam. -/// -/// When TF2 (and similar) is launched from inside Steam, the URL Steam emits -/// is `link2ea://launchgame/?platform=steam&theme=...`. Steam -/// does NOT spawn the game executable — it expects the link2ea handler (us) -/// to take over the launch entirely: auth + spawn the binary with the right -/// env vars so TF2 connects to our LSX server. -/// -/// For Steam-only owners whose EA account is not linked, the EA library -/// lookup will fail to translate the Steam App ID to an Origin offer ID -/// AND won't know where the game is installed. This table provides both: -/// - the EA Origin offer ID to use for license/auth -/// - the relative path inside Steam's `steamapps/common/` to find the exe -/// -/// Discovery process at runtime (`resolve_steam_install_path`): -/// 1. Read `HKLM\SOFTWARE\(Wow6432Node\)Valve\Steam\InstallPath` for Steam root -/// 2. Parse `\steamapps\libraryfolders.vdf` for additional libraries -/// 3. Look for `\steamapps\appmanifest_.acf` and its `installdir` -/// 4. Fall back to `\steamapps\common\\` from this table -/// -/// Extend as more EA-on-Steam titles are validated. -struct SteamGameEntry { - steam_app_id: &'static str, - origin_offer_id: &'static str, - /// Directory name under `steamapps/common/`, e.g. "Titanfall2" - install_subdir: &'static str, - /// Game executable filename within the install dir, e.g. "Titanfall2.exe" - exe_name: &'static str, -} - -const STEAM_GAMES: &[SteamGameEntry] = &[ - SteamGameEntry { - steam_app_id: "1237970", - // Note: NOT Origin.OFR.50.0002694 — that's Apex Legends. TF2's real - // offer ID is Origin.OFR.50.0001456, confirmed against a real EA - // library dump ("titanfall-2 - Titanfall 2 - Origin.OFR.50.0001456"). - origin_offer_id: "Origin.OFR.50.0001456", - install_subdir: "Titanfall2", - exe_name: "Titanfall2.exe", - }, -]; - -fn lookup_steam_game(steam_app_id: &str) -> Option<&'static SteamGameEntry> { - STEAM_GAMES.iter().find(|g| g.steam_app_id == steam_app_id) -} - -/// Resolve where a given Steam game is installed on disk. Returns the full -/// path to the game executable (e.g. `C:\...\Steam\steamapps\common\Titanfall2\Titanfall2.exe`) -/// or `None` if the game isn't installed in any known Steam library. -/// -/// Lookup order: -/// 1. Steam install path from registry (`HKLM\SOFTWARE\WOW6432Node\Valve\Steam\InstallPath` -/// or `HKLM\SOFTWARE\Valve\Steam\InstallPath`) -/// 2. Common default install locations as a last-resort -/// 3. Parse libraryfolders.vdf to find additional Steam library folders -/// 4. Verify the executable exists at `\steamapps\common\\` -#[cfg(windows)] -fn resolve_steam_install_path(game: &SteamGameEntry) -> Option { - use winreg::enums::HKEY_LOCAL_MACHINE; - use winreg::RegKey; - - let mut steam_roots: Vec = Vec::new(); - - // 1. Registry — try both views since Steam installs as 32-bit on most systems - let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); - for key in &[ - "SOFTWARE\\WOW6432Node\\Valve\\Steam", - "SOFTWARE\\Valve\\Steam", - ] { - if let Ok(subkey) = hklm.open_subkey(key) { - if let Ok(path) = subkey.get_value::("InstallPath") { - steam_roots.push(PathBuf::from(path)); - } - } - } - - // 2. Common defaults (covers fresh Wine bottles where the registry key - // may not have been written yet, or when running outside Wine) - for default in &[ - "C:\\Program Files (x86)\\Steam", - "C:\\Program Files\\Steam", - ] { - let p = PathBuf::from(default); - if p.exists() && !steam_roots.contains(&p) { - steam_roots.push(p); - } - } - - // 3. For each Steam root, gather library folders from libraryfolders.vdf - // and search for the game. - for root in &steam_roots { - let mut libraries: Vec = vec![root.clone()]; - - // Parse libraryfolders.vdf for extra library paths. VDF is a simple - // key-value format; we don't need a full parser — just grep "path". - let vdf_paths = [ - root.join("steamapps").join("libraryfolders.vdf"), - root.join("config").join("libraryfolders.vdf"), - ]; - for vdf in &vdf_paths { - if let Ok(content) = std::fs::read_to_string(vdf) { - for line in content.lines() { - let trimmed = line.trim(); - // Lines look like: "path" "C:\\SteamLibrary" - if let Some(rest) = trimmed.strip_prefix("\"path\"") { - if let Some(start) = rest.find('"') { - let after = &rest[start + 1..]; - if let Some(end) = after.find('"') { - let extra = PathBuf::from(after[..end].replace("\\\\", "\\")); - if !libraries.contains(&extra) { - libraries.push(extra); - } - } - } - } - } - } - } - - // 4. Verify the executable exists in each library - for lib in &libraries { - let exe = lib - .join("steamapps") - .join("common") - .join(game.install_subdir) - .join(game.exe_name); - if exe.exists() { - return Some(exe); - } - } - } - - None -} - -#[cfg(not(windows))] -fn resolve_steam_install_path(_game: &SteamGameEntry) -> Option { - None } #[derive(Subcommand, Debug)] @@ -255,6 +112,25 @@ enum Mode { #[arg(long)] file: String, }, + /// Run as a passive LSX server — log in, start the LSX listener, optionally + /// log in to RTM, and wait indefinitely (Ctrl-C to stop). This is the CLI + /// equivalent of "open the Maxima UI and leave it running": no game is + /// launched by this process, so when an externally-started game (Steam + /// `applaunch`, Northstar's `steam.exe -applaunch 1237970 -northstar`, or + /// a direct double-click on `Titanfall2.exe`) connects to LSX, the + /// connection's `playing()` is None — which exercises the + /// catornot/patch-external-lsx code path that the user reports works on + /// Windows. Use this when `maxima-cli launch` keeps tripping TF2's + /// "File corruption detected" tamper check: kick `serve` first, then + /// launch the game externally. + Serve { + /// Skip RTM (Real-Time Messaging) login — useful in low-connectivity + /// environments or when you only care about LSX auth, not friends + /// presence. Default is to log in to RTM so SetPresence requests from + /// the game update your status normally. + #[arg(long)] + no_rtm: bool, + }, } #[derive(Parser, Debug)] @@ -689,54 +565,24 @@ async fn startup(args: Args) -> Result<()> { game_path }; - // Steam DRM stub in EA-on-Steam titles (notably TF2) exits with - // code 100010 ("Steam not detected") if launched without the - // SteamAppId / SteamGameId env vars set. EA Desktop's Link2EA.exe - // sets these when it spawns the game; we have to do the same. - // The env vars propagate from this process through bootstrap to - // the actual game executable via Command::env inheritance. - let is_steam_launch = STEAM_APP_ID_PATTERN.is_match(&slug); - if is_steam_launch { - info!("Setting Steam env vars (SteamAppId={}) for Steam-launched game", slug); - std::env::set_var("SteamAppId", &slug); - std::env::set_var("SteamGameId", &slug); - // Steam also normally exports these — set defaults so anything - // that polls the env directly doesn't see them unset. - if std::env::var("SteamClientLaunch").is_err() { - std::env::set_var("SteamClientLaunch", "1"); - } - if std::env::var("SteamPath").is_err() { - std::env::set_var("SteamPath", "C:\\Program Files (x86)\\Steam"); - } - } - - // Auto-inject launch args known to be required for Wine/Steam - // launches. These are the same defaults flightcore-ng uses - // (see catornot/flightcore-ng wine_run.rs L23-31): - // -noOriginStartup : skip the Origin "starting" wait that hangs - // forever in Wine since EA Desktop isn't present - // -multiple : allow multiple game instances (avoids the - // "another instance already running" check that - // can fire when Steam + Maxima race the launch) - // Only add them if the user didn't already specify them. - let mut final_game_args = game_args; - if is_steam_launch { - let has_no_origin = final_game_args - .iter() - .any(|a| a.eq_ignore_ascii_case("-noOriginStartup")); - let has_multiple = final_game_args - .iter() - .any(|a| a.eq_ignore_ascii_case("-multiple")); - if !has_no_origin { - final_game_args.insert(0, "-noOriginStartup".to_string()); - } - if !has_multiple { - final_game_args.insert(0, "-multiple".to_string()); - } - info!("Auto-injected Steam launch args; final args: {:?}", final_game_args); - } - - start_game(&offer_id, resolved_game_path, final_game_args, login, is_steam_launch, maxima_arc.clone()).await + // Steam-Play detection: if the original slug was a numeric + // Steam App ID, surface it via `LaunchOptions.steam_app_id`. + // `launch::start_game` handles the SteamAppId/SteamGameId env + // vars + `-noOriginStartup -multiple` arg injection in one + // place — see the LaunchOptions doc comment. + let steam_app_id = STEAM_APP_ID_PATTERN + .is_match(&slug) + .then(|| slug.clone()); + + start_game( + &offer_id, + resolved_game_path, + game_args, + login, + steam_app_id, + maxima_arc.clone(), + ) + .await } Mode::ListGames => list_games(maxima_arc.clone()).await, Mode::LocateGame { path } => locate_game(maxima_arc.clone(), &path).await, @@ -761,6 +607,7 @@ async fn startup(args: Args) -> Result<()> { build_id, file, } => download_specific_file(maxima_arc.clone(), &offer_id, &build_id, &file).await, + Mode::Serve { no_rtm } => serve_lsx(maxima_arc.clone(), no_rtm).await, }?; Ok(()) @@ -815,7 +662,7 @@ async fn interactive_start_game(maxima_arc: LockedMaxima) -> Result<()> { game.base_offer().offer_id().to_owned() }; - start_game(&offer_id, None, Vec::new(), None, false, maxima_arc.clone()).await?; + start_game(&offer_id, None, Vec::new(), None, None, maxima_arc.clone()).await?; Ok(()) } @@ -1258,7 +1105,7 @@ async fn start_game( game_path_override: Option, game_args: Vec, login: Option, - steam_launch: bool, + steam_app_id: Option, maxima_arc: LockedMaxima, ) -> Result<()> { { @@ -1280,7 +1127,7 @@ async fn start_game( path_override: game_path_override, arguments: game_args, cloud_saves: true, - steam_launch, + steam_app_id, }; if login.is_none() { @@ -1323,3 +1170,106 @@ async fn start_game( Ok(()) } + +/// Long-running "passive LSX server" mode — the CLI equivalent of leaving the +/// Maxima UI open. +/// +/// Why this exists: the catornot/patch-external-lsx scenario only works +/// reliably when the LSX server's `maxima.playing()` is None at the moment +/// the game establishes its socket. `maxima-cli launch` always sets +/// `playing = Some(...)` immediately before spawning bootstrap, so when the +/// game connects a few seconds later the LSX handlers go down the +/// "Some(context)" branch in `Connection::new` (Kyber PID lookup, RTM +/// presence updates, real OOA license requests, etc.). On Windows that's +/// fine; on macOS/CrossOver the user reports it triggers TF2's +/// "Engine Error: File corruption detected" tamper dialog. +/// +/// `maxima-cli serve` decouples the two halves of the launch: +/// +/// 1. Terminal 1: `maxima-cli.exe serve` — logs in, opens the LSX listener +/// on the configured port (`MAXIMA_LSX_PORT` or 3216), optionally logs +/// in to RTM, and parks. +/// 2. Terminal/Steam/Northstar: launch the game by any means that gets +/// `EALsxPort=` into the process environment (Steam's +/// `applaunch`, Draconis's vanilla / Northstar launch, or a `cxstart` +/// against `Titanfall2.exe` after manually setting the env var). +/// +/// When the game connects, the server sees `playing=None`, takes the +/// catornot external-LSX path (now correctly defended in +/// `license.rs` / `profile.rs::set_presence`), and the auth flow proceeds. +/// +/// This loop deliberately does NOT call `maxima.update()` — `update_playing_status` +/// is a no-op when `playing` is None and we don't want the content manager +/// poking at downloads from a serve session. Ctrl-C is the exit path. +async fn serve_lsx(maxima_arc: LockedMaxima, no_rtm: bool) -> Result<()> { + { + let mut maxima = maxima_arc.lock().await; + maxima.start_lsx(maxima_arc.clone()).await?; + info!("LSX server listening on port {}", maxima.lsx_port()); + + // Bring up the HTTP `/authorize` endpoint too. Bootstrap probes + // this when handling `link2ea://` / `origin2://` and forwards the + // offer here instead of spawning a duplicate `maxima-cli launch`. + // Failure to bind isn't fatal — LSX is what TF2 strictly needs, + // and bootstrap falls back to the legacy spawn path if the probe + // can't reach us. + if let Err(err) = maxima.start_auth_server(maxima_arc.clone()).await { + warn!( + "Authorize HTTP server failed to start ({}); bootstrap will fall back \ + to spawning maxima-cli launch on link2ea://.", + err + ); + } + + if !no_rtm { + // Best-effort RTM login: it's only needed for friends presence / + // SetPresence handlers. A failure here shouldn't bring down the + // LSX server. + if let Err(err) = maxima.rtm().login().await { + warn!("RTM login failed (continuing without presence): {}", err); + } else { + match maxima.friends(0).await { + Ok(friends) => { + let players: Vec = + friends.iter().map(|f| f.id().to_owned()).collect(); + if let Err(err) = maxima.rtm().subscribe(&players).await { + warn!("Failed to subscribe to friends presence: {}", err); + } else { + info!("Subscribed to {} friends for presence", players.len()); + } + } + Err(err) => warn!("Failed to fetch friends list: {}", err), + } + } + } + } + + info!("Serving LSX. Launch your game externally (Steam / Draconis / etc.); press Ctrl-C to stop."); + + // Park indefinitely. Tick `maxima.update()` so when a game launched + // via `/authorize` exits, `update_playing_status` notices the + // bootstrap child has finished, runs cloud-save sync, and clears + // `maxima.playing` — leaving the server ready to handle the next + // launch with a clean state. + loop { + { + let mut maxima = maxima_arc.lock().await; + for event in maxima.consume_pending_events() { + if let MaximaEvent::ReceivedLSXRequest(pid, request) = event { + debug!("LSX request from pid={}: {:?}", pid, request); + } + } + maxima.update().await; + // Heartbeat RTM so presence stays fresh (no-op if RTM wasn't started). + if !no_rtm { + if let Err(err) = maxima.rtm().heartbeat().await { + warn!("RTM heartbeat failed: {}", err); + } + } + } + // 1s tick — enough to detect game exit promptly without burning + // CPU. Lock contention with LSX handlers is negligible at this + // cadence. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } +} diff --git a/maxima-lib/Cargo.toml b/maxima-lib/Cargo.toml index 9f3a6e7..6f2acc9 100644 --- a/maxima-lib/Cargo.toml +++ b/maxima-lib/Cargo.toml @@ -37,6 +37,7 @@ regex = "1.8.4" directories = "5.0.1" open = "5.0.0" querystring = "1.1.0" +urlencoding = "2.1.3" ureq = { version = "2.9.1", features = ["tls"] } sysinfo = "0.29.7" strum_macros = "0.25.2" diff --git a/maxima-lib/src/auth_server.rs b/maxima-lib/src/auth_server.rs new file mode 100644 index 0000000..977654a --- /dev/null +++ b/maxima-lib/src/auth_server.rs @@ -0,0 +1,454 @@ +//! HTTP server that handles `link2ea://` / `origin2://` auth handoffs. +//! +//! ## Why this exists +//! +//! Upstream's bootstrap treats `link2ea://launchgame/` as +//! "launch this game" — it spawns a fresh `maxima-cli launch ` +//! which in turn calls `launch::start_game`, spawning the game executable. +//! That works as a one-shot but doesn't compose: if Draconis already has +//! a long-running Maxima session in the bottle (cached login, RTM, etc.), +//! every protocol-handler invocation re-bootstraps from scratch. +//! +//! Upstream's own tracking issue +//! [`#27 — Support launching Maxima from Epic/Steam`](https://github.com/ArmchairDevelopers/Maxima/issues/27) +//! describes the intended end state: a long-running Maxima server, a +//! protocol handler that consults it over IPC, and the server preparing +//! the OOA license + computing launch params before the handler spawns +//! the game. This module is our HTTP-based realization of that design +//! (D-Bus on Linux per the issue, plain TCP HTTP for our cross-OS +//! Wine bottle). +//! +//! ## Endpoints +//! +//! - `GET /` → `200 OK` body `maxima-auth-server`. Used by bootstrap as +//! a liveness probe before deciding whether to forward or fall back to +//! spawning a fresh `maxima-cli launch`. +//! - `POST /authorize?offer_id=` → Validate login, resolve the offer +//! (EA library lookup with [`crate::steam`] fallback for the install +//! path), then call [`crate::core::launch::start_game`] which: +//! 1. Refreshes the OOA license via `request_and_save_license` +//! (writes `…/EA Services/License/.dlf`). +//! 2. Sets the EA-* environment variables required by the game +//! (`EALsxPort`, `EAGenericAuthToken`, `EAAccessTokenJWS`, …). +//! 3. Spawns the game via the upstream bootstrap → game chain. +//! 4. Records `maxima.playing = Some(ActiveGameContext)` so the +//! LSX server takes the active-launch branch when the game +//! connects. +//! Returns `200 OK` `{"status":"ok"}` once the spawn is in flight, or +//! `4xx`/`5xx` with `{"status":"error","message":...}` on failure. +//! +//! ### Why /authorize spawns the game (not just preflight) +//! +//! Empirically, Titanfall 2's Origin DRM stub emits `link2ea://` and +//! **exits**, expecting whoever handles the URL to re-launch it with +//! EA auth context (`EAGenericAuthToken` etc.) in the environment. A +//! preflight-only endpoint would refresh the `.dlf` but leave the game +//! closed. By calling `launch::start_game` we hand the spawned game a +//! full EA env — same as the upstream UI does via its Play button. +//! `maxima.playing` ends up `Some(...)` and the LSX flow goes down +//! the standard active-launch branch (not the catornot external-LSX +//! branch). + +use std::sync::Arc; + +use log::{debug, error, info, warn}; +use serde::Serialize; +use thiserror::Error; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; +use tokio::time::Duration; + +/// Per-request total deadline. A legitimate `/authorize` finishes in +/// well under 2s (license preflight + spawn); anything longer is either +/// EA's licensing service is having a bad day, or a slow / malicious +/// local client trying to pin our task indefinitely. 30s is generous +/// enough not to cut off the slow-but-real case while still keeping +/// the worker free for the next request. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Cap total bytes we'll read from one connection at 8 KiB. This is the +/// request line plus all headers (we never read a body). 8 KiB is the +/// same number Nginx's `large_client_header_buffers` defaults to. +/// Without this cap, an attacker could send an arbitrarily long +/// request line on the loopback socket to exhaust memory. +const MAX_REQUEST_HEAD_BYTES: u64 = 8 * 1024; + +use crate::core::{ + auth::storage::TokenError, + launch::{self, LaunchError, LaunchMode, LaunchOptions}, + library::LibraryError, + Maxima, +}; +use crate::steam::{ + lookup_steam_game, lookup_steam_game_by_offer, resolve_steam_install_path, + STEAM_APP_ID_PATTERN, +}; + +/// Default port for the authorize HTTP server. LSX is 3216; we pick +/// `lsx + 3` so the two stay together in `netstat` output but don't +/// collide. Override via `MAXIMA_AUTHORIZE_PORT` if anything ever clashes. +pub const AUTHORIZE_PORT: u16 = 13219; + +#[derive(Error, Debug)] +pub enum AuthServerError { + #[error(transparent)] + Io(#[from] std::io::Error), +} + +/// Bind the authorize HTTP listener and spawn the accept loop. Returns +/// once the listener is bound; errors inside the accept loop are logged +/// but don't propagate, so an LSX server already running stays up if +/// some transient socket error hits this listener. +pub async fn start_server( + port: u16, + maxima_arc: Arc>, +) -> Result<(), AuthServerError> { + let addr = format!("127.0.0.1:{}", port); + let listener = TcpListener::bind(&addr).await?; + info!("Authorize HTTP server listening on {}", addr); + + tokio::spawn(async move { + loop { + match listener.accept().await { + Ok((socket, peer)) => { + debug!("Authorize: new connection from {}", peer); + let maxima = maxima_arc.clone(); + tokio::spawn(async move { + if let Err(err) = handle_connection(socket, maxima).await { + warn!("Authorize: request failed: {}", err); + } + }); + } + Err(err) => { + error!("Authorize: accept failed: {}", err); + // Brief backoff so we don't hot-loop on a permanent + // listener error. + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + } + } + }); + + Ok(()) +} + +#[derive(Serialize)] +struct OkResponse { + status: &'static str, +} + +#[derive(Serialize)] +struct ErrorResponse { + status: &'static str, + message: String, +} + +/// Public entry point: wraps the real handler in a per-request +/// `tokio::time::timeout` so a stalled / hostile peer can't keep a +/// task pinned indefinitely. Slow-client mitigation for an +/// unauthenticated loopback HTTP listener. +async fn handle_connection( + socket: TcpStream, + maxima_arc: Arc>, +) -> Result<(), std::io::Error> { + match tokio::time::timeout(REQUEST_TIMEOUT, handle_connection_inner(socket, maxima_arc)).await { + Ok(result) => result, + Err(_) => { + warn!( + "Authorize: request exceeded {:?} timeout, dropping connection", + REQUEST_TIMEOUT + ); + Ok(()) + } + } +} + +async fn handle_connection_inner( + mut socket: TcpStream, + maxima_arc: Arc>, +) -> Result<(), std::io::Error> { + let (read_half, _) = socket.split(); + // `.take(N)` bounds the total bytes our BufReader will surface — once + // the limit is reached, subsequent reads return 0 (EOF). A truncated + // request line / header block then trips the HTTP parser below and + // we respond 400 instead of hanging on the read. + let mut reader = BufReader::new(read_half.take(MAX_REQUEST_HEAD_BYTES)); + + // We only need the request line — the body is empty for our endpoints + // and headers carry nothing we care about. Drain enough to keep the + // peer's send buffer happy, then respond. + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + let parts: Vec<&str> = request_line.split_whitespace().collect(); + if parts.len() < 2 { + return write_response(&mut socket, 400, "Bad Request", b"").await; + } + let method = parts[0].to_string(); + let path_and_query = parts[1].to_string(); + + // Drain headers (until empty line). HTTP/1.1 requires this even if + // we don't read further data — without it, some clients refuse to + // read the response. + loop { + let mut header = String::new(); + let n = reader.read_line(&mut header).await?; + if n == 0 || header == "\r\n" || header == "\n" { + break; + } + } + + // GET / — health probe used by bootstrap. + if method == "GET" && (path_and_query == "/" || path_and_query.starts_with("/?")) { + return write_response(&mut socket, 200, "OK", b"maxima-auth-server").await; + } + + // POST /authorize?offer_id=...&cmd_params=... + if method == "POST" && path_and_query.starts_with("/authorize") { + let offer_id = extract_query_param(&path_and_query, "offer_id"); + let cmd_params = extract_query_param(&path_and_query, "cmd_params"); + return match handle_authorize( + offer_id.as_deref(), + cmd_params.as_deref(), + maxima_arc, + ) + .await + { + Ok(()) => { + let body = serde_json::to_vec(&OkResponse { status: "ok" }) + .unwrap_or_else(|_| b"{}".to_vec()); + write_json_response(&mut socket, 200, "OK", &body).await + } + Err(err) => { + let (status, reason) = err.http_status(); + let body = serde_json::to_vec(&ErrorResponse { + status: "error", + message: err.to_string(), + }) + .unwrap_or_else(|_| b"{}".to_vec()); + write_json_response(&mut socket, status, reason, &body).await + } + }; + } + + write_response(&mut socket, 404, "Not Found", b"").await +} + +#[derive(Error, Debug)] +enum AuthorizeError { + #[error("missing offer_id query parameter")] + MissingOfferId, + #[error("not logged in — open the Maxima UI / CLI once to authenticate first")] + NotLoggedIn, + #[error("no owned offer '{0}' in EA library — link your Steam account at https://www.ea.com")] + OfferNotFound(String), + #[error(transparent)] + Token(#[from] TokenError), + #[error(transparent)] + Library(#[from] LibraryError), + #[error(transparent)] + Launch(#[from] LaunchError), +} + +impl AuthorizeError { + fn http_status(&self) -> (u16, &'static str) { + match self { + AuthorizeError::MissingOfferId => (400, "Bad Request"), + AuthorizeError::NotLoggedIn | AuthorizeError::Token(_) => (401, "Unauthorized"), + AuthorizeError::OfferNotFound(_) => (404, "Not Found"), + // `LaunchError::NotInstalled` / `NoOfferFound` are also "not found" + // shaped; map them precisely so curl users see a useful status. + AuthorizeError::Launch(LaunchError::NotInstalled(_)) + | AuthorizeError::Launch(LaunchError::NoOfferFound(_)) + | AuthorizeError::Launch(LaunchError::GamePath) => (404, "Not Found"), + AuthorizeError::Launch(LaunchError::BootstrapMissing) => (500, "Internal Server Error"), + AuthorizeError::Library(_) | AuthorizeError::Launch(_) => (502, "Bad Gateway"), + } + } +} + +/// Core authorize logic: validate login → resolve offer → call +/// `launch::start_game` which does the OOA license refresh, sets the +/// EA-* env vars, and spawns the game executable. +/// +/// We accept an optional `cmd_params` query parameter — the URL-encoded +/// argument string from `link2ea://launchgame/?cmdParams=…`. It +/// is parsed and forwarded as additional launch args. +async fn handle_authorize( + raw_offer_id: Option<&str>, + cmd_params: Option<&str>, + maxima_arc: Arc>, +) -> Result<(), AuthorizeError> { + let raw_offer_id = raw_offer_id.ok_or(AuthorizeError::MissingOfferId)?; + info!("Authorize request for slug '{}'", raw_offer_id); + + // Steam emits `link2ea://launchgame/?platform=steam` + // (e.g. `1237970` for TF2). EA Desktop's library is keyed by Origin + // offer IDs like `Origin.OFR.50.0001456`, so we translate via the + // STEAM_GAMES table before doing the library lookup. The original + // slug is kept as `steam_app_id` to thread through to `launch.rs` + // for SteamAppId/SteamGameId env-var setup on the spawned game. + let (offer_id, steam_app_id): (String, Option) = + if STEAM_APP_ID_PATTERN.is_match(raw_offer_id) { + match lookup_steam_game(raw_offer_id) { + Some(entry) => { + info!( + "Steam App ID '{}' resolved to Origin offer ID '{}'", + raw_offer_id, entry.origin_offer_id + ); + ( + entry.origin_offer_id.to_owned(), + Some(raw_offer_id.to_owned()), + ) + } + None => { + warn!( + "Steam App ID '{}' is not in the STEAM_GAMES table; \ + passing through directly (will likely 404 in library lookup)", + raw_offer_id + ); + (raw_offer_id.to_owned(), Some(raw_offer_id.to_owned())) + } + } + } else { + // Looks like an Origin offer ID already (TF2 itself emits + // these mid-run; older EA-Desktop-style launches go this + // path too). + (raw_offer_id.to_owned(), None) + }; + + // Phase 1: cheap pre-checks. Drop the lock before + // `launch::start_game` re-acquires it, so we don't deadlock. + { + let mut maxima = maxima_arc.lock().await; + + // `logged_in()` re-validates the cached token so an expired + // account doesn't fall through to a confusing 502. + let logged_in = { + let mut auth_storage = maxima.auth_storage().lock().await; + auth_storage.logged_in().await.unwrap_or(false) + }; + if !logged_in { + return Err(AuthorizeError::NotLoggedIn); + } + + // Confirm the (translated) offer is in the user's EA library + // here so we can give a clean 404 ("link your accounts at + // ea.com") instead of bubbling a less-helpful + // `LaunchError::NoOfferFound` later. + if maxima + .mut_library() + .game_by_base_offer(&offer_id) + .await? + .is_none() + { + return Err(AuthorizeError::OfferNotFound(offer_id.clone())); + } + } + + // Phase 2: build LaunchOptions. The Steam-install path fallback is + // crucial for Titanfall 2 from Steam — EA Desktop has no record of + // the install, so `launch::start_game` would bail with + // `LaunchError::NotInstalled` without an explicit override. + let path_override = lookup_steam_game_by_offer(&offer_id) + .and_then(resolve_steam_install_path) + .and_then(|p| p.to_str().map(str::to_owned)); + if let Some(ref p) = path_override { + info!("Resolved Steam install path for {}: {}", offer_id, p); + } + + let arguments = cmd_params + .map(|raw| { + // URL-decode then split on whitespace, respecting basic + // double-quote grouping (same shape `MAXIMA_LAUNCH_ARGS` + // expects elsewhere in the codebase). + let decoded = urlencoding::decode(raw) + .map(|c| c.into_owned()) + .unwrap_or_else(|_| raw.to_owned()) + .replace("\\\"", "\""); + launch::parse_arguments(&decoded) + }) + .unwrap_or_default(); + + let launch_options = LaunchOptions { + path_override, + arguments, + cloud_saves: true, + // Threading the original Steam App ID (if any) through to + // `launch.rs` makes it set SteamAppId/SteamGameId env vars on + // the spawned game and auto-inject -noOriginStartup / -multiple + // launch args — without this TF2 from Steam exits with code + // 100010 "Steam not detected". + steam_app_id, + }; + + // Phase 3: hand off to the upstream launch flow. This refreshes the + // license, populates the EA-* env vars (including SteamAppId via + // `LaunchOptions.steam_app_id`), spawns bootstrap → game, and sets + // `maxima.playing = Some(ActiveGameContext)` so the LSX server + // takes the active-launch branch when the game connects. + launch::start_game( + maxima_arc.clone(), + LaunchMode::Online(offer_id.clone()), + launch_options, + ) + .await?; + + info!("Game launched for offer '{}'", offer_id); + Ok(()) +} + +fn extract_query_param(path_and_query: &str, key: &str) -> Option { + let qs = path_and_query.split_once('?').map(|(_, q)| q)?; + for pair in qs.split('&') { + if let Some((k, v)) = pair.split_once('=') { + if k == key { + return Some( + urlencoding::decode(v) + .map(|c| c.into_owned()) + .unwrap_or_else(|_| v.to_owned()), + ); + } + } + } + None +} + +async fn write_response( + socket: &mut TcpStream, + status: u16, + reason: &str, + body: &[u8], +) -> Result<(), std::io::Error> { + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + status, + reason, + body.len() + ); + socket.write_all(head.as_bytes()).await?; + if !body.is_empty() { + socket.write_all(body).await?; + } + socket.flush().await?; + Ok(()) +} + +async fn write_json_response( + socket: &mut TcpStream, + status: u16, + reason: &str, + body: &[u8], +) -> Result<(), std::io::Error> { + let head = format!( + "HTTP/1.1 {} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + status, + reason, + body.len() + ); + socket.write_all(head.as_bytes()).await?; + socket.write_all(body).await?; + socket.flush().await?; + Ok(()) +} diff --git a/maxima-lib/src/core/launch.rs b/maxima-lib/src/core/launch.rs index 5cb6ae9..1e247a6 100644 --- a/maxima-lib/src/core/launch.rs +++ b/maxima-lib/src/core/launch.rs @@ -1,6 +1,6 @@ use base64::{engine::general_purpose, Engine}; use derive_getters::Getters; -use log::{error, info}; +use log::{error, info, warn}; use std::{env, fmt::Display, path::PathBuf, sync::Arc}; use tokio::{ process::{Child, Command}, @@ -87,13 +87,30 @@ pub struct LaunchOptions { pub path_override: Option, pub arguments: Vec, pub cloud_saves: bool, - /// When true, the game is being launched from Steam context (the user - /// clicked Play in Steam, which emitted `link2ea://launchgame/?platform=steam`). - /// The EA env vars passed to the game are flipped from "EA" to "Steam" - /// for `EAExternalSource` / `EALaunchOwner` / `EAEntitlementSource` so the - /// game's launch context matches what it expects from Steam DRM and - /// Steamworks, avoiding "corrupted files" / "wrong entitlement" rejections. - pub steam_launch: bool, + /// When set, the game is being launched from Steam context. Steam + /// emits `link2ea://launchgame/?platform=steam` + /// expecting the link2ea handler to take over the launch entirely + /// (Steam does NOT spawn the exe itself for older EA-on-Steam titles + /// like TF2 — it delegates to whatever owns the link2ea protocol). + /// + /// Passing `Some(steam_app_id)` causes `start_game` to: + /// 1. Set `EAEntitlementSource` / `EAExternalSource` / `EALaunchOwner` + /// to `"Steam"` instead of `"EA"` so TF2's DRM stub sees a launch + /// context consistent with where it's being run from. + /// 2. Set `SteamAppId` / `SteamGameId` env vars on the spawned game + /// (required by the Steam DRM stub — without these TF2 exits + /// immediately with code 100010 "Steam not detected"). + /// 3. Default `SteamClientLaunch=1` and `SteamPath=...` if the + /// parent env doesn't already provide them. + /// 4. Inject `-noOriginStartup -multiple` into `arguments` (the + /// `-noOriginStartup` flag skips Northstar/TF2's Origin-startup + /// wait that hangs forever in Wine; `-multiple` avoids the + /// "another instance already running" race between Steam's + /// verification probe and our spawn). + /// + /// `None` (the default) is the EA-Desktop-style launch path — env + /// vars stay `"EA"` and no Steam-specific setup happens. + pub steam_app_id: Option, } pub enum LaunchMode { @@ -125,6 +142,18 @@ pub struct ActiveGameContext { mode: LaunchMode, injections: Vec, cloud_saves: bool, + /// The Steam App ID this launch came from, if any. Threaded through + /// from `LaunchOptions.steam_app_id` so the LSX request handlers + /// (specifically `GetProfile` and `GetAllGameInfo`) can return + /// consistent values for `IsSteamSubscriber` / `EntitlementSource` + /// without resorting to reading `env::var("SteamAppId")` from the + /// serve process (which doesn't have it — those env vars are set + /// directly on the spawned game's `Command`, not on the parent). + /// + /// `None` means this is an EA-Desktop-style launch (TF2 emitting + /// `link2ea://launchgame/Origin.OFR.…` mid-run, or maxima-cli launch + /// with an Origin offer ID slug). + steam_app_id: Option, process: Child, started: bool, } @@ -137,6 +166,7 @@ impl ActiveGameContext { content_id: &str, offer: Option, mode: LaunchMode, + steam_app_id: Option, process: Child, ) -> Self { Self { @@ -147,6 +177,7 @@ impl ActiveGameContext { mode, injections: Vec::new(), cloud_saves, + steam_app_id, process, started: false, } @@ -266,7 +297,23 @@ pub async fn start_game( let auth = LicenseAuth::AccessToken(maxima.access_token().await?); let offer = offer.as_ref().unwrap(); - if needs_license_update(&content_id).await? { + + // Diagnostic override: setting `MAXIMA_SKIP_LICENSE_WRITE=1` in the + // environment makes us NOT fetch + write the `.dlf` license file + // to `…/EA Services/License/.dlf`. Used to test whether + // TF2's "Engine Error: File corruption detected" symptom is driven + // by the on-disk `.dlf` (hardware-hash mismatch hypothesis from + // CLAUDE.md) — if TF2 still corrupts when we DON'T write a `.dlf`, + // the issue is somewhere else (Steam DRM, local file integrity, + // some other check). Remove the .dlf manually before testing so + // there's no stale file lying around. + if env::var("MAXIMA_SKIP_LICENSE_WRITE").is_ok() { + warn!( + "MAXIMA_SKIP_LICENSE_WRITE is set — skipping OOA license \ + fetch + .dlf write entirely. Game will only have whatever \ + .dlf was already on disk (or none)." + ); + } else if needs_license_update(&content_id).await? { info!( "Requesting new game license for {}...", offer.offer().display_name() @@ -318,6 +365,28 @@ pub async fn start_game( game_args.append(&mut parse_arguments(args.as_str())); } + // Steam-Play context: auto-inject `-noOriginStartup -multiple` if not + // already present. The two flags are required for EA-on-Steam titles + // (notably TF2) under Wine — see the `LaunchOptions.steam_app_id` + // doc comment for the why. We add them BEFORE building bootstrap_args + // so the bootstrap-spawned child gets them via the base64 payload. + let is_steam_launch = options.steam_app_id.is_some(); + if is_steam_launch { + if !game_args + .iter() + .any(|a| a.eq_ignore_ascii_case("-noOriginStartup")) + { + game_args.insert(0, "-noOriginStartup".to_string()); + } + if !game_args + .iter() + .any(|a| a.eq_ignore_ascii_case("-multiple")) + { + game_args.insert(0, "-multiple".to_string()); + } + info!("Steam-Play context; final game args: {:?}", game_args); + } + if !bootstrap_path()?.exists() { return Err(LaunchError::BootstrapMissing); } @@ -339,9 +408,9 @@ pub async fn start_game( // Source / owner / entitlement env vars: "EA" for EA-Desktop-launched // games, "Steam" for games launched via Steam (the user clicked Play in // Steam). When mismatched, TF2 (and likely other EA-on-Steam titles) - // throws a "corrupted game files" error because its DRM stub expects the - // ownership tag to match its install context. - let source_tag = if options.steam_launch { "Steam" } else { "EA" }; + // throws a "corrupted game files" error because its DRM stub expects + // the ownership tag to match its install context. + let source_tag = if is_steam_launch { "Steam" } else { "EA" }; child .current_dir(PathBuf::from(path).safe_parent()?) @@ -375,6 +444,36 @@ pub async fn start_game( .env("ContentId", content_id.clone()) .env("EAOnErrorExitRetCode", "1"); + // Steam-Play env vars on the spawned child specifically (NOT via + // `std::env::set_var` on the parent — that would persist for every + // future spawn in the same process, which matters for the long- + // running `maxima-cli serve` host where /authorize spawns multiple + // games over its lifetime). + // + // The Steam DRM stub in EA-on-Steam titles reads `SteamAppId` / + // `SteamGameId` during `SteamAPI_Init()`. If either is absent the + // game exits immediately with code 100010 ("Steam not detected"). + // `SteamClientLaunch` and `SteamPath` are normally set by Steam's + // own runtime; we default-fill them from the parent env (if Steam + // really did launch us) or to safe constants otherwise. + if let Some(ref app_id) = options.steam_app_id { + child + .env("SteamAppId", app_id) + .env("SteamGameId", app_id); + let inherited_client_launch = env::var("SteamClientLaunch").ok(); + child.env( + "SteamClientLaunch", + inherited_client_launch.as_deref().unwrap_or("1"), + ); + let inherited_steam_path = env::var("SteamPath").ok(); + child.env( + "SteamPath", + inherited_steam_path + .as_deref() + .unwrap_or("C:\\Program Files (x86)\\Steam"), + ); + } + match mode { LaunchMode::Offline(ref _offer_id) => { // Offline mode: use cached license, skip cloud sync @@ -382,7 +481,29 @@ pub async fn start_game( child.env("EALaunchOfflineMode", "true"); } LaunchMode::Online(ref offer_id) => { - let short_token = request_opaque_ooa_token(&access_token).await?; + // Best-effort: fetch an OPAQUE short-token for `EALaunchUserAuthToken` + // (introduced by upstream PR #34 so the OOA license API works even + // with a hardware-hash mismatch). Under Wine / CrossOver EA's auth + // service routinely rejects this exchange with a redirect to + // `signin.ea.com` (treated as `AuthError::InvalidRedirect`). When + // that happens we fall back to the JWS access token — that's the + // pre-PR-#34 upstream behavior and it still satisfies the env-var + // contract the game expects. Without this fallback every launch + // would fail end-to-end on bottles where the OOA exchange isn't + // happy with our pc_sign / token, even though the rest of the + // flow is fine. + let short_token = match request_opaque_ooa_token(&access_token).await { + Ok(token) => token, + Err(err) => { + warn!( + "OPAQUE OOA token exchange failed ({}); falling back to \ + JWS access_token for EALaunchUserAuthToken. The game's \ + OOA-side calls may still work via EAAccessTokenJWS.", + err + ); + access_token.clone() + } + }; child .env("EAConnectionId", offer_id.clone()) @@ -408,6 +529,7 @@ pub async fn start_game( &content_id, offer, mode, + options.steam_app_id.clone(), child, )); diff --git a/maxima-lib/src/core/mod.rs b/maxima-lib/src/core/mod.rs index 88ff044..39c2535 100644 --- a/maxima-lib/src/core/mod.rs +++ b/maxima-lib/src/core/mod.rs @@ -63,6 +63,7 @@ use self::{ }, }; use crate::{ + auth_server, content::manager::{ContentManager, ContentManagerError}, lsx::{self, service::LSXServerError, types::LSXRequestType}, rtm::client::{BasicPresence, RtmClient}, @@ -232,6 +233,62 @@ impl Maxima { pub async fn start_lsx(&self, maxima: LockedMaxima) -> Result<(), LSXServerError> { let lsx_port = self.lsx_port; + // Cooperate with any LSX server already listening on the same port + // — this is what makes `maxima-cli serve` actually useful. + // + // The protocol-handler chain (Steam Play → `link2ea://launchgame/X` + // → bootstrap → `maxima-cli.exe launch X`) **always** spawns a fresh + // maxima-cli process. That child has its own `Maxima` instance with + // `playing = Some(context)` and, without this guard, also tries to + // bind 127.0.0.1:3216. On a stock Linux/Windows stack the second + // `TcpListener::bind` would fail and the child's LSX task would + // exit harmlessly — the game's traffic would then hit the original + // server (serve / UI / earlier instance) which has `playing()=None`, + // exercising the catornot/patch-external-lsx code path that the + // user reports works on Windows. + // + // Under Wine on macOS/CrossOver we observed the opposite: the + // child's bind appears to succeed (or take precedence), so the + // game's connection lands on the *child's* LSX server, where + // `playing()=Some(...)`. That puts every handler down the + // active-launch branch and reproduces the "File corruption + // detected" symptom the user has been hitting. + // + // The fix is a synchronous probe: if a TCP connection to + // 127.0.0.1: succeeds, an LSX server is already there, so + // we deliberately do NOT start another. The child still proceeds + // with `launch::start_game` (license preflight, env vars, spawn + // the game executable) — the game's `EALsxPort=` env var + // will resolve to the existing server. + // + // Non-blocking probe via tokio so we don't park an executor + // thread for up to 200ms (an earlier version used + // `std::net::TcpStream::connect_timeout` which did exactly that). + // The connect is cheap when nothing's listening — immediate + // ECONNREFUSED on localhost — so the timeout is mostly a guard + // against accidental long DNS resolves or routing weirdness. + let probe_addr = format!("127.0.0.1:{}", lsx_port); + let probe_result = tokio::time::timeout( + Duration::from_millis(200), + tokio::net::TcpStream::connect(&probe_addr), + ) + .await; + match probe_result { + Ok(Ok(stream)) => { + drop(stream); + info!( + "LSX server already listening on {} (likely `maxima-cli serve` \ + in another window); skipping our own bind so the game's traffic \ + lands on the existing server.", + probe_addr + ); + return Ok(()); + } + Ok(Err(_)) | Err(_) => { + // Nothing listening or probe timed out — proceed to bind below. + } + } + tokio::spawn(async move { if let Err(e) = lsx::service::start_server(lsx_port, maxima).await { error!("Error starting LSX server: {}", e); @@ -242,6 +299,29 @@ impl Maxima { Ok(()) } + /// Start the `/authorize` HTTP server. Companion to [`Self::start_lsx`] + /// for the bootstrap → `link2ea://` forward path — see + /// [`crate::auth_server`] for protocol details. Defaults to port + /// [`crate::auth_server::AUTHORIZE_PORT`] (13219); override with the + /// `MAXIMA_AUTHORIZE_PORT` env var. + /// + /// This method is intended to be called once per process at startup + /// (e.g. `maxima-cli serve` or the UI bridge thread). It returns + /// immediately after the listener is bound; the accept loop runs in + /// a tokio task. Errors are surfaced if the bind itself fails, so + /// callers can degrade gracefully (the LSX server keeps working + /// even if authorize-HTTP is unavailable). + pub async fn start_auth_server( + &self, + maxima: LockedMaxima, + ) -> Result<(), auth_server::AuthServerError> { + let port = env::var("MAXIMA_AUTHORIZE_PORT") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(auth_server::AUTHORIZE_PORT); + auth_server::start_server(port, maxima).await + } + pub async fn access_token(&mut self) -> Result { let mut auth_storage = self.auth_storage.lock().await; match auth_storage.access_token().await? { diff --git a/maxima-lib/src/lib.rs b/maxima-lib/src/lib.rs index 0d878db..914adfe 100644 --- a/maxima-lib/src/lib.rs +++ b/maxima-lib/src/lib.rs @@ -4,11 +4,13 @@ #![feature(trait_alias)] #![feature(type_alias_impl_trait)] +pub mod auth_server; pub mod content; pub mod core; pub mod lsx; pub mod ooa; pub mod rtm; +pub mod steam; pub mod util; #[cfg(unix)] diff --git a/maxima-lib/src/lsx/connection.rs b/maxima-lib/src/lsx/connection.rs index 7d4bb47..7edda15 100644 --- a/maxima-lib/src/lsx/connection.rs +++ b/maxima-lib/src/lsx/connection.rs @@ -1,6 +1,6 @@ use derive_getters::Getters; use lazy_static::lazy_static; -use log::{debug, error, warn}; +use log::{debug, error, info, warn}; use quick_xml::DeError; use regex::Regex; use std::{ @@ -151,7 +151,10 @@ impl ConnectionState { pub fn queue_message(&mut self, message: LSX) -> Result<(), LSXConnectionError> { let mut str = quick_xml::se::to_string(&message)?; - debug!("Queuing LSX Message: {}", str); + // Same rationale as the `info!("Received LSX Message: …")` log + // above — paired here so the trace shows the request/response + // sequence in order. + info!("Queuing LSX Message: {}", str); if let EncryptionState::Enabled(key) = self.encryption { str = simple_encrypt(str.as_bytes(), &key) @@ -376,7 +379,14 @@ impl Connection { // Message Processing async fn process_message(&mut self, message: &str) -> Result<(), LSXConnectionError> { - debug!("Received LSX Message: {}", message); + // Promoted from `debug!` to `info!` so the per-launch LSX trace + // is captured in `maxima-cli.log` by default. The XML payload is + // typically <500 bytes per message and we receive ~15 messages + // per TF2 launch, so the volume is fine. Lets us diagnose exactly + // which LSX request TF2 sends last before disconnecting (the + // "File corruption" symptom kills the connection mid-flow and + // the last successful request tells us where to look next). + info!("Received LSX Message: {}", message); let mut message = message.to_string(); message.remove_matches("version=\"\" "); diff --git a/maxima-lib/src/lsx/request/game.rs b/maxima-lib/src/lsx/request/game.rs index f3f4150..2cf8fe8 100644 --- a/maxima-lib/src/lsx/request/game.rs +++ b/maxima-lib/src/lsx/request/game.rs @@ -65,6 +65,27 @@ pub async fn handle_all_game_info_request( ) }; + // EntitlementSource must agree with `IsSteamSubscriber` in + // `GetProfileResponse` — TF2's DRM stub treats any contradiction + // (e.g. "STEAM" + IsSteamSubscriber=false) as a tamper signal and + // shows "Engine Error: File corruption detected". Both are now + // sourced from `ActiveGameContext.steam_app_id` (the original + // Steam App ID that triggered this launch, if any). + let entitlement_source: String = { + let arc = state.write().await.maxima_arc(); + let maxima = arc.lock().await; + let is_steam = maxima + .playing() + .as_ref() + .and_then(|p| p.steam_app_id().as_ref()) + .is_some(); + if is_steam { + "STEAM".to_string() + } else { + "EA".to_string() + } + }; + make_lsx_handler_response!(Response, GetAllGameInfoResponse, { attr_FullGamePurchased: true, attr_FullGameReleased: true, @@ -74,7 +95,7 @@ pub async fn handle_all_game_info_request( attr_Expiration: "0000-00-00T00:00:00".to_string(), attr_UpToDate: true, attr_HasExpiration: false, - attr_EntitlementSource: "STEAM".to_string(), + attr_EntitlementSource: entitlement_source, attr_AvailableVersion: version, attr_DisplayName: title, attr_FreeTrial: false, diff --git a/maxima-lib/src/lsx/request/license.rs b/maxima-lib/src/lsx/request/license.rs index 19261ac..b07cb18 100644 --- a/maxima-lib/src/lsx/request/license.rs +++ b/maxima-lib/src/lsx/request/license.rs @@ -25,7 +25,21 @@ pub async fn handle_license_request( let arc = state.write().await.maxima_arc(); let mut maxima = arc.lock().await; - let playing = maxima.playing().as_ref().unwrap(); + // When the game wasn't launched through Maxima (e.g. the user opened + // Maxima UI / `maxima-cli serve` and then started TF2 via Steam or + // Northstar mode), `maxima.playing()` is None — there is no + // ActiveGameContext to consult for content_id or mode. Upstream this + // unwrap-panics, killing the spawned LSX-request task and leaving the + // game waiting forever for a response. Mirror the same defensive + // pattern `handle_set_presence_request` already uses below: return an + // empty `attr_License` so TF2 falls back to its on-disk `.dlf` (which + // `request_and_save_license` deposited at `…/EA Services/License/` + // during the prior `maxima-cli launch` run, if there was one) rather + // than crashing the connection. + let Some(playing) = maxima.playing().as_ref() else { + info!("RequestLicense from external LSX (playing=None); returning empty token so the game falls back to its cached .dlf"); + return make_lsx_handler_response!(Response, RequestLicenseResponse, { attr_License: String::new() }); + }; let content_id = playing.content_id().to_owned(); let mode = playing.mode(); diff --git a/maxima-lib/src/lsx/request/profile.rs b/maxima-lib/src/lsx/request/profile.rs index af9aa6c..26703d1 100644 --- a/maxima-lib/src/lsx/request/profile.rs +++ b/maxima-lib/src/lsx/request/profile.rs @@ -39,15 +39,23 @@ pub async fn handle_profile_request( debug!("Got profile for {} {:?}", &name, path); // When the game was launched from a Steam context, the user IS a Steam - // subscriber for this title — the EntitlementSource in GetAllGameInfo - // is already "STEAM", and TF2's DRM stub treats a contradiction between - // those two fields (entitlement says Steam, profile says "not a Steam - // subscriber") as a tamper signal and triggers "Engine Error: File - // corruption detected". + // subscriber for this title — and the EntitlementSource in GetAllGameInfo + // also reports "STEAM" so we keep the two consistent here. TF2's DRM stub + // treats any contradiction between those two fields (e.g. entitlement + // says Steam, profile says "not a Steam subscriber") as a tamper signal + // and shows "Engine Error: File corruption detected". // - // Detect by env var SteamAppId which maxima-cli sets when invoked via a - // `link2ea://launchgame/?platform=steam` URL. - let is_steam_launch = std::env::var("SteamAppId").is_ok(); + // Read the launch context from `Maxima.playing()` — populated by + // `launch::start_game` with `LaunchOptions.steam_app_id`. This used to + // read `env::var("SteamAppId")` directly, which worked when maxima-cli + // set the env var on its own process, but stopped working once + // `launch::start_game` started setting it on the spawned game's Command + // only (not the parent serve process where this handler runs). + let is_steam_launch = maxima + .playing() + .as_ref() + .and_then(|p| p.steam_app_id().as_ref()) + .is_some(); make_lsx_handler_response!(Response, GetProfileResponse, { attr_Persona: name.to_owned(), diff --git a/maxima-lib/src/lsx/service.rs b/maxima-lib/src/lsx/service.rs index e5cef99..64a54fd 100644 --- a/maxima-lib/src/lsx/service.rs +++ b/maxima-lib/src/lsx/service.rs @@ -29,12 +29,16 @@ pub async fn start_server(port: u16, maxima: LockedMaxima) -> Result<(), LSXServ while idx < connections.len() { let connection = &mut connections[idx]; - if let Err(_) = connection.process_queue().await { - warn!("Failed to process LSX message queue"); + if let Err(err) = connection.process_queue().await { + warn!("Failed to process LSX message queue: {}", err); } - if let Err(_) = connection.listen().await { - warn!("LSX connection closed"); + if let Err(err) = connection.listen().await { + // Surface the actual error — `Closed` means clean EOF + // from the peer (game closed the socket on purpose), + // anything else is an unexpected transport / parse + // failure that may be the cause of an in-game error. + warn!("LSX connection closed: {}", err); connections.remove(idx); maxima .lock() diff --git a/maxima-lib/src/steam.rs b/maxima-lib/src/steam.rs new file mode 100644 index 0000000..d717334 --- /dev/null +++ b/maxima-lib/src/steam.rs @@ -0,0 +1,168 @@ +//! Steam install discovery helpers, shared between `maxima-cli` (which uses +//! them when `link2ea://launchgame/?platform=steam` arrives with no +//! linked EA library) and `auth_server` (which needs the on-disk path to +//! tell `request_and_save_license` which OOA version to probe). +//! +//! These were originally in `maxima-cli/src/main.rs`; they were moved up +//! into `maxima-lib` once the same lookup was needed from the HTTP +//! `/authorize` handler, so we don't end up with two copies that can drift. + +use std::path::PathBuf; + +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + /// Matches a well-formed EA offer ID like "Origin.OFR.50.0002694". + pub static ref EA_OFFER_ID_PATTERN: Regex = Regex::new(r"^Origin\.OFR\.\d+\.\d+$").unwrap(); + /// Matches a Steam App ID emitted by `link2ea://launchgame/?platform=steam`. + /// Current Steam App IDs fit in 1..=10 ASCII digits (max issued is ~3M). + pub static ref STEAM_APP_ID_PATTERN: Regex = Regex::new(r"^\d{1,10}$").unwrap(); +} + +/// Hardcoded fallback table for EA-published games available on Steam. +/// +/// When a Steam-only owner whose EA account isn't linked launches an +/// EA-on-Steam title, the EA library lookup fails — both for the +/// offer-id translation (Steam App ID → Origin offer ID) AND for the +/// install location (EA Desktop doesn't know where Steam put the game). +/// This table provides both: +/// - the EA Origin offer ID to use for license/auth +/// - the relative path under `steamapps/common/` to find the exe +/// +/// Discovery (`resolve_steam_install_path`): +/// 1. Read `HKLM\SOFTWARE\(Wow6432Node\)Valve\Steam\InstallPath` for Steam root +/// 2. Parse `\steamapps\libraryfolders.vdf` for additional libraries +/// 3. Verify `\steamapps\common\\` exists +/// +/// Extend as more EA-on-Steam titles get validated. +pub struct SteamGameEntry { + pub steam_app_id: &'static str, + pub origin_offer_id: &'static str, + /// Directory name under `steamapps/common/`, e.g. "Titanfall2". + pub install_subdir: &'static str, + /// Game executable filename within the install dir, e.g. "Titanfall2.exe". + pub exe_name: &'static str, +} + +pub const STEAM_GAMES: &[SteamGameEntry] = &[ + SteamGameEntry { + steam_app_id: "1237970", + // Note: NOT Origin.OFR.50.0002694 — that's Apex Legends. TF2's real + // offer ID is Origin.OFR.50.0001456, confirmed against a real EA + // library dump ("titanfall-2 - Titanfall 2 - Origin.OFR.50.0001456"). + origin_offer_id: "Origin.OFR.50.0001456", + install_subdir: "Titanfall2", + exe_name: "Titanfall2.exe", + }, +]; + +pub fn lookup_steam_game(steam_app_id: &str) -> Option<&'static SteamGameEntry> { + STEAM_GAMES.iter().find(|g| g.steam_app_id == steam_app_id) +} + +/// Reverse of `lookup_steam_game`: find the entry for a given Origin offer ID. +/// Used by the HTTP `/authorize` handler — it receives an offer ID (because +/// that's what `link2ea://launchgame/Origin.OFR.…` carries when TF2 emits the +/// URL mid-run) and needs to find the on-disk path even if the EA library +/// lookup fails. +pub fn lookup_steam_game_by_offer(origin_offer_id: &str) -> Option<&'static SteamGameEntry> { + STEAM_GAMES.iter().find(|g| g.origin_offer_id == origin_offer_id) +} + +/// Resolve where a Steam-installed EA game lives on disk. Returns the full +/// path to the executable, or None if not installed in any known Steam +/// library. +/// +/// Lookup order: +/// 1. Steam install path from registry (both 32-bit and 64-bit views) +/// 2. Common defaults (covers fresh Wine bottles where the registry key +/// may not yet exist) +/// 3. Parse `libraryfolders.vdf` to discover extra Steam library folders +/// 4. Verify `\steamapps\common\\` exists +#[cfg(windows)] +pub fn resolve_steam_install_path(game: &SteamGameEntry) -> Option { + use winreg::enums::HKEY_LOCAL_MACHINE; + use winreg::RegKey; + + let mut steam_roots: Vec = Vec::new(); + + // 1. Registry — try both views since Steam installs as 32-bit on most systems + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + for key in &[ + "SOFTWARE\\WOW6432Node\\Valve\\Steam", + "SOFTWARE\\Valve\\Steam", + ] { + if let Ok(subkey) = hklm.open_subkey(key) { + if let Ok(path) = subkey.get_value::("InstallPath") { + steam_roots.push(PathBuf::from(path)); + } + } + } + + // 2. Common defaults (covers fresh Wine bottles where the registry key + // may not have been written yet, or when running outside Wine) + for default in &[ + "C:\\Program Files (x86)\\Steam", + "C:\\Program Files\\Steam", + ] { + let p = PathBuf::from(default); + if p.exists() && !steam_roots.contains(&p) { + steam_roots.push(p); + } + } + + // 3. For each Steam root, gather library folders from libraryfolders.vdf + // and search for the game. + for root in &steam_roots { + let mut libraries: Vec = vec![root.clone()]; + + // VDF is a simple key-value format; we don't need a full parser + // — just grep "path" lines. + let vdf_paths = [ + root.join("steamapps").join("libraryfolders.vdf"), + root.join("config").join("libraryfolders.vdf"), + ]; + for vdf in &vdf_paths { + if let Ok(content) = std::fs::read_to_string(vdf) { + for line in content.lines() { + let trimmed = line.trim(); + // Lines look like: "path" "C:\\SteamLibrary" + if let Some(rest) = trimmed.strip_prefix("\"path\"") { + if let Some(start) = rest.find('"') { + let after = &rest[start + 1..]; + if let Some(end) = after.find('"') { + let extra = PathBuf::from(after[..end].replace("\\\\", "\\")); + if !libraries.contains(&extra) { + libraries.push(extra); + } + } + } + } + } + } + } + + // 4. Verify the executable exists in each library + for lib in &libraries { + let exe = lib + .join("steamapps") + .join("common") + .join(game.install_subdir) + .join(game.exe_name); + if exe.exists() { + return Some(exe); + } + } + } + + None +} + +#[cfg(not(windows))] +pub fn resolve_steam_install_path(_game: &SteamGameEntry) -> Option { + // Non-Windows builds (Linux CI, native macOS) can't read the Windows + // registry. Maxima only runs through Wine on those targets and the + // win32 binaries take the cfg(windows) branch. + None +} diff --git a/maxima-service/Cargo.toml b/maxima-service/Cargo.toml index 812ea88..233dd35 100644 --- a/maxima-service/Cargo.toml +++ b/maxima-service/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "maxima-service" -version = "0.4.0" +version = "0.5.0" authors = ["Sean Kahler "] edition = "2021" diff --git a/maxima-tui/src/main.rs b/maxima-tui/src/main.rs index 43ed8fa..2e213f3 100644 --- a/maxima-tui/src/main.rs +++ b/maxima-tui/src/main.rs @@ -445,7 +445,10 @@ async fn start_game( path_override: game_path_override, arguments: game_args, cloud_saves: true, - steam_launch: false, + // TUI launches the way EA Desktop's UI does — Steam-Play + // handoffs come through bootstrap → /authorize, not through + // the TUI's launch path. + steam_app_id: None, }; if login.is_none() { diff --git a/maxima-ui/src/bridge/start_game.rs b/maxima-ui/src/bridge/start_game.rs index 4d20bfc..32abcec 100644 --- a/maxima-ui/src/bridge/start_game.rs +++ b/maxima-ui/src/bridge/start_game.rs @@ -42,7 +42,10 @@ pub async fn start_game_request( path_override: exe_override, arguments: args, cloud_saves, - steam_launch: false, + // UI launches are always EA-Desktop-style; the UI never + // receives a Steam App ID. Steam-Play handoffs come through + // `link2ea://` to the bootstrap, not the UI's Play button. + steam_app_id: None, }, ) .await