diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12546ad..6e4c49f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,11 @@ jobs: with: node-version: 22 + # Nothing in the repository ships an icon, so without this the icon rules + # would only ever run against a contributor's plugin. + - name: Test the registry tooling + run: node --test scripts/lib/*.test.mjs + # Runs the same manifest validator Studio uses at install time, so a # plugin that passes CI is one Studio will accept. - name: Validate manifests @@ -65,7 +70,21 @@ jobs: working-directory: plugins/${{ matrix.plugin }} run: yarn typecheck - # Also proves the built output matches what manifest.json declares. + # Opt-in: a plugin with no "test" script is not a plugin with a failing + # one. `yarn run --top-level` is not it — this asks package.json directly. + - name: Test + working-directory: plugins/${{ matrix.plugin }} + run: | + if node -e "process.exit(require('./package.json').scripts?.test ? 0 : 1)"; then + yarn test + else + echo "no test script; skipping" + fi + + # Also proves the built output matches what manifest.json declares. This + # runs on Linux with no Rust toolchain, so a sidecar plugin packages here + # in its mirror-only shape — which is exactly the shape worth checking on + # every push, because it is the one that must never break. - name: Build and package run: node scripts/package-plugin.mjs ${{ matrix.plugin }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7724b79..61eac31 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,15 +12,19 @@ permissions: contents: write jobs: - release: - name: Publish ${{ github.ref_name }} + prepare: + name: Check ${{ github.ref_name }} runs-on: ubuntu-latest + outputs: + id: ${{ steps.tag.outputs.id }} + version: ${{ steps.tag.outputs.version }} + asset: ${{ steps.tag.outputs.asset }} + has-sidecar: ${{ steps.sidecar.outputs.has-sidecar }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 22 - - run: corepack enable - name: Parse tag id: tag @@ -41,7 +45,8 @@ jobs: TAG: ${{ github.ref_name }} # The tag must agree with what is committed, otherwise the published - # artifact would not match the source anyone can read at that tag. + # artifact would not match the source anyone can read at that tag. Checked + # before anything is built, so a bad tag costs one runner and not four. - name: Verify tag matches committed version run: | node -e " @@ -77,28 +82,130 @@ jobs: - name: Validate run: node scripts/validate.mjs ${{ steps.tag.outputs.id }} + # A plugin ships a native sidecar if it has a builder for one. Plugins + # without it skip the whole matrix below rather than spinning up three + # runners to do nothing. + - name: Detect sidecar + id: sidecar + run: | + if [ -f "plugins/${{ steps.tag.outputs.id }}/sidecar/build.mjs" ]; then + echo "has-sidecar=true" >> "$GITHUB_OUTPUT" + else + echo "has-sidecar=false" >> "$GITHUB_OUTPUT" + fi + + # One runner per platform, because a native binary can only honestly be built + # on the platform it targets: cross-building loses the executable bit and, on + # macOS, the second architecture. + sidecar: + name: Sidecar ${{ matrix.platform-key }} + needs: prepare + if: needs.prepare.outputs.has-sidecar == 'true' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + include: + - os: windows-latest + platform-key: windows-x64 + - os: macos-latest + platform-key: macos-universal + # macos-latest is Apple silicon; the Intel slice needs adding before + # sidecar/build.mjs can lipo the two together. + extra-targets: x86_64-apple-darwin + - os: ubuntu-latest + platform-key: linux-x64 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Install Rust + run: rustup update stable --no-self-update && rustup default stable + + - name: Add extra targets + if: matrix.extra-targets != '' + run: rustup target add ${{ matrix.extra-targets }} + + - uses: Swatinem/rust-cache@v2 + with: + workspaces: plugins/${{ needs.prepare.outputs.id }}/sidecar + + # Invoked directly rather than through `yarn build:sidecar`: this needs + # cargo and Node, not the plugin's JavaScript dependencies, and installing + # them on three runners buys nothing. + - name: Build + working-directory: plugins/${{ needs.prepare.outputs.id }} + run: node sidecar/build.mjs + + - uses: actions/upload-artifact@v5 + with: + name: sidecar-${{ matrix.platform-key }} + path: plugins/${{ needs.prepare.outputs.id }}/bin + if-no-files-found: error + + release: + name: Publish ${{ github.ref_name }} + needs: [prepare, sidecar] + # `always()` so a plugin with no sidecar (matrix skipped) still publishes, + # while a matrix that actually failed still stops the release. + if: always() && needs.prepare.result == 'success' && (needs.sidecar.result == 'success' || needs.sidecar.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 22 + - run: corepack enable + + # Every platform's binaries land back under bin/, where the plugin's + # build.mjs looks for them. Packaging on Linux is safe even for the macOS + # image: no zip writer records POSIX modes, so Studio repairs the + # executable bit when it spawns a sidecar (sidecarHost.ensureExecutable). + - name: Collect sidecar binaries + if: needs.prepare.outputs.has-sidecar == 'true' + uses: actions/download-artifact@v5 + with: + pattern: sidecar-* + merge-multiple: true + path: plugins/${{ needs.prepare.outputs.id }}/bin + + - name: Show what will ship + if: needs.prepare.outputs.has-sidecar == 'true' + run: find plugins/${{ needs.prepare.outputs.id }}/bin -type f -exec sha256sum {} + + - name: Install (immutable) - working-directory: plugins/${{ steps.tag.outputs.id }} + working-directory: plugins/${{ needs.prepare.outputs.id }} run: yarn install --immutable - name: Typecheck - working-directory: plugins/${{ steps.tag.outputs.id }} + working-directory: plugins/${{ needs.prepare.outputs.id }} run: yarn typecheck + - name: Test + working-directory: plugins/${{ needs.prepare.outputs.id }} + run: | + if node -e "process.exit(require('./package.json').scripts?.test ? 0 : 1)"; then + yarn test + else + echo "no test script; skipping" + fi + - name: Build and package - run: node scripts/package-plugin.mjs ${{ steps.tag.outputs.id }} + run: node scripts/package-plugin.mjs ${{ needs.prepare.outputs.id }} - name: Publish release uses: softprops/action-gh-release@v2 with: - name: ${{ steps.tag.outputs.id }} ${{ steps.tag.outputs.version }} - files: .out/${{ steps.tag.outputs.asset }} + name: ${{ needs.prepare.outputs.id }} ${{ needs.prepare.outputs.version }} + files: .out/${{ needs.prepare.outputs.asset }} fail_on_unmatched_files: true body: | - **${{ steps.tag.outputs.id }}** v${{ steps.tag.outputs.version }} + **${{ needs.prepare.outputs.id }}** v${{ needs.prepare.outputs.version }} - Download `${{ steps.tag.outputs.asset }}`, unzip it, then in NarraLeaf Studio open + Download `${{ needs.prepare.outputs.asset }}`, unzip it, then in NarraLeaf Studio open **Launcher → Plugins → Install from folder** and select the unzipped - `${{ steps.tag.outputs.id }}` folder. + `${{ needs.prepare.outputs.id }}` folder. - Source: [`plugins/${{ steps.tag.outputs.id }}`](https://github.com/NarraLeaf/Plugins/tree/${{ github.ref_name }}/plugins/${{ steps.tag.outputs.id }}) + Source: [`plugins/${{ needs.prepare.outputs.id }}`](https://github.com/NarraLeaf/Plugins/tree/${{ github.ref_name }}/plugins/${{ needs.prepare.outputs.id }}) diff --git a/.gitignore b/.gitignore index aaaacc7..1c3d27e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ Thumbs.db # Local packaging output from scripts/package-plugin.mjs .out/ + +# Rust sidecars: `target/` is cargo's scratch, `bin/` holds the compiled +# artifacts a build drops for build.mjs to pick up. Neither is source — a +# release builds them on each platform's own runner (see .github/workflows). +target/ +plugins/*/bin/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc36af7..c19b900 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,6 +53,17 @@ A pull request is mergeable when: - **Permissions are minimal.** Every entry in `permissions` needs a reason in the pull request description. Filesystem and API permissions get the most scrutiny — plugins are not sandboxed, so an approved permission is real trust. +- **An icon, if you ship one, is square.** `manifest.json` may declare + `"icon": "icon.png"` — a package-relative path to the thumbnail Studio shows + beside your plugin in the Launcher list, installed and store alike. It must be + `.png`, `.webp`, `.jpg` or `.jpeg` (no SVG — it is a document that can carry + script; no GIF — an animating row is not yours to impose), square, between + 64x64 and 512x512, and at most 512 KB. Your build script has to copy it into + `dist/` the way it copies `manifest.json`; the template's already does. + `scripts/validate.mjs` checks every rule, and Studio refuses to install a + package whose icon is missing or out of bounds. Declaring one is optional: + a plugin without an icon gets the monogram tile Studio draws from its name, + which is a perfectly good place to stay. - **Host modules stay external.** Never bundle `react`, `react-dom`, or `narraleaf-studio/*`. The host supplies them through an import map; bundling React produces a second, broken instance. The template's `build.mjs` already @@ -83,6 +94,13 @@ if the tag, `manifest.json`, and `index.json` disagree. See ## Keeping the validator honest `scripts/lib/plugins.mjs` contains a port of Studio's manifest validator -(`src/shared/utils/pluginManifest.ts`). If Studio's validation rules change, -update the port in the same change — otherwise CI accepts manifests that Studio -rejects at install, which is the worst possible failure mode for a registry. +(`src/shared/utils/pluginManifest.ts`), and `scripts/lib/image.mjs` a port of its +icon rules (`src/shared/constants/pluginIcon.ts` plus +`src/shared/utils/{pluginIcon,imageDimensions}.ts`). If Studio's validation rules +change, update the ports in the same change — otherwise CI accepts manifests that +Studio rejects at install, which is the worst possible failure mode for a +registry. + +Run the tooling's own tests with `node --test scripts/lib/*.test.mjs`. They carry +the icon rules in particular, because no plugin here ships an icon and the code +would otherwise never execute until a contributor's did. diff --git a/index.json b/index.json index 9e1d6fc..e2955d5 100644 --- a/index.json +++ b/index.json @@ -40,6 +40,61 @@ "download": "https://github.com/NarraLeaf/Plugins/releases/download/helloyork.nekolang-i18n%401.1.0/helloyork.nekolang-i18n-1.1.0.zip" }, "studioVersion": ">=0.0.1" + }, + { + "id": "narraleaf.steam-achievements", + "name": "Steam Achievements", + "version": "0.1.0", + "description": "Author Steam achievements and stats in Studio, and unlock them from blueprint graphs. Falls back to a local mirror wherever Steam is not available, so the same script works on itch, on the web export and in Dev Mode.", + "publisher": "NarraLeaf Studio", + "path": "plugins/narraleaf.steam-achievements", + "targets": [ + "studio", + "runtime" + ], + "categories": [ + "integration", + "blueprint" + ], + "keywords": [ + "narraleaf", + "narraleaf-studio-plugin", + "steam", + "steamworks", + "achievements", + "stats", + "sidecar" + ], + "license": "MPL-2.0", + "contributes": { + "blueprintNodes": [ + "narraleaf.steam-achievements.unlock", + "narraleaf.steam-achievements.isUnlocked", + "narraleaf.steam-achievements.indicateProgress", + "narraleaf.steam-achievements.setStat", + "narraleaf.steam-achievements.addStat", + "narraleaf.steam-achievements.getStat", + "narraleaf.steam-achievements.available", + "narraleaf.steam-achievements.language", + "narraleaf.steam-achievements.resetAll" + ], + "widgets": [], + "locales": [] + }, + "permissions": [], + "release": { + "tag": "narraleaf.steam-achievements@0.1.0", + "page": "https://github.com/NarraLeaf/Plugins/releases/tag/narraleaf.steam-achievements%400.1.0", + "download": "https://github.com/NarraLeaf/Plugins/releases/download/narraleaf.steam-achievements%400.1.0/narraleaf.steam-achievements-0.1.0.zip" + }, + "icon": "https://raw.githubusercontent.com/NarraLeaf/Plugins/narraleaf.steam-achievements%400.1.0/plugins/narraleaf.steam-achievements/icon.png", + "studioVersion": ">=0.2.0", + "locales": { + "zh-CN": { + "name": "Steam 成就", + "description": "在 Studio 里编写 Steam 成就与统计量,并用蓝图节点解锁。Steam 不可用时写入本地镜像,itch 版、web 版与 Dev Mode 下同一套脚本照常工作。" + } + } } ] } diff --git a/plugins/narraleaf.steam-achievements/README.md b/plugins/narraleaf.steam-achievements/README.md index b1efc71..4dd67fd 100644 --- a/plugins/narraleaf.steam-achievements/README.md +++ b/plugins/narraleaf.steam-achievements/README.md @@ -8,10 +8,6 @@ then echoes to Steam; every read node reads the mirror. So the same script works on the Steam build, the itch build, the web export, Android, iOS, Dev Mode, and a dev machine with Steam closed. Degrading is the design, not a fallback. -> **Status: not shippable yet.** The native bridge has never been compiled and -> the sha256 digests in `manifest.json` are placeholders, so this package will -> fail to install as-is. See [Building the sidecar](#building-the-sidecar). - ## What it adds **An achievements editor** — a full editor tab (opened from the left rail's @@ -56,6 +52,18 @@ When Steam launched the game it has already set `SteamAppId`, and *that* wins: it describes the app actually running. A disagreement with the catalog is logged rather than acted on. +## Which platforms reach Steam + +The bridge is a native executable, so it exists only where one was built. A +package carries a sidecar for a platform if, and only if, that binary was present +when `yarn build` ran — see [Building the sidecar](#building-the-sidecar). + +**A package with no bridge at all is not a broken package.** It is the +mirror-only build: every node still runs, every read still answers, nothing is +echoed to Steam. That is already what happens on the web export and on mobile, +which can never host a native child process, and on a desktop player who has +Steam closed. There is one behaviour to reason about, and it is the mirror. + ## Capabilities it asks for `contributes.runtimeCapabilities: ["store"]`, and nothing else. @@ -69,39 +77,55 @@ gated domain the plugin touches — no `state`, no `saves`, no `events`, no `contributes.sidecars` *is* the request, and the install prompt names the binaries and platforms. -The Steamworks redistributable is declared as -`contributes.buildDependencies`, so Studio fetches and verifies it at project -build time; it is not vendored here. +The Steam shared library (`steam_api64.dll` and its POSIX equivalents) ships +inside the package, beside the executable that links against it. It is not +fetched at build time and needs no Valve account: `steamworks-sys` vendors the +SDK, and `sidecar/build.mjs` copies the very library the binary was linked +against out of cargo's own output — so the two can never drift apart. ## Building the sidecar `sidecar/` holds the Rust source for `nl-steam-bridge`, the native child process -that actually talks to Steamworks. **It has never been compiled** — see -[sidecar/README.md](sidecar/README.md) for the build steps, the crate calls that -need checking, and why cross-building is refused. +that talks to Steamworks. Building it needs a Rust toolchain and nothing else — +**no SDK download and no Valve partner account** — because `steamworks-sys` +vendors the Steamworks SDK under its own `lib/steam/` and falls back to that copy +whenever `STEAM_SDK_LOCATION` is unset. + +```sh +yarn build:sidecar # cargo build for THIS platform -> bin// +yarn build # copies bin/ into dist/ and writes the digests +``` + +Host platform only, deliberately: a Windows host cannot set the executable bit on +a macOS or Linux artifact, so the packaged sidecar would arrive unrunnable — and +Studio's build preflight refuses those combinations for exactly that reason. Each +platform is built on its own runner; see `.github/workflows/release.yml` in the +repository root, which does all three and packages the result. -Before this plugin can be released: +### Digests are computed, never authored -1. Build the binary on each of Windows x64, macOS (universal), Linux x64. -2. Put them under `bin//` and replace every placeholder digest in - `manifest.json` (they are currently 64 zeros) with the real sha256 — Studio - verifies each one at install. -3. Fill in the Steamworks SDK zip digest in `contributes.buildDependencies`. +There is no sha256 to fill in by hand, and no `contributes.sidecars` block in +`manifest.json`. A sidecar target is a claim about bytes — *this package carries +this executable, and its hash is this* — and that claim is only true of a package +that actually has the binary. This repository has none; they are build output. -`yarn build` checks each digest and copies the payload into `dist/`. A *missing* -binary is only a warning so the JS half stays buildable without a Rust -toolchain; a *mismatched* one is fatal. +So `sidecar/contribution.json` holds everything about the sidecar that is *not* a +property of a compiled artifact, and `build.mjs` supplies the rest: it includes +each platform whose files are present, hashes them, and writes the block into +`dist/manifest.json`. Platforms with no binary are dropped with a line saying so. + +Studio verifies those digests when it compiles a game — a preview or a build — +not at install. A package whose bytes changed after installation fails there, +with the file and both hashes named. ## Known gaps -- **`yarn typecheck` fails against the published types.** The plugin is written - against the unreleased plugin API (the narrowed node context, `app.game.store`, - `app.game.sidecar`), which `narraleaf-studio@0.2.0` predates. Bump the - devDependency to `^0.3.0` once it is published; until then only `yarn build` - (esbuild, which strips types without checking them) is meaningful. - **Release languages are authored here, not read from the project.** The studio plugin surface exposes no project settings, so the catalog carries its own `locales` list and validation checks against that. +- **The editor tab is English only.** It ships no `contributes.locales` pack, so + its headers and buttons stay English whatever Studio is set to. The authored + achievement *text* is fully multilingual; only the chrome is not. - **No `avgrate` stats.** Steam writes average-rate stats with `UpdateAvgRateStat(name, countThisSession, sessionLength)`, and no node here has a session length to give — so an `avgrate` stat could only ever reach the @@ -116,15 +140,16 @@ toolchain; a *mismatched* one is fatal. requirement is therefore not validated either. - **Icons never reach the game.** They exist for the backend export. In-game achievement art should come from the gallery plugin or your own widgets. +- **Only `windows-x64` has been run against a real Steam client.** The macOS and + Linux builds are wired up in CI and share every line of source, but their first + release should be smoke-tested on those platforms before it is trusted. ## Development ```sh yarn install yarn build # or: yarn dev, for unminified output with sourcemaps +yarn test # the catalog's pure half +yarn typecheck +yarn icon # regenerate icon.png ``` - -To typecheck against an unreleased Studio API, stage its generated -`packages/plugin-types/dist` somewhere and point a throwaway tsconfig's `paths` -at it — keep the staged copy under `node_modules/` so React's types still -resolve from this package. diff --git a/plugins/narraleaf.steam-achievements/build.mjs b/plugins/narraleaf.steam-achievements/build.mjs index 3e9d832..655b173 100644 --- a/plugins/narraleaf.steam-achievements/build.mjs +++ b/plugins/narraleaf.steam-achievements/build.mjs @@ -1,20 +1,24 @@ /** - * Bundles each entry declared in manifest.json into dist/, then copies the - * sidecar payload the manifest declares. + * Bundles each entry declared in manifest.json into dist/, then assembles the + * manifest that actually ships. * * Same shape as template/build.mjs — one prebundled ESM file per entry, host - * modules left external — plus two things a sidecar plugin needs: + * modules left external — plus the one thing a sidecar plugin needs: the + * `contributes.sidecars` block is *generated here*, not authored. * - * 1. Package-relative `contributes.sidecars[].targets[].include` files are - * copied into dist/ at exactly the paths the manifest names. Studio resolves - * them against the installed package root, which is what dist/ becomes. - * 2. Every copied file is hashed and checked against the manifest's `sha256`. - * A mismatch is fatal: a package whose binary does not match its declared - * digest fails to install anyway, so failing here beats shipping it. + * Why generated. A sidecar target is a claim about bytes: this package carries + * this executable, and its sha256 is this. That claim is only true of a package + * that actually has the binary, and the repository has none — they are build + * output. Writing the block by hand would mean either placeholder digests (a lie + * the validator cannot catch, and one that surfaces much later as a failed game + * build) or a manifest describing files nobody has. * - * A *missing* binary is a warning, not an error, so the JS half stays buildable - * on a machine with no Rust toolchain. The release flow must not accept that - * warning — see README "Building the sidecar". + * So: for every platform in sidecar/contribution.json whose files are all + * present under bin/, this copies them into dist/, hashes them, and emits the + * target. Platforms with no binary are dropped with a line saying so. If none + * survive, `contributes.sidecars` is omitted entirely — and that package is not + * broken, it is the mirror-only build: every node still works, Steam is simply + * never reached. See src/bridge.ts. */ import crypto from "node:crypto"; @@ -37,8 +41,6 @@ const EXTERNALS = [ "react/jsx-dev-runtime", ]; -const DEP_INCLUDE_PREFIX = "dep:"; - const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf-8")); /** Map a declared entry (`main.js`) onto its source file (`src/main.tsx`). */ @@ -79,47 +81,77 @@ for (const target of ["studio", "runtime"]) { console.log(`built ${target} -> dist/${entry}`); } -const missing = []; -for (const sidecar of manifest.contributes?.sidecars ?? []) { - for (const [platformKey, target] of Object.entries(sidecar.targets ?? {})) { - for (const include of target.include ?? []) { - if (include.startsWith(DEP_INCLUDE_PREFIX)) { - // Served by a build dependency: Studio fetches and verifies it at - // project build time, so it is not in this package to copy. - continue; - } - const source = path.join(root, ...include.split("/")); - if (!fs.existsSync(source)) { - missing.push(`${sidecar.id} ${platformKey}: ${include}`); - continue; - } - const digest = crypto.createHash("sha256").update(fs.readFileSync(source)).digest("hex"); - const declared = String(target.sha256?.[include] ?? "").toLowerCase(); - if (digest !== declared) { - throw new Error( - `sha256 mismatch for ${include}\n manifest: ${declared}\n actual: ${digest}\n` + - "Update manifest.json (or rebuild the binary) — Studio rejects the package otherwise.", - ); - } +/* ------------------------------------------------------------------ sidecar */ + +const contributionPath = path.join(root, "sidecar", "contribution.json"); +const included = []; +const dropped = []; + +if (fs.existsSync(contributionPath)) { + const contribution = JSON.parse(fs.readFileSync(contributionPath, "utf-8")); + // Authoring aid only; it must not travel into the shipped manifest, whose + // validator rejects keys it does not know. + delete contribution.$comment; + + const targets = {}; + for (const [platformKey, target] of Object.entries(contribution.targets ?? {})) { + const sources = target.include.map(include => ({ + include, + source: path.join(root, ...include.split("/")), + })); + const absent = sources.filter(file => !fs.existsSync(file.source)); + if (absent.length) { + dropped.push({ platformKey, absent: absent.map(file => file.include) }); + continue; + } + + const sha256 = {}; + for (const { include, source } of sources) { + const bytes = fs.readFileSync(source); + sha256[include] = crypto.createHash("sha256").update(bytes).digest("hex"); const destination = path.join(distDir, ...include.split("/")); fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(source, destination); - console.log(`copied ${include}`); + // The OS loader opens a sidecar by path, so the executable bit has to + // survive into the package. copyFileSync keeps the mode on POSIX; + // setting it explicitly also covers a source that lost it. + if (process.platform !== "win32" && include === target.entry) { + fs.chmodSync(destination, 0o755); + } } + targets[platformKey] = { entry: target.entry, include: [...target.include], sha256 }; + included.push(platformKey); + } + + if (included.length) { + manifest.contributes = { ...manifest.contributes, sidecars: [{ ...contribution, targets }] }; } } -// Studio reads manifest.json from the installed directory, so it ships too. -fs.copyFileSync(path.join(root, "manifest.json"), path.join(distDir, "manifest.json")); -console.log("copied manifest.json"); +// Studio reads manifest.json from the installed directory, so the assembled one +// ships — not the source copy, which carries no sidecar block. +fs.writeFileSync(path.join(distDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); +console.log("wrote dist/manifest.json"); -if (missing.length) { - console.warn(""); - console.warn("WARNING: sidecar binaries are missing from this package:"); - for (const item of missing) { - console.warn(` - ${item}`); +if (typeof manifest.icon === "string" && manifest.icon.trim()) { + const icon = manifest.icon.trim(); + const source = path.join(root, ...icon.split("/")); + if (!fs.existsSync(source)) { + throw new Error(`manifest declares icon "${icon}" but ${source} does not exist`); } - console.warn("The bundle built, but the packaged plugin will FAIL to install:"); - console.warn("Studio verifies every declared sha256 at install time."); - console.warn("See README.md -> Building the sidecar."); + const destination = path.join(distDir, ...icon.split("/")); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.copyFileSync(source, destination); + console.log(`copied ${icon}`); +} + +console.log(""); +if (included.length) { + console.log(`Steam bridge included for: ${included.join(", ")}`); +} else { + console.log("Steam bridge: not included — this is a mirror-only package."); + console.log("Every node still works; nothing is echoed to Steam. Run `yarn build:sidecar` first to include it."); +} +for (const { platformKey, absent } of dropped) { + console.log(` dropped ${platformKey} (missing ${absent.join(", ")})`); } diff --git a/plugins/narraleaf.steam-achievements/icon.png b/plugins/narraleaf.steam-achievements/icon.png new file mode 100644 index 0000000..65350b0 Binary files /dev/null and b/plugins/narraleaf.steam-achievements/icon.png differ diff --git a/plugins/narraleaf.steam-achievements/manifest.json b/plugins/narraleaf.steam-achievements/manifest.json index cd9c333..0480fe3 100644 --- a/plugins/narraleaf.steam-achievements/manifest.json +++ b/plugins/narraleaf.steam-achievements/manifest.json @@ -5,6 +5,7 @@ "version": "0.1.0", "description": "Author Steam achievements and stats in Studio, and unlock them from blueprint graphs. Falls back to a local mirror wherever Steam is not available, so the same script works on itch, on the web export and in Dev Mode.", "publisher": "NarraLeaf Studio", + "icon": "icon.png", "entries": { "studio": "main.js", "runtime": "runtime.js" @@ -26,84 +27,6 @@ ], "runtimeCapabilities": [ "store" - ], - "buildDependencies": [ - { - "id": "narraleaf.steam-achievements.sdk", - "description": "Steamworks SDK redistributable binaries (steam_api64.dll / libsteam_api.dylib / libsteam_api.so)", - "targets": { - "windows-x64": { - "url": "https://partner.steamgames.com/downloads/steamworks_sdk_162.zip", - "sha256": "0000000000000000000000000000000000000000000000000000000000000000", - "archive": "zip", - "files": { - "sdk/redistributable_bin/win64/steam_api64.dll": "bin/windows-x64/steam_api64.dll" - } - }, - "macos-universal": { - "url": "https://partner.steamgames.com/downloads/steamworks_sdk_162.zip", - "sha256": "0000000000000000000000000000000000000000000000000000000000000000", - "archive": "zip", - "files": { - "sdk/redistributable_bin/osx/libsteam_api.dylib": "bin/macos-universal/libsteam_api.dylib" - } - }, - "linux-x64": { - "url": "https://partner.steamgames.com/downloads/steamworks_sdk_162.zip", - "sha256": "0000000000000000000000000000000000000000000000000000000000000000", - "archive": "zip", - "files": { - "sdk/redistributable_bin/linux64/libsteam_api.so": "bin/linux-x64/libsteam_api.so" - } - } - } - } - ], - "sidecars": [ - { - "id": "narraleaf.steam-achievements.bridge", - "kind": "executable", - "transport": "stdio-jsonl", - "autostart": "onRequest", - "startupTimeoutMs": 5000, - "shutdownTimeoutMs": 3000, - "restart": { - "maxRetries": 2, - "backoffMs": 1000 - }, - "targets": { - "windows-x64": { - "entry": "bin/windows-x64/nl-steam-bridge.exe", - "include": [ - "bin/windows-x64/nl-steam-bridge.exe", - "dep:narraleaf.steam-achievements.sdk/bin/windows-x64/steam_api64.dll" - ], - "sha256": { - "bin/windows-x64/nl-steam-bridge.exe": "0000000000000000000000000000000000000000000000000000000000000000" - } - }, - "macos-universal": { - "entry": "bin/macos-universal/nl-steam-bridge", - "include": [ - "bin/macos-universal/nl-steam-bridge", - "dep:narraleaf.steam-achievements.sdk/bin/macos-universal/libsteam_api.dylib" - ], - "sha256": { - "bin/macos-universal/nl-steam-bridge": "0000000000000000000000000000000000000000000000000000000000000000" - } - }, - "linux-x64": { - "entry": "bin/linux-x64/nl-steam-bridge", - "include": [ - "bin/linux-x64/nl-steam-bridge", - "dep:narraleaf.steam-achievements.sdk/bin/linux-x64/libsteam_api.so" - ], - "sha256": { - "bin/linux-x64/nl-steam-bridge": "0000000000000000000000000000000000000000000000000000000000000000" - } - } - } - } ] }, "permissions": [] diff --git a/plugins/narraleaf.steam-achievements/package.json b/plugins/narraleaf.steam-achievements/package.json index e836095..8822668 100644 --- a/plugins/narraleaf.steam-achievements/package.json +++ b/plugins/narraleaf.steam-achievements/package.json @@ -17,13 +17,17 @@ "packageManager": "yarn@4.10.3", "scripts": { "build": "node build.mjs", + "build:sidecar": "node sidecar/build.mjs", "dev": "node build.mjs --dev", + "icon": "node tools/make-icon.mjs icon.png", + "test": "vitest run", "typecheck": "tsc --noEmit" }, "devDependencies": { "esbuild": "^0.25.0", - "narraleaf-studio": "^0.2.0", - "typescript": "^5.7.0" + "narraleaf-studio": "^0.5.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" }, "narraleaf": { "categories": [ diff --git a/plugins/narraleaf.steam-achievements/sidecar/Cargo.lock b/plugins/narraleaf.steam-achievements/sidecar/Cargo.lock new file mode 100644 index 0000000..fb1655d --- /dev/null +++ b/plugins/narraleaf.steam-achievements/sidecar/Cargo.lock @@ -0,0 +1,159 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "nl-steam-bridge" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "steamworks", + "steamworks-sys", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "steamworks" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ff29921f6a7e8c85b0c971ec3c42002b9f535e3b8f4358cb22911d43709739a" +dependencies = [ + "bitflags", + "paste", + "steamworks-sys", + "thiserror", +] + +[[package]] +name = "steamworks-sys" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae139f051204a4af015d4f0ed3a1f7a51020d03a44c7cf249168c543d88131e9" + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/plugins/narraleaf.steam-achievements/sidecar/Cargo.toml b/plugins/narraleaf.steam-achievements/sidecar/Cargo.toml index 9687957..7f36bed 100644 --- a/plugins/narraleaf.steam-achievements/sidecar/Cargo.toml +++ b/plugins/narraleaf.steam-achievements/sidecar/Cargo.toml @@ -14,12 +14,20 @@ path = "src/main.rs" serde = { version = "1", features = ["derive"] } serde_json = "1" -# Pinned deliberately: the safe wrapper's shape changed across releases (0.10 -# handed out a separate `SingleClient` for callback pumping, 0.11 folded it into -# `Client`). This source is written against 0.11. Bumping it is a code change, -# not a version bump — see sidecar/README.md "Verification status". -steamworks = "=0.11.0" -steamworks-sys = "=0.11.0" +# Pinned to a minor, deliberately: the safe wrapper's shape has moved across +# releases (0.10 handed out a separate `SingleClient` for callback pumping, 0.11 +# folded it into `Client`, 0.13 dropped `request_current_stats` because SDK 1.59+ +# requests them during init). Bumping the minor is a code change, not a version +# bump — see sidecar/README.md. +# +# `steamworks-sys` is a direct dependency because progress toasts are not on the +# safe wrapper; see `Bridge::indicate_progress`. It must track `steamworks` +# exactly, since the pointer it hands back crosses between them. +steamworks = "=0.13.1" +# There is no 0.13.1 of the -sys crate; 0.13.1 of the wrapper builds on 0.13.0 +# of it. Pinning both exactly keeps the pointer that crosses between them the +# same type, and keeps a rebuild months from now byte-reproducible. +steamworks-sys = "=0.13.0" [profile.release] opt-level = "z" diff --git a/plugins/narraleaf.steam-achievements/sidecar/README.md b/plugins/narraleaf.steam-achievements/sidecar/README.md index e8f2e61..d3eeb13 100644 --- a/plugins/narraleaf.steam-achievements/sidecar/README.md +++ b/plugins/narraleaf.steam-achievements/sidecar/README.md @@ -6,34 +6,84 @@ main process spawns and talks to over newline-delimited JSON on stdio. It exists because Steamworks is a native C API and a plugin's runtime entry runs in the game's **renderer** process, which cannot load a dynamic library. -## Verification status - -**This code has never been compiled and has never been run.** - -It was written on a machine with no Rust toolchain and no Steamworks SDK (the SDK -requires a Valve partner account to download), so nothing here has been checked by -a compiler, let alone against a running Steam client. Treat it as a specification -in Rust syntax, not as a working binary. - -One thing still needs verifying before any of this ships. The wire protocol was -the other, and is now settled — it has been reconciled frame by frame against the -host, see [The wire protocol](#the-wire-protocol). - -**The crate API (`src/steam.rs`).** Pinned to `steamworks = "=0.11.0"`. These -calls are the ones most likely to be wrong, in rough order of risk: - -- `SteamAPI_SteamUserStats_v013()` and - `SteamAPI_ISteamUserStats_IndicateAchievementProgress` from `steamworks-sys` — - the interface accessor carries a version suffix that changes with the SDK. If - the safe wrapper gained an equivalent on `AchievementHelper`, use that instead - and delete the `unsafe` block. -- `UserStats::set_stat_i32` / `set_stat_f32` — spelling has moved between - releases (a `stat_i32(name).set(value)` helper style also exists in some). -- `Client::init()` returning a single `Client` — true from 0.11; 0.10 and earlier - returned `(Client, SingleClient)` and pumped callbacks on the latter. -- `UserStats::request_current_stats` / `store_stats` / `reset_all_stats` - return types (`()` vs `Result<_, _>`). +## Status + +Built and run against a live Steam client on `windows-x64`, with +`steamworks 0.13.1` / `steamworks-sys 0.13.0`. Verified end to end against +Spacewar (App ID 480), Valve's test app: + +- `steam.init` publishes the App ID and reports `available: true` with the real + App ID and game language back. +- An unknown API name is refused by Steam (`SetAchievement(...) failed`) rather + than silently succeeding — which is how you know the call is reaching Steam + and not a stub. +- `ACH_WIN_ONE_GAME`, `NumGames`, `IndicateAchievementProgress` and + `ResetAllStats` all succeed against Spacewar's real schema, and `StoreStats` + commits them. +- A `req` with no `id` produces no `res` frame, and `bye` exits 0. + +macOS and Linux share every line of this source and are wired into the release +workflow, but have not been run against a Steam client. Smoke-test their first +release. + +## Building + +No Steamworks SDK download, and no Valve partner account. `steamworks-sys` +vendors the SDK under its own `lib/steam/` and its build script falls back to +that copy whenever `STEAM_SDK_LOCATION` is unset — which is also why the crate +builds on docs.rs. Set `STEAM_SDK_LOCATION` only if you deliberately want a +different SDK version. + +From the plugin root, for the platform you are on: + +```sh +yarn build:sidecar +``` + +That runs `cargo build --release` for the host target, drops the executable into +`../bin//`, and copies the Steam shared library out of cargo's +`OUT_DIR` next to it — the same bytes the executable was linked against, so the +two can never disagree. On macOS it builds both arches and `lipo`s them into one +universal image. Then `yarn build` in the plugin root hashes whatever is in +`bin/` and writes the digests into the shipped manifest. + +### The shared library must sit next to the executable + +- **Windows** searches the executable's own directory first, so nothing extra is + needed. +- **Linux and macOS** search an rpath, so `sidecar/build.mjs` passes + `-Wl,-rpath,$ORIGIN` / `@executable_path`. Without it the binary loads on the + build machine (where the SDK is on the library path) and fails on every + player's. + +### Cross-building + +Do not. A Windows host cannot set the executable bit on a macOS or Linux artifact +(NTFS has none), so the packaged sidecar arrives unrunnable; Studio's preflight +refuses those combinations for exactly this reason. Build each target on its own +platform, or in CI. + +## The crate API + +Pinned to `steamworks = "=0.13.1"` with `steamworks-sys = "=0.13.0"` (there is no +0.13.1 of the -sys crate). The wrapper's shape has moved across releases, so a +minor bump is a code change rather than a version bump. What this source depends +on: + +- `Client::init() -> SIResult` — one `Client`, which pumps its own + callbacks. Up to 0.10 this handed back a separate `SingleClient`. +- `UserStats::{set_stat_i32, set_stat_f32, store_stats, reset_all_stats}` and + `achievement(name).set()`. +- **No `request_current_stats`.** Valve deprecated `RequestCurrentStats` in SDK + 1.59, which fetches the current user's stats during init, and the crate dropped + the binding. Calling it is not merely unnecessary now — it does not compile. - `Apps::current_game_language` and `Utils::app_id`. +- `steamworks_sys::SteamAPI_SteamUserStats_v013()` plus + `SteamAPI_ISteamUserStats_IndicateAchievementProgress`, through raw FFI: + progress toasts are not on `AchievementHelper` at this version. The `_v013` + suffix is the interface version and moves with the SDK, so it is the first + thing to check on a bump. If a later release grows an equivalent on + `AchievementHelper`, prefer it and delete the `unsafe` block. ## The wire protocol @@ -53,8 +103,7 @@ host -> {"t":"bye"} Four rules that are easy to get wrong: - **There is no `notify` frame type.** A notify is a `req` with no `id`, and it - gets no reply. (An earlier draft of this file proposed `t:"notify"`; the host - never sends it.) + gets no reply. - **Events are `evt`, not `event`.** This binary emits none today, but the host forwards them to the plugin's `handle.onEvent`. - **stdout is the protocol, stderr is the log.** The host classifies each stderr @@ -90,91 +139,20 @@ player's `userData` — an author could not find it, and the plugin's runtime AP has no filesystem to write into it. The sidecar is the half of this plugin that has both. -## Building - -Per platform-arch, on that platform (see "Cross-building" below): - -```sh -# Windows x64 -cargo build --release --target x86_64-pc-windows-msvc -# -> target/x86_64-pc-windows-msvc/release/nl-steam-bridge.exe -# into ../bin/windows-x64/ - -# Linux x64 -cargo build --release --target x86_64-unknown-linux-gnu -# -> ../bin/linux-x64/nl-steam-bridge - -# macOS universal — build both arches and lipo them together -cargo build --release --target aarch64-apple-darwin -cargo build --release --target x86_64-apple-darwin -lipo -create -output ../bin/macos-universal/nl-steam-bridge \ - target/aarch64-apple-darwin/release/nl-steam-bridge \ - target/x86_64-apple-darwin/release/nl-steam-bridge -``` - -The `steamworks-sys` build script needs the Steamworks SDK. Download it from the -partner site (a Valve account with a signed agreement is required — it is not -publicly fetchable) and point `STEAM_SDK_LOCATION` at the unpacked `sdk` -directory: - -```sh -export STEAM_SDK_LOCATION=/path/to/steamworks_sdk_162/sdk -``` - -### The shared library must sit next to the executable - -`steam_api64.dll` / `libsteam_api.dylib` / `libsteam_api.so` are **not** vendored -here — the plugin manifest declares them as a build dependency so Studio fetches -them at project build time and lands them in the same directory as the binary. - -- **Windows** searches the executable's own directory first, so nothing extra is - needed. -- **Linux and macOS** search an rpath. Link with `$ORIGIN` / `@executable_path`: - - ```sh - # linux - RUSTFLAGS="-C link-arg=-Wl,-rpath,\$ORIGIN" cargo build --release --target x86_64-unknown-linux-gnu - # macos - RUSTFLAGS="-C link-arg=-Wl,-rpath,@executable_path" cargo build --release --target aarch64-apple-darwin - ``` - - Without this the binary loads on the build machine (where the SDK is on the - library path) and fails on every player's. - -### After building - -1. Copy the binaries to `../bin//`. -2. Recompute digests and paste them into `../manifest.json` — every - `contributes.sidecars[].targets[].sha256` currently holds a **placeholder of - 64 zeros**, and Studio rejects the package at install until they are real: - - ```sh - shasum -a 256 ../bin/windows-x64/nl-steam-bridge.exe - ``` - -3. Do the same for the SDK zip's digest in `contributes.buildDependencies`. -4. `yarn build` in the plugin root verifies every digest and copies the payload - into `dist/`. - -### Cross-building - -Do not. A Windows host cannot set the executable bit on a macOS or Linux artifact -(NTFS has none), so the packaged sidecar arrives unrunnable; Studio's preflight -refuses those combinations for exactly this reason. Build each target on its own -platform, or in CI. - ## Running it by hand Type the two host frames on stdin; replies come back on stdout. `480` is Spacewar, Valve's test app — nothing has to be placed in the directory first, -`steam.init` is what publishes the App ID. +`steam.init` is what publishes the App ID. Copy the shared library in beside the +executable, or init will fail to load it. ```sh cd /some/writable/dir ./nl-steam-bridge {"t":"hello","protocol":1,"cwd":".","mode":"preview","game":{"name":"test","version":null}} {"t":"req","id":1,"method":"steam.init","params":{"appId":"480"}} -{"t":"req","id":2,"method":"achievements.unlock","params":{"id":"WIN"}} +{"t":"req","id":2,"method":"achievements.unlock","params":{"id":"ACH_WIN_ONE_GAME"}} +{"t":"req","id":3,"method":"stats.store"} {"t":"bye"} ``` diff --git a/plugins/narraleaf.steam-achievements/sidecar/build.mjs b/plugins/narraleaf.steam-achievements/sidecar/build.mjs new file mode 100644 index 0000000..6b5feb8 --- /dev/null +++ b/plugins/narraleaf.steam-achievements/sidecar/build.mjs @@ -0,0 +1,154 @@ +/** + * Compile nl-steam-bridge for the host platform and drop it, with the Steam + * shared library it needs, into `bin//` for the plugin build to + * pick up. + * + * Building needs no Valve partner account: `steamworks-sys` vendors the SDK + * under its own `lib/steam/` and its build script falls back to that whenever + * `STEAM_SDK_LOCATION` is unset. It also copies the right shared library for the + * target into OUT_DIR, which is where this script takes it from — so the bytes + * that ship are the same ones the binary was linked against. + * + * Host platform only, deliberately. A Windows host cannot set the executable bit + * on a macOS or Linux artifact (NTFS has none) and would package something no + * player can run; Studio's build preflight refuses those combinations for the + * same reason. Each platform is built on its own runner — see + * .github/workflows/release.yml. + * + * Usage: node sidecar/build.mjs [--debug] + */ + +import { execFileSync } from "node:child_process"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const sidecarDir = path.dirname(fileURLToPath(import.meta.url)); +const pluginDir = path.dirname(sidecarDir); +const debug = process.argv.includes("--debug"); +const profile = debug ? "debug" : "release"; + +/** + * What each host produces. `libs` are the shared-library names the Steam SDK + * uses there; the first one found in OUT_DIR is the one that ships. + */ +const HOSTS = { + win32: { + platformKey: "windows-x64", + targets: ["x86_64-pc-windows-msvc"], + binary: "nl-steam-bridge.exe", + libs: ["steam_api64.dll"], + // Windows searches the executable's own directory first, so the DLL + // beside it is found with no link-time help. + rustflags: null, + }, + linux: { + platformKey: "linux-x64", + targets: ["x86_64-unknown-linux-gnu"], + binary: "nl-steam-bridge", + libs: ["libsteam_api.so"], + // Without an $ORIGIN rpath the binary loads on the build machine (where + // the SDK sits on the library path) and fails on every player's. + rustflags: "-C link-arg=-Wl,-rpath,$ORIGIN", + }, + darwin: { + platformKey: "macos-universal", + targets: ["aarch64-apple-darwin", "x86_64-apple-darwin"], + binary: "nl-steam-bridge", + libs: ["libsteam_api.dylib"], + rustflags: "-C link-arg=-Wl,-rpath,@executable_path", + }, +}; + +const host = HOSTS[process.platform]; +if (!host) { + console.error(`No sidecar build is defined for ${process.platform}.`); + process.exit(1); +} + +function cargo(args, extraEnv = {}) { + console.log(`> cargo ${args.join(" ")}`); + // No `shell: true`: cargo is a real executable on every platform (not a .cmd + // shim), so Node resolves it directly — and a shell would concatenate these + // arguments instead of passing them. + execFileSync("cargo", args, { + cwd: sidecarDir, + stdio: "inherit", + env: { ...process.env, ...extraEnv }, + }); +} + +const env = host.rustflags ? { RUSTFLAGS: host.rustflags } : {}; +for (const target of host.targets) { + cargo(["build", ...(debug ? [] : ["--release"]), "--target", target], env); +} + +/** Where cargo put the binary for one target triple. */ +function builtBinary(target) { + return path.join(sidecarDir, "target", target, profile, host.binary); +} + +const outDir = path.join(pluginDir, "bin", host.platformKey); +fs.rmSync(outDir, { recursive: true, force: true }); +fs.mkdirSync(outDir, { recursive: true }); +const outBinary = path.join(outDir, host.binary); + +if (host.targets.length > 1) { + // macOS: two slices fused into one image, so a single package serves both + // Apple silicon and Intel. + console.log("> lipo -create"); + execFileSync("lipo", ["-create", "-output", outBinary, ...host.targets.map(builtBinary)], { stdio: "inherit" }); +} else { + fs.copyFileSync(builtBinary(host.targets[0]), outBinary); +} +// Cargo's output is already executable; copying preserves that on POSIX, but +// lipo's output and any future path may not, so say so explicitly. +if (process.platform !== "win32") { + fs.chmodSync(outBinary, 0o755); +} + +/** + * Find the shared library steamworks-sys copied next to its build output. Its + * directory name carries a hash, so it is searched for rather than named. + */ +function findVendoredLib() { + const roots = host.targets.map(target => path.join(sidecarDir, "target", target, profile, "build")); + for (const root of roots) { + if (!fs.existsSync(root)) { + continue; + } + for (const entry of fs.readdirSync(root)) { + if (!entry.startsWith("steamworks-sys-")) { + continue; + } + for (const lib of host.libs) { + const candidate = path.join(root, entry, "out", lib); + if (fs.existsSync(candidate)) { + return candidate; + } + } + } + } + return null; +} + +const vendored = findVendoredLib(); +if (!vendored) { + console.error( + `Built the binary but could not find ${host.libs.join(" or ")} in cargo's build output.\n` + + "steamworks-sys copies it into OUT_DIR; without it the sidecar cannot load Steam on a player's machine.", + ); + process.exit(1); +} +fs.copyFileSync(vendored, path.join(outDir, path.basename(vendored))); + +console.log(""); +console.log(`sidecar -> bin/${host.platformKey}/`); +for (const file of fs.readdirSync(outDir)) { + const bytes = fs.readFileSync(path.join(outDir, file)); + const digest = crypto.createHash("sha256").update(bytes).digest("hex"); + console.log(` ${file} ${bytes.length} bytes ${digest}`); +} +console.log(""); +console.log("Now run `yarn build` — it copies these into dist/ and writes their digests into the manifest."); diff --git a/plugins/narraleaf.steam-achievements/sidecar/contribution.json b/plugins/narraleaf.steam-achievements/sidecar/contribution.json new file mode 100644 index 0000000..f18d246 --- /dev/null +++ b/plugins/narraleaf.steam-achievements/sidecar/contribution.json @@ -0,0 +1,52 @@ +{ + "$comment": [ + "The sidecar declaration, kept out of manifest.json on purpose.", + "", + "A sidecar target is only true of a package that actually carries the", + "binary, and this repository carries none — they are build output (see", + ".gitignore). Declaring them in the committed manifest would mean either", + "placeholder digests, which are a lie the validator cannot catch, or a", + "manifest that describes files nobody has.", + "", + "So build.mjs owns it instead: for every platform whose files are all", + "present under bin/, it copies them into dist/, computes their sha256, and", + "injects the target into dist/manifest.json. Platforms with no binary are", + "dropped; if none survive, contributes.sidecars is omitted entirely and the", + "package is a perfectly good mirror-only plugin.", + "", + "`sha256` is deliberately absent here. It is computed, never authored." + ], + "id": "narraleaf.steam-achievements.bridge", + "kind": "executable", + "transport": "stdio-jsonl", + "autostart": "onRequest", + "startupTimeoutMs": 5000, + "shutdownTimeoutMs": 3000, + "restart": { + "maxRetries": 2, + "backoffMs": 1000 + }, + "targets": { + "windows-x64": { + "entry": "bin/windows-x64/nl-steam-bridge.exe", + "include": [ + "bin/windows-x64/nl-steam-bridge.exe", + "bin/windows-x64/steam_api64.dll" + ] + }, + "macos-universal": { + "entry": "bin/macos-universal/nl-steam-bridge", + "include": [ + "bin/macos-universal/nl-steam-bridge", + "bin/macos-universal/libsteam_api.dylib" + ] + }, + "linux-x64": { + "entry": "bin/linux-x64/nl-steam-bridge", + "include": [ + "bin/linux-x64/nl-steam-bridge", + "bin/linux-x64/libsteam_api.so" + ] + } + } +} diff --git a/plugins/narraleaf.steam-achievements/sidecar/src/steam.rs b/plugins/narraleaf.steam-achievements/sidecar/src/steam.rs index 3f8e8d0..f18dcf2 100644 --- a/plugins/narraleaf.steam-achievements/sidecar/src/steam.rs +++ b/plugins/narraleaf.steam-achievements/sidecar/src/steam.rs @@ -4,11 +4,9 @@ //! this API around — is a diff in one file rather than a hunt through the //! protocol loop. //! -//! !!! THIS FILE HAS NEVER BEEN COMPILED. !!! -//! It was written without a Rust toolchain and without the Steamworks SDK (which -//! needs a Valve partner account to download). The protocol loop in main.rs is -//! ordinary Rust; the calls below are the part to check first against the pinned -//! crate version. See sidecar/README.md "Verification status" for the list. +//! Building this needs no Valve partner account: `steamworks-sys` vendors the +//! SDK under `lib/steam/` and its build script falls back to that copy whenever +//! `STEAM_SDK_LOCATION` is unset. use std::ffi::CString; use std::path::Path; @@ -58,11 +56,11 @@ impl Bridge { return None; } }; - // Asks Steam for this user's current achievement and stat values. The - // reply arrives on a callback, which the main loop is already pumping; - // reads before it lands return defaults, which is the same answer the - // local mirror would give. - client.user_stats().request_current_stats(); + // No `RequestCurrentStats` call: Valve deprecated it in SDK 1.59, which + // fetches the current user's stats during init instead, and the crate + // dropped the binding to match. Reads that beat the fetch home would + // return defaults anyway — the same answer the local mirror gives, which + // is why no node reads Steam in the first place. Some(Bridge { client, dirty: false }) } diff --git a/plugins/narraleaf.steam-achievements/src/catalog.test.ts b/plugins/narraleaf.steam-achievements/src/catalog.test.ts new file mode 100644 index 0000000..3509fb4 --- /dev/null +++ b/plugins/narraleaf.steam-achievements/src/catalog.test.ts @@ -0,0 +1,259 @@ +/** + * The catalog's pure half. Every function here runs in both the editor and a + * shipped game, against JSON that has sat on disk across plugin versions and + * schema changes — so the cases worth writing down are the malformed ones. + */ + +import { describe, expect, it } from "vitest"; +import { + CATALOG_VERSION, + clampStatValue, + emptyCatalog, + findAchievement, + findStat, + issuesBySubject, + localizedText, + normalizeCatalog, + validateCatalog, + type AchievementCatalog, + type SteamStat, +} from "./catalog"; + +function catalog(patch: Partial = {}): AchievementCatalog { + return { ...emptyCatalog(), ...patch }; +} + +describe("normalizeCatalog", () => { + it("turns anything unusable into an empty catalog rather than throwing", () => { + for (const input of [null, undefined, 42, "catalog", [], true]) { + expect(normalizeCatalog(input)).toEqual(emptyCatalog()); + } + }); + + it("drops entries with no id instead of keeping unaddressable ones", () => { + const result = normalizeCatalog({ + achievements: [{ id: "KEPT" }, { id: " " }, { name: {} }, null, 7], + stats: [{ id: "KEPT_STAT" }, {}, "nope"], + }); + expect(result.achievements.map(item => item.id)).toEqual(["KEPT"]); + expect(result.stats.map(item => item.id)).toEqual(["KEPT_STAT"]); + }); + + it("always leaves at least one locale, and never a duplicate", () => { + expect(normalizeCatalog({ locales: [] }).locales).toEqual(["en"]); + expect(normalizeCatalog({ locales: ["zh-CN", "zh-CN", " en ", ""] }).locales).toEqual(["zh-CN", "en"]); + }); + + it("keeps localized text for locales the catalog does not declare", () => { + // Dropping them would silently destroy translations the moment an author + // removed a language from the switcher. + const result = normalizeCatalog({ + locales: ["en"], + achievements: [{ id: "A", name: { en: "One", ja: "いち" } }], + }); + expect(result.achievements[0].name).toEqual({ en: "One", ja: "いち" }); + }); + + it("reads a stat authored while avgrate existed as float, not int", () => { + // An average-rate value is fractional; truncating it would lose data the + // mirror already holds. + expect(normalizeCatalog({ stats: [{ id: "S", type: "avgrate" }] }).stats[0].type).toBe("float"); + expect(normalizeCatalog({ stats: [{ id: "S", type: "nonsense" }] }).stats[0].type).toBe("int"); + expect(normalizeCatalog({ stats: [{ id: "S", type: "float" }] }).stats[0].type).toBe("float"); + }); + + it("rejects non-finite numbers rather than storing NaN", () => { + const [stat] = normalizeCatalog({ + stats: [{ id: "S", defaultValue: Number.NaN, min: Number.POSITIVE_INFINITY, max: 10 }], + }).stats; + expect(stat.defaultValue).toBe(0); + expect(stat.min).toBeUndefined(); + expect(stat.max).toBe(10); + }); + + it("keeps a progress binding only when it names a stat", () => { + const result = normalizeCatalog({ + achievements: [ + { id: "A", progress: { statId: "S", max: 10 } }, + { id: "B", progress: { max: 10 } }, + { id: "C", progress: "yes" }, + ], + }); + expect(result.achievements.map(item => item.progress)).toEqual([{ statId: "S", max: 10 }, undefined, undefined]); + }); + + it("stamps the current version even on data that claimed another", () => { + expect(normalizeCatalog({ version: 99 }).version).toBe(CATALOG_VERSION); + }); + + it("keeps an appId only when it is a non-empty string", () => { + expect(normalizeCatalog({ appId: " 480 " }).appId).toBe("480"); + expect(normalizeCatalog({ appId: " " }).appId).toBeUndefined(); + expect(normalizeCatalog({ appId: 480 }).appId).toBeUndefined(); + }); +}); + +describe("validateCatalog", () => { + const errors = (input: AchievementCatalog) => + validateCatalog(input).filter(issue => issue.severity === "error").map(issue => issue.message); + + it("rejects API names Steam would not accept", () => { + const messages = errors(catalog({ + achievements: [{ id: "has space", name: {}, description: {}, hidden: false }], + stats: [{ id: "né", type: "int", defaultValue: 0 }], + })); + expect(messages).toEqual([ + expect.stringContaining("Stat API Name"), + expect.stringContaining("API Name"), + ]); + }); + + it("rejects an API name longer than Steam's 44 characters", () => { + expect(errors(catalog({ + achievements: [{ id: "A".repeat(45), name: {}, description: {}, hidden: false }], + }))).toHaveLength(1); + expect(errors(catalog({ + achievements: [{ id: "A".repeat(44), name: {}, description: {}, hidden: false }], + }))).toHaveLength(0); + }); + + it("catches duplicates on both sides", () => { + const messages = errors(catalog({ + achievements: [ + { id: "SAME", name: {}, description: {}, hidden: false }, + { id: "SAME", name: {}, description: {}, hidden: false }, + ], + stats: [ + { id: "S", type: "int", defaultValue: 0 }, + { id: "S", type: "int", defaultValue: 0 }, + ], + })); + expect(messages).toEqual([ + expect.stringContaining("Duplicate stat"), + expect.stringContaining("Duplicate API Name"), + ]); + }); + + it("catches progress pointing at a stat that is not there, and a zero max", () => { + expect(errors(catalog({ + achievements: [{ id: "A", name: {}, description: {}, hidden: false, progress: { statId: "GONE", max: 10 } }], + }))).toEqual([expect.stringContaining("unknown stat")]); + + expect(errors(catalog({ + stats: [{ id: "S", type: "int", defaultValue: 0 }], + achievements: [{ id: "A", name: {}, description: {}, hidden: false, progress: { statId: "S", max: 0 } }], + }))).toEqual([expect.stringContaining("above zero")]); + }); + + it("catches a stat whose min is above its max", () => { + expect(errors(catalog({ + stats: [{ id: "S", type: "int", defaultValue: 0, min: 10, max: 1 }], + }))).toEqual([expect.stringContaining("min above max")]); + }); + + it("warns per missing language, and only for declared ones", () => { + const issues = validateCatalog(catalog({ + locales: ["en", "zh-CN"], + appId: "480", + achievements: [{ id: "A", name: { en: "One" }, description: {}, hidden: false }], + })); + expect(issues.map(issue => issue.message)).toEqual([ + "Missing description for en", + "Missing name for zh-CN", + "Missing description for zh-CN", + ]); + expect(issues.every(issue => issue.severity === "warning")).toBe(true); + }); + + it("warns about a missing App ID only once there is something to unlock", () => { + expect(validateCatalog(catalog())).toEqual([]); + expect(validateCatalog(catalog({ + achievements: [{ id: "A", name: { en: "x" }, description: { en: "y" }, hidden: false }], + }))).toEqual([{ severity: "warning", message: "No Steam App ID set" }]); + }); +}); + +describe("issuesBySubject", () => { + it("groups by subject and drops catalog-wide issues", () => { + const grouped = issuesBySubject([ + { severity: "error", subjectId: "A", message: "one" }, + { severity: "warning", subjectId: "A", message: "two" }, + { severity: "warning", message: "catalog-wide" }, + ]); + expect([...grouped.keys()]).toEqual(["A"]); + expect(grouped.get("A")).toHaveLength(2); + }); +}); + +describe("clampStatValue", () => { + const stat = (patch: Partial = {}): SteamStat => + ({ id: "S", type: "int", defaultValue: 0, ...patch }); + + it("truncates for int and keeps the fraction for float", () => { + expect(clampStatValue(stat(), 0, 3.9)).toBe(3); + expect(clampStatValue(stat({ type: "float" }), 0, 3.9)).toBeCloseTo(3.9); + }); + + it("truncates toward zero, so a negative int does not gain a point", () => { + expect(clampStatValue(stat(), 0, -3.9)).toBe(-3); + }); + + it("holds an increment-only stat at its previous value", () => { + expect(clampStatValue(stat({ incrementOnly: true }), 10, 4)).toBe(10); + expect(clampStatValue(stat({ incrementOnly: true }), 10, 12)).toBe(12); + }); + + it("applies min and max", () => { + expect(clampStatValue(stat({ min: 5 }), 0, 1)).toBe(5); + expect(clampStatValue(stat({ max: 5 }), 0, 9)).toBe(5); + }); + + it("lets bounds win over increment-only, so a lowered max is honoured", () => { + expect(clampStatValue(stat({ incrementOnly: true, max: 5 }), 9, 3)).toBe(5); + }); + + it("passes the value through untouched when the stat is unknown", () => { + // An id no longer in the catalog still has a mirror value; clamping it to + // some default would rewrite data the author never asked to change. + expect(clampStatValue(null, 0, 3.9)).toBeCloseTo(3.9); + }); +}); + +describe("localizedText", () => { + const locales = ["en", "zh-CN"]; + + it("prefers the asked-for locale", () => { + expect(localizedText({ en: "One", "zh-CN": "一" }, "zh-CN", locales)).toBe("一"); + }); + + it("falls back to the first locale that has any text", () => { + expect(localizedText({ "zh-CN": "一" }, "en", locales)).toBe("一"); + }); + + it("treats whitespace as absent, in both the exact hit and the fallback", () => { + expect(localizedText({ en: " ", "zh-CN": "一" }, "en", locales)).toBe("一"); + expect(localizedText({ en: " " }, "en", locales)).toBe(""); + }); + + it("returns empty rather than undefined when nothing is authored", () => { + expect(localizedText({}, "en", locales)).toBe(""); + }); +}); + +describe("findAchievement / findStat", () => { + const source = catalog({ + achievements: [{ id: "A", name: {}, description: {}, hidden: false }], + stats: [{ id: "S", type: "int", defaultValue: 0 }], + }); + + it("finds by id, tolerating the whitespace a wired pin can carry", () => { + expect(findAchievement(source, " A ")?.id).toBe("A"); + expect(findStat(source, " S ")?.id).toBe("S"); + }); + + it("returns null for an empty or unknown id", () => { + expect(findAchievement(source, " ")).toBeNull(); + expect(findAchievement(source, "MISSING")).toBeNull(); + expect(findStat(source, "MISSING")).toBeNull(); + }); +}); diff --git a/plugins/narraleaf.steam-achievements/src/main.tsx b/plugins/narraleaf.steam-achievements/src/main.tsx index 877336f..fe95a99 100644 --- a/plugins/narraleaf.steam-achievements/src/main.tsx +++ b/plugins/narraleaf.steam-achievements/src/main.tsx @@ -24,6 +24,7 @@ import { ui, type Asset, type BlueprintInspectorParamSelectOption, + type FreezeGuard, type PluginApp, } from "narraleaf-studio/plugin"; import { @@ -70,6 +71,13 @@ function createCatalogStore(app: PluginApp) { }; const commit = async (next: AchievementCatalog) => { + // Bail *before* touching memory, not after. A frozen project discards + // the write at the boundary, so mutating first would leave the tab + // showing a catalog the disk does not have — and the next thaw would + // write that phantom over whatever version the author restored. + if (app.services.workspace.frozen) { + return; + } catalog = normalizeCatalog(next); notify(); await app.services.storage.writeJson(CATALOG_NAMESPACE, { @@ -79,6 +87,11 @@ function createCatalogStore(app: PluginApp) { }; return { + /** + * Read the catalog off disk. Also the reloader: version control replaces + * the working tree under us, and a store that kept its pre-restore copy + * in RAM would write it back on the author's next edit. + */ async load() { catalog = normalizeCatalog(await app.services.storage.readJson(CATALOG_NAMESPACE)); notify(); @@ -156,6 +169,10 @@ function AchievementsTab({ app, store }: { app: PluginApp; store: CatalogStore } const [newLocale, setNewLocale] = useState(""); const [iconTarget, setIconTarget] = useState<{ achievementId: string; slot: IconSlot } | null>(null); const anchorRef = useRef(null); + // Only the writes are switched off. Searching, switching the display + // language and scrolling the table are the whole point of looking at a + // frozen version, so they stay live. + const freeze = ui.useFreezeGuard(); useEffect(() => store.subscribe(() => setCatalog({ ...store.get() })), [store]); useEffect(() => { @@ -212,12 +229,15 @@ function AchievementsTab({ app, store }: { app: PluginApp; store: CatalogStore } return (
- run(store.patch({ appId: event.target.value.trim() }))} + allowEmpty + {...freeze.writes()} + onCommit={appId => run(store.patch({ appId }))} /> setNewLocale(event.target.value)} - onKeyDown={event => { + onKeyDown={freeze.run(event => { if (event.key === "Enter") { addLocale(); } - }} + })} /> run(store.patch({ locales: catalog.locales.filter(code => code !== locale), }))} @@ -266,7 +288,12 @@ function AchievementsTab({ app, store }: { app: PluginApp; store: CatalogStore } {warningCount > 0 && {warningCount} warnings} )} - run(store.addAchievement())}> + run(store.addAchievement())} + > Achievement @@ -289,6 +316,7 @@ function AchievementsTab({ app, store }: { app: PluginApp; store: CatalogStore } achievement={achievement} locale={locale} statOptions={statOptions} + freeze={freeze} issues={bySubject.get(achievement.id) ?? []} onRename={id => run(store.patchAchievement(achievement.id, { id }))} onText={(field, text) => setLocalizedText(achievement, field, text)} @@ -302,7 +330,12 @@ function AchievementsTab({ app, store }: { app: PluginApp; store: CatalogStore }
Stats - run(store.addStat())}> + run(store.addStat())} + > Stat @@ -317,6 +350,7 @@ function AchievementsTab({ app, store }: { app: PluginApp; store: CatalogStore } run(store.patchStat(stat.id, patch))} onRemove={() => run(store.removeStat(stat.id))} @@ -361,6 +395,7 @@ function AchievementRow({ achievement, locale, statOptions, + freeze, issues, onRename, onText, @@ -374,6 +409,7 @@ function AchievementRow({ achievement: Achievement; locale: LocaleCode; statOptions: { value: string; label: string }[]; + freeze: FreezeGuard; issues: CatalogIssue[]; onRename: (id: string) => void; onText: (field: "name" | "description", text: string) => void; @@ -394,6 +430,7 @@ function AchievementRow({ app={app} assetId={achievement.iconAchievedAssetId ?? null} title="Unlocked icon" + freeze={freeze} onPick={() => onPickIcon("iconAchievedAssetId")} onClear={() => onClearIcon("iconAchievedAssetId")} /> @@ -401,6 +438,7 @@ function AchievementRow({ app={app} assetId={achievement.iconUnachievedAssetId ?? null} title="Locked icon" + freeze={freeze} onPick={() => onPickIcon("iconUnachievedAssetId")} onClear={() => onClearIcon("iconUnachievedAssetId")} /> @@ -408,21 +446,25 @@ function AchievementRow({ onText("name", text)} allowEmpty /> onText("description", text)} allowEmpty />
@@ -431,6 +473,7 @@ function AchievementRow({ value={achievement.progress?.statId ?? ""} options={statOptions} portalMenu + {...freeze.writes()} onChange={value => { const statId = String(value); onProgress(statId ? { statId, max: achievement.progress?.max ?? 0 } : undefined); @@ -441,6 +484,7 @@ function AchievementRow({ value={String(achievement.progress.max)} className="w-16" allowEmpty + {...freeze.writes()} onCommit={text => onProgress({ statId: achievement.progress?.statId ?? "", max: Number.parseFloat(text) || 0, @@ -448,7 +492,13 @@ function AchievementRow({ /> )}
- +
@@ -464,11 +514,13 @@ function AchievementRow({ function StatRow({ stat, + freeze, issues, onPatch, onRemove, }: { stat: SteamStat; + freeze: FreezeGuard; issues: CatalogIssue[]; onPatch: (patch: Partial) => void; onRemove: () => void; @@ -481,6 +533,7 @@ function StatRow({ { const parsed = Number.parseFloat(text); commit(text.trim() && Number.isFinite(parsed) ? parsed : undefined); @@ -494,6 +547,7 @@ function StatRow({ onPatch({ id })} /> onPatch({ type: String(value) as SteamStatType })} /> {numberField(stat.defaultValue, next => onPatch({ defaultValue: next ?? 0 }))} @@ -512,9 +567,16 @@ function StatRow({ onPatch({ incrementOnly })} /> - +
@@ -527,12 +589,14 @@ function IconCell({ app, assetId, title, + freeze, onPick, onClear, }: { app: PluginApp; assetId: string | null; title: string; + freeze: FreezeGuard; onPick: () => void; onClear: () => void; }) { @@ -571,14 +635,16 @@ function IconCell({ return (