From cc27e694b32567a068474198f9c696ea99f00fb5 Mon Sep 17 00:00:00 2001 From: helloyork Date: Fri, 31 Jul 2026 13:07:46 -0700 Subject: [PATCH 1/3] feat(registry): plugins can declare a square thumbnail Studio now shows a plugin's own icon beside its name in the Launcher list, so `manifest.json` gains `icon`: a package-relative image the store and the installed list both draw. index.json carries it as an absolute raw URL pinned to the release tag, for the same reason the download URL is - an index entry describes one immutable version, and the picture it shows should not change under it when the next version lands. The validator enforces every rule Studio enforces, one step earlier: square, 64x64 to 512x512, at most 512 KB, .png/.webp/.jpg/.jpeg only, and the bytes must really be the format the name claims. scripts/lib/image.mjs is a port of Studio's reader and carries the same standing obligation as the manifest validator next to it - if Studio's rules move, this moves with them. The icon has to reach dist/ the way manifest.json does, so the template's build script copies it and package-plugin.mjs refuses a zip that is missing it. The starter ships a placeholder piece, which is also what keeps the whole path exercised by `node scripts/validate.mjs` on every run. Also fills in index.schema.json's `contributes.locales`, which the generator has been emitting since language packs landed without the schema knowing. Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 11 ++ schema/index.schema.json | 14 ++- schema/manifest.schema.json | 5 + scripts/lib/image.mjs | 194 ++++++++++++++++++++++++++++++++++++ scripts/lib/plugins.mjs | 52 ++++++++++ scripts/package-plugin.mjs | 9 ++ template/build.mjs | 10 ++ template/icon.png | Bin 0 -> 6148 bytes template/manifest.json | 1 + 9 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/image.mjs create mode 100644 template/icon.png diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc36af7..76f6aa5 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. Plugins without one keep the + monogram tile Studio draws from the name — that is a fine place to stay, but + do replace the template's placeholder piece before publishing. - **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 diff --git a/schema/index.schema.json b/schema/index.schema.json index b3064aa..38dc432 100644 --- a/schema/index.schema.json +++ b/schema/index.schema.json @@ -83,6 +83,11 @@ "items": { "type": "string" } }, "license": { "type": "string" }, + "icon": { + "type": "string", + "format": "uri", + "description": "Thumbnail for the store list, pinned to the release tag so an entry's picture cannot change under it. Present only when the manifest declares an icon." + }, "homepage": { "type": "string", "format": "uri" }, "studioVersion": { "type": "string", @@ -90,11 +95,16 @@ }, "contributes": { "type": "object", - "required": ["blueprintNodes", "widgets"], + "required": ["blueprintNodes", "widgets", "locales"], "additionalProperties": false, "properties": { "blueprintNodes": { "type": "array", "items": { "type": "string" } }, - "widgets": { "type": "array", "items": { "type": "string" } } + "widgets": { "type": "array", "items": { "type": "string" } }, + "locales": { + "type": "array", + "items": { "type": "string" }, + "description": "Locale codes the plugin's language packs add or fill." + } } }, "permissions": { diff --git a/schema/manifest.schema.json b/schema/manifest.schema.json index 136ab5b..82b7ba9 100644 --- a/schema/manifest.schema.json +++ b/schema/manifest.schema.json @@ -28,6 +28,11 @@ }, "description": { "type": "string" }, "publisher": { "type": "string" }, + "icon": { + "type": "string", + "pattern": "^(?![A-Za-z]:)(?![\\\\/])(?!.*(^|[\\\\/])\\.\\.([\\\\/]|$))[^\\u0000?#]+\\.([pP][nN][gG]|[wW][eE][bB][pP]|[jJ][pP][gG]|[jJ][pP][eE][gG])$", + "description": "Package-relative thumbnail shown beside the plugin in Studio's Launcher list. Must be square, at most 512x512 and at least 64x64, at most 512 KB, and .png/.webp/.jpg/.jpeg (no SVG, no GIF). The file must also be copied into dist/ by the build script — scripts/validate.mjs checks all of this." + }, "entries": { "type": "object", "minProperties": 1, diff --git a/scripts/lib/image.mjs b/scripts/lib/image.mjs new file mode 100644 index 0000000..b1b175c --- /dev/null +++ b/scripts/lib/image.mjs @@ -0,0 +1,194 @@ +/** + * Icon rules and a header-only image reader. + * + * Port of Studio's src/shared/constants/pluginIcon.ts + src/shared/utils/ + * {imageDimensions,pluginIcon}.ts. Same contract as the manifest validator next + * door: if that file changes, this one must change with it, or CI accepts an + * icon Studio refuses at install. + * + * Header-only is deliberate — nothing here decodes an image, it reads a bounded + * prefix of structure and reports what the bytes actually are, which is how a + * `.png` that is really something else gets caught. + */ + +/** Raster only. `svg` is a document that can carry script; `gif` animates a list row. */ +export const PLUGIN_ICON_EXTENSIONS = ["png", "webp", "jpg", "jpeg"]; +export const PLUGIN_ICON_MAX_DIMENSION = 512; +export const PLUGIN_ICON_MIN_DIMENSION = 64; +export const PLUGIN_ICON_MAX_BYTES = 512 * 1024; + +const FORMAT_OF_EXTENSION = { png: "png", webp: "webp", jpg: "jpeg", jpeg: "jpeg" }; + +const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +/** The declared extension of an icon path, lowercased, or null if not allowed. */ +export function pluginIconExtension(iconPath) { + const dot = typeof iconPath === "string" ? iconPath.lastIndexOf(".") : -1; + if (dot < 0) { + return null; + } + const extension = iconPath.slice(dot + 1).toLowerCase(); + return PLUGIN_ICON_EXTENSIONS.includes(extension) ? extension : null; +} + +export function pluginIconExtensionList() { + return PLUGIN_ICON_EXTENSIONS.map(extension => `.${extension}`).join(", "); +} + +/** `{ format, width, height }`, or null when the bytes are not a readable image. */ +export function readImageDimensions(bytes) { + return readPng(bytes) ?? readJpeg(bytes) ?? readWebp(bytes); +} + +/** An error message describing why these bytes are not a shippable icon, or null. */ +export function validatePluginIconBytes(bytes, iconPath) { + const extension = pluginIconExtension(iconPath); + if (!extension) { + return `icon must be one of: ${pluginIconExtensionList()}`; + } + if (bytes.length > PLUGIN_ICON_MAX_BYTES) { + return `icon must be at most ${Math.floor(PLUGIN_ICON_MAX_BYTES / 1024)} KB (got ${Math.ceil(bytes.length / 1024)} KB)`; + } + const probe = readImageDimensions(bytes); + if (!probe) { + return `icon "${iconPath}" is not a readable ${extension.toUpperCase()} image`; + } + if (probe.format !== FORMAT_OF_EXTENSION[extension]) { + return `icon "${iconPath}" is a ${probe.format.toUpperCase()} file with a .${extension} name`; + } + if (probe.width !== probe.height) { + return `icon must be square (got ${probe.width}x${probe.height})`; + } + if (probe.width > PLUGIN_ICON_MAX_DIMENSION) { + return `icon must be at most ${PLUGIN_ICON_MAX_DIMENSION}x${PLUGIN_ICON_MAX_DIMENSION} (got ${probe.width}x${probe.height})`; + } + if (probe.width < PLUGIN_ICON_MIN_DIMENSION) { + return `icon must be at least ${PLUGIN_ICON_MIN_DIMENSION}x${PLUGIN_ICON_MIN_DIMENSION} (got ${probe.width}x${probe.height})`; + } + return null; +} + +function ascii(bytes, offset, length) { + if (offset + length > bytes.length) { + return ""; + } + let out = ""; + for (let i = 0; i < length; i += 1) { + out += String.fromCharCode(bytes[offset + i]); + } + return out; +} + +function be32(bytes, offset) { + return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0; +} + +function be16(bytes, offset) { + return (bytes[offset] << 8) | bytes[offset + 1]; +} + +function le16(bytes, offset) { + return bytes[offset] | (bytes[offset + 1] << 8); +} + +/** IHDR is walked to, not assumed first: tools do emit a chunk ahead of it. */ +function readPng(bytes) { + if (bytes.length < 24 || PNG_SIGNATURE.some((byte, index) => bytes[index] !== byte)) { + return null; + } + let offset = 8; + while (offset + 8 <= bytes.length) { + const length = be32(bytes, offset); + if (ascii(bytes, offset + 4, 4) === "IHDR") { + if (offset + 16 > bytes.length) { + return null; + } + return { format: "png", width: be32(bytes, offset + 8), height: be32(bytes, offset + 12) }; + } + // length + the 4-byte length field + the 4-byte type + the 4-byte CRC. + offset += length + 12; + } + return null; +} + +/** Start-of-frame markers. C4/C8/CC share the range but are tables, not frames. */ +function isStartOfFrame(marker) { + return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc; +} + +function readJpeg(bytes) { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) { + return null; + } + let offset = 2; + while (offset + 4 <= bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + let marker = bytes[offset + 1]; + while (marker === 0xff && offset + 2 < bytes.length) { + offset += 1; + marker = bytes[offset + 1]; + } + offset += 2; + if (marker === 0xd9) { + return null; + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + continue; + } + if (offset + 2 > bytes.length) { + return null; + } + if (isStartOfFrame(marker)) { + // length(2) precision(1) height(2) width(2) + if (offset + 7 > bytes.length) { + return null; + } + return { format: "jpeg", height: be16(bytes, offset + 3), width: be16(bytes, offset + 5) }; + } + const segmentLength = be16(bytes, offset); + if (segmentLength < 2) { + return null; + } + offset += segmentLength; + } + return null; +} + +function readWebp(bytes) { + if (bytes.length < 30 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") { + return null; + } + const chunk = ascii(bytes, 12, 4); + const payload = 20; + + if (chunk === "VP8X") { + const width = (bytes[payload + 4] | (bytes[payload + 5] << 8) | (bytes[payload + 6] << 16)) + 1; + const height = (bytes[payload + 7] | (bytes[payload + 8] << 8) | (bytes[payload + 9] << 16)) + 1; + return { format: "webp", width, height }; + } + if (chunk === "VP8 ") { + if (bytes[payload + 3] !== 0x9d || bytes[payload + 4] !== 0x01 || bytes[payload + 5] !== 0x2a) { + return null; + } + return { + format: "webp", + width: le16(bytes, payload + 6) & 0x3fff, + height: le16(bytes, payload + 8) & 0x3fff, + }; + } + if (chunk === "VP8L") { + if (bytes[payload] !== 0x2f) { + return null; + } + const bits = (bytes[payload + 1] | (bytes[payload + 2] << 8) | (bytes[payload + 3] << 16) | (bytes[payload + 4] << 24)) >>> 0; + return { + format: "webp", + width: (bits & 0x3fff) + 1, + height: ((bits >>> 14) & 0x3fff) + 1, + }; + } + return null; +} diff --git a/scripts/lib/plugins.mjs b/scripts/lib/plugins.mjs index 4d625ce..77cc5f2 100644 --- a/scripts/lib/plugins.mjs +++ b/scripts/lib/plugins.mjs @@ -12,6 +12,11 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + pluginIconExtension, + pluginIconExtensionList, + validatePluginIconBytes, +} from "./image.mjs"; export const repoRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); export const pluginsDir = path.join(repoRoot, "plugins"); @@ -19,6 +24,8 @@ export const templateDir = path.join(repoRoot, "template"); export const indexPath = path.join(repoRoot, "index.json"); export const REPOSITORY_URL = "https://github.com/NarraLeaf/Plugins"; +/** Where the same repository serves raw file bytes. Must stay in step with REPOSITORY_URL. */ +export const RAW_CONTENT_URL = "https://raw.githubusercontent.com/NarraLeaf/Plugins"; export const INDEX_FORMAT_VERSION = 1; /** Studio only understands manifestVersion 2. v1 is hard-rejected at install. */ @@ -156,6 +163,21 @@ export function releasePageUrl(id, version) { return `${REPOSITORY_URL}/releases/tag/${encodeURIComponent(releaseTag(id, version))}`; } +/** + * Where the store fetches a plugin's thumbnail: the icon file as it stood at + * the release tag. + * + * Pinned to the tag rather than to a branch for the same reason the download + * URL is — an index entry describes one immutable version, so the picture it + * carries should not change under it when the plugin's next version lands. Like + * the download URL this is deterministic from (id, version) and therefore + * reviewable in the PR that bumps the version, before the tag exists. + */ +export function iconUrl(id, version, icon) { + const relative = icon.split(/[\\/]+/).map(encodeURIComponent).join("/"); + return `${RAW_CONTENT_URL}/${encodeURIComponent(releaseTag(id, version))}/plugins/${id}/${relative}`; +} + /** Split `@` back apart. Returns null when malformed. */ export function parseReleaseTag(tag) { const at = tag.lastIndexOf("@"); @@ -462,6 +484,18 @@ export function validatePluginManifest(value) { errors.push("version must be semver, for example 1.0.0"); } + // Only the shape is decidable here — the icon is a file, and whether those + // bytes are a square image within the size limits is checked in loadPlugin, + // which has the directory. Both halves have to hold for Studio to install it. + if (value.icon !== undefined) { + const icon = typeof value.icon === "string" ? value.icon.trim() : ""; + if (!icon || !isSafeRelativeEntry(icon)) { + errors.push("icon must be a relative image path inside the plugin package"); + } else if (!pluginIconExtension(icon)) { + errors.push(`icon must be one of: ${pluginIconExtensionList()}`); + } + } + const entries = value.entries; if (!isRecord(entries)) { errors.push("entries must be an object declaring at least one of: studio, runtime"); @@ -676,6 +710,21 @@ export function loadPlugin(dirName, { root = pluginsDir } = {}) { } } + // The icon travels with the package, so it is checked here rather than left + // for Studio to reject at install time — the same rules, one step earlier. + if (result.ok && typeof manifest.icon === "string" && manifest.icon.trim()) { + const icon = manifest.icon.trim(); + const iconPath = path.join(dir, ...icon.split(/[\\/]+/)); + if (!fs.existsSync(iconPath) || !fs.statSync(iconPath).isFile()) { + errors.push(`icon file not found: ${icon}`); + } else { + const problem = validatePluginIconBytes(fs.readFileSync(iconPath), icon); + if (problem) { + errors.push(problem); + } + } + } + // A committed lockfile is what makes a plugin reproducible for other // contributors and for the release runner. if (!fs.existsSync(path.join(dir, "yarn.lock"))) { @@ -716,6 +765,9 @@ export function toIndexEntry(plugin) { }, }; + if (manifest.icon) { + entry.icon = iconUrl(manifest.id, manifest.version, manifest.icon.trim()); + } if (meta.studioVersion) { entry.studioVersion = meta.studioVersion; } diff --git a/scripts/package-plugin.mjs b/scripts/package-plugin.mjs index 89daeb3..f53412b 100644 --- a/scripts/package-plugin.mjs +++ b/scripts/package-plugin.mjs @@ -87,6 +87,15 @@ for (const target of ["studio", "runtime"]) { missing.push(`${entry} (declared as entries.${target})`); } } +// The build script has to copy the icon the way it copies manifest.json. +// Studio refuses a package whose declared icon is absent, so catching it here +// turns a failed install into a failed build. +if (typeof manifest.icon === "string" && manifest.icon.trim()) { + const icon = manifest.icon.trim(); + if (!fs.existsSync(path.join(distDir, ...icon.split(/[\\/]+/)))) { + missing.push(`${icon} (declared as icon — copy it into dist/ from your build script)`); + } +} if (missing.length) { console.error(`plugins/${pluginId}/dist is missing declared file(s):`); for (const item of missing) { diff --git a/template/build.mjs b/template/build.mjs index 9c512c2..d1cab6a 100644 --- a/template/build.mjs +++ b/template/build.mjs @@ -69,3 +69,13 @@ for (const target of ["studio", "runtime"]) { // 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"); + +// Same for the icon: Studio refuses a package whose declared icon is missing, +// so anything the manifest points at has to end up in dist/. +if (typeof manifest.icon === "string" && manifest.icon.trim()) { + const relative = manifest.icon.trim().split(/[\\/]+/); + const target = path.join(distDir, ...relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(root, ...relative), target); + console.log(`copied ${manifest.icon}`); +} diff --git a/template/icon.png b/template/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3eca024256c6be85d605914942a10b80cc88d741 GIT binary patch literal 6148 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 a?!or94(jIzKckZY_U<~Um%799;{O4I+oBEt literal 0 HcmV?d00001 diff --git a/template/manifest.json b/template/manifest.json index 22f5a5a..1ca1bdc 100644 --- a/template/manifest.json +++ b/template/manifest.json @@ -5,6 +5,7 @@ "version": "0.1.0", "description": "Template plugin: one blueprint node registered on both the studio and runtime targets.", "publisher": "Example", + "icon": "icon.png", "entries": { "studio": "main.js", "runtime": "runtime.js" From db806ea4a498376aa6d501527dfed3d9691670cc Mon Sep 17 00:00:00 2001 From: helloyork Date: Fri, 31 Jul 2026 16:24:43 -0700 Subject: [PATCH 2/3] fix(registry): no placeholder icon, real tests, and index.json back in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The starter no longer declares an icon. A template that ships one hands every new plugin a placeholder to forget to replace, and the fallback it was standing in for is better anyway — Studio draws a monogram from the plugin's name, which is a fine place for a plugin to stay. That left the icon rules with nothing in the repository exercising them, so they get their own tests (`node --test scripts/lib/*.test.mjs`, no dependency, wired into CI ahead of the validator). Without them the port would be code CI never runs until the first contributor's plugin is accepted or refused by rules nobody has executed. Also regenerates index.json, which has been stale since the Steam achievements plugin landed without it — `generate-index.mjs --check` runs in CI, so the registry job has been failing on develop since that merge. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 ++ CONTRIBUTING.md | 19 ++++-- index.json | 54 ++++++++++++++++ scripts/lib/image.test.mjs | 124 +++++++++++++++++++++++++++++++++++++ template/build.mjs | 5 +- template/icon.png | Bin 6148 -> 0 bytes template/manifest.json | 1 - 7 files changed, 199 insertions(+), 9 deletions(-) create mode 100644 scripts/lib/image.test.mjs delete mode 100644 template/icon.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12546ad..9be9a2a 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76f6aa5..c19b900 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,9 +61,9 @@ A pull request is mergeable when: 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. Plugins without one keep the - monogram tile Studio draws from the name — that is a fine place to stay, but - do replace the template's placeholder piece before publishing. + 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 @@ -94,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..ed5e873 100644 --- a/index.json +++ b/index.json @@ -40,6 +40,60 @@ "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" + }, + "studioVersion": ">=0.2.0", + "locales": { + "zh-CN": { + "name": "Steam 成就", + "description": "在 Studio 里编写 Steam 成就与统计量,并用蓝图节点解锁。Steam 不可用时写入本地镜像,itch 版、web 版与 Dev Mode 下同一套脚本照常工作。" + } + } } ] } diff --git a/scripts/lib/image.test.mjs b/scripts/lib/image.test.mjs new file mode 100644 index 0000000..4883cd7 --- /dev/null +++ b/scripts/lib/image.test.mjs @@ -0,0 +1,124 @@ +/** + * Tests for the icon reader. Run with `node --test scripts/`. + * + * This file exists because nothing in the repository ships an icon — the + * starter deliberately does not — so without it the port would be dead code + * that CI never executes, right up until the first contributor's plugin is + * accepted or refused by rules nobody has run. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { + pluginIconExtension, + readImageDimensions, + validatePluginIconBytes, +} from "./image.mjs"; + +const be32 = value => [(value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff]; +const be16 = value => [(value >>> 8) & 0xff, value & 0xff]; +const le32 = value => [value & 0xff, (value >>> 8) & 0xff, (value >>> 16) & 0xff, (value >>> 24) & 0xff]; +const ascii = value => [...value].map(character => character.charCodeAt(0)); + +/** PNG signature + an IHDR chunk, optionally padded out to a byte length. */ +function png(width, height = width, padTo = 0) { + const bytes = [ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0, 0, 0, 13, ...ascii("IHDR"), + ...be32(width), ...be32(height), + 8, 6, 0, 0, 0, 0, 0, 0, 0, + ]; + while (bytes.length < padTo) { + bytes.push(0); + } + return Buffer.from(bytes); +} + +/** SOI, an APP0 segment to skip over, then an SOF0 frame header. */ +function jpeg(width, height = width) { + return Buffer.from([ + 0xff, 0xd8, + 0xff, 0xe0, 0x00, 0x06, ...ascii("JFIF"), + 0xff, 0xc0, 0x00, 0x11, 0x08, ...be16(height), ...be16(width), 0x03, + 0xff, 0xd9, + ]); +} + +function webp(chunk, payload) { + const body = [...ascii("WEBP"), ...ascii(chunk), ...le32(payload.length), ...payload]; + const bytes = [...ascii("RIFF"), ...le32(body.length), ...body]; + while (bytes.length < 40) { + bytes.push(0); + } + return Buffer.from(bytes); +} + +test("reads PNG dimensions", () => { + assert.deepEqual(readImageDimensions(png(512)), { format: "png", width: 512, height: 512 }); +}); + +test("reads PNG dimensions past a chunk that precedes IHDR", () => { + const leading = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0, 0, 0, 4, ...ascii("cLLi"), 1, 2, 3, 4, 0, 0, 0, 0, + 0, 0, 0, 13, ...ascii("IHDR"), ...be32(256), ...be32(128), 8, 6, 0, 0, 0, 0, 0, 0, 0, + ]); + assert.deepEqual(readImageDimensions(leading), { format: "png", width: 256, height: 128 }); +}); + +test("reads JPEG dimensions from the start-of-frame marker", () => { + assert.deepEqual(readImageDimensions(jpeg(300, 200)), { format: "jpeg", width: 300, height: 200 }); +}); + +test("reads lossy WebP dimensions", () => { + const payload = [0, 0, 0, 0x9d, 0x01, 0x2a, 0x00, 0x02, 0x00, 0x02]; + assert.deepEqual(readImageDimensions(webp("VP8 ", payload)), { format: "webp", width: 512, height: 512 }); +}); + +test("reads lossless WebP dimensions", () => { + const bits = (511 & 0x3fff) | ((255 & 0x3fff) << 14); + assert.deepEqual( + readImageDimensions(webp("VP8L", [0x2f, ...le32(bits >>> 0)])), + { format: "webp", width: 512, height: 256 }, + ); +}); + +test("reads extended WebP canvas dimensions", () => { + const payload = [0, 0, 0, 0, 0xff, 0x01, 0x00, 0xff, 0x01, 0x00]; + assert.deepEqual(readImageDimensions(webp("VP8X", payload)), { format: "webp", width: 512, height: 512 }); +}); + +test("returns null for bytes that are not a readable image", () => { + assert.equal(readImageDimensions(Buffer.from(ascii(''))), null); + assert.equal(readImageDimensions(Buffer.alloc(0)), null); + assert.equal(readImageDimensions(png(512).subarray(0, 20)), null); +}); + +test("accepts the allowed extensions, case-insensitively", () => { + assert.equal(pluginIconExtension("icon.png"), "png"); + assert.equal(pluginIconExtension("assets/Icon.PNG"), "png"); + assert.equal(pluginIconExtension("icon.webp"), "webp"); + assert.equal(pluginIconExtension("icon.jpeg"), "jpeg"); + assert.equal(pluginIconExtension("icon.svg"), null); + assert.equal(pluginIconExtension("icon.gif"), null); + assert.equal(pluginIconExtension("icon"), null); +}); + +test("accepts a square icon inside the size range", () => { + assert.equal(validatePluginIconBytes(png(512), "icon.png"), null); + assert.equal(validatePluginIconBytes(png(64), "icon.png"), null); + assert.equal(validatePluginIconBytes(jpeg(128), "photo.JPG"), null); +}); + +test("refuses everything the rules refuse", () => { + assert.match(validatePluginIconBytes(png(512, 256), "icon.png"), /square \(got 512x256\)/); + assert.match(validatePluginIconBytes(png(513), "icon.png"), /at most 512x512/); + assert.match(validatePluginIconBytes(png(32), "icon.png"), /at least 64x64/); + assert.match(validatePluginIconBytes(png(512, 512, 512 * 1024 + 1), "icon.png"), /at most 512 KB/); + // A file named .png that decodes as something else is either a mistake or an + // attempt to smuggle a format past the extension allowlist. + assert.match(validatePluginIconBytes(png(512), "icon.webp"), /is a PNG file with a \.webp name/); + assert.match(validatePluginIconBytes(png(512), "icon.svg"), /must be one of/); + const svg = Buffer.from(ascii('')); + assert.match(validatePluginIconBytes(svg, "icon.png"), /not a readable PNG image/); +}); diff --git a/template/build.mjs b/template/build.mjs index d1cab6a..39560f2 100644 --- a/template/build.mjs +++ b/template/build.mjs @@ -70,8 +70,9 @@ for (const target of ["studio", "runtime"]) { fs.copyFileSync(path.join(root, "manifest.json"), path.join(distDir, "manifest.json")); console.log("copied manifest.json"); -// Same for the icon: Studio refuses a package whose declared icon is missing, -// so anything the manifest points at has to end up in dist/. +// Same for a declared icon (the starter has none — add `"icon": "icon.png"` to +// manifest.json and drop the file next to it). Studio refuses a package whose +// declared icon is missing, so anything the manifest points at ships too. if (typeof manifest.icon === "string" && manifest.icon.trim()) { const relative = manifest.icon.trim().split(/[\\/]+/); const target = path.join(distDir, ...relative); diff --git a/template/icon.png b/template/icon.png deleted file mode 100644 index 3eca024256c6be85d605914942a10b80cc88d741..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 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 a?!or94(jIzKckZY_U<~Um%799;{O4I+oBEt diff --git a/template/manifest.json b/template/manifest.json index 1ca1bdc..22f5a5a 100644 --- a/template/manifest.json +++ b/template/manifest.json @@ -5,7 +5,6 @@ "version": "0.1.0", "description": "Template plugin: one blueprint node registered on both the studio and runtime targets.", "publisher": "Example", - "icon": "icon.png", "entries": { "studio": "main.js", "runtime": "runtime.js" From 1c79a6d25c542aaf58cf10e1ad11605ec245fbd9 Mon Sep 17 00:00:00 2001 From: helloyork Date: Sat, 1 Aug 2026 17:30:35 -0700 Subject: [PATCH 3/3] feat(steam-achievements): build the bridge, and stop shipping digests nobody can check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's Rust half had never been compiled and its manifest carried six placeholder sha256 digests, so every game build failed on a file that was not there. The stated reason — that the Steamworks SDK needs a Valve partner account — is not true: steamworks-sys vendors the SDK under its own lib/steam/ and falls back to it whenever STEAM_SDK_LOCATION is unset. So the bridge now builds with a Rust toolchain and nothing else, and contributes.buildDependencies is gone: the shared library ships beside the executable, copied out of cargo's own output so the two can never drift apart. Verified against a live Steam client on windows-x64 with Spacewar (App ID 480): init reports the real App ID and language, an unknown API name is refused by Steam rather than silently succeeding, and unlock / setStat / indicateProgress / resetAll all land through StoreStats. Studio's own artifact compiler stages the package into a preview compile with both digests matching. Digests are now computed, never authored. A sidecar target claims that a package carries certain bytes, and that is only true of a package that has the binary — which this repository does not, they are build output. So manifest.json declares no sidecar at all; sidecar/contribution.json holds what is not a property of a compiled artifact, and build.mjs includes each platform whose files are present, hashes them, and writes the block into dist/manifest.json. With no binaries at all it emits a mirror-only package: every node still works, nothing reaches Steam. That shape is what CI packages on every push, because it is the one that must never break. Also in the TypeScript half: - narraleaf-studio ^0.2.0 -> ^0.5.0. The plugin was written against APIs that version predates, so `yarn typecheck` had failed since the plugin landed and CI has been red ever since. It is green now, with 0 errors. - Join the freeze/reload contract. The store mutated memory before awaiting a write that a frozen project discards, and never re-read after a restore — so the author's next edit wrote pre-restore memory over the version they had just restored. commit() now bails before touching memory, every writing control goes through FreezeGuard, and the store registers a reloader. - The Steam App ID field wrote the whole catalog to disk on every keystroke; it is a DraftInput like every other field now. - 29 tests for the catalog's pure half, and a plugin thumbnail. package-plugin.mjs validates dist/manifest.json too, not just the committed one: the shipped manifest is now generated, and generation was the one part of the package nothing checked. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 16 +- .github/workflows/release.yml | 133 +- .gitignore | 6 + index.json | 1 + .../narraleaf.steam-achievements/README.md | 83 +- .../narraleaf.steam-achievements/build.mjs | 128 +- plugins/narraleaf.steam-achievements/icon.png | Bin 0 -> 7107 bytes .../manifest.json | 79 +- .../narraleaf.steam-achievements/package.json | 8 +- .../sidecar/Cargo.lock | 159 ++ .../sidecar/Cargo.toml | 20 +- .../sidecar/README.md | 186 +-- .../sidecar/build.mjs | 154 ++ .../sidecar/contribution.json | 52 + .../sidecar/src/steam.rs | 18 +- .../src/catalog.test.ts | 259 +++ .../narraleaf.steam-achievements/src/main.tsx | 110 +- .../tools/make-icon.mjs | 222 +++ .../narraleaf.steam-achievements/yarn.lock | 1488 +++++++++++++++-- scripts/package-plugin.mjs | 36 + 20 files changed, 2718 insertions(+), 440 deletions(-) create mode 100644 plugins/narraleaf.steam-achievements/icon.png create mode 100644 plugins/narraleaf.steam-achievements/sidecar/Cargo.lock create mode 100644 plugins/narraleaf.steam-achievements/sidecar/build.mjs create mode 100644 plugins/narraleaf.steam-achievements/sidecar/contribution.json create mode 100644 plugins/narraleaf.steam-achievements/src/catalog.test.ts create mode 100644 plugins/narraleaf.steam-achievements/tools/make-icon.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9be9a2a..6e4c49f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,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/index.json b/index.json index ed5e873..e2955d5 100644 --- a/index.json +++ b/index.json @@ -87,6 +87,7 @@ "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": { 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 0000000000000000000000000000000000000000..65350b0a73253ebe350e2b2d94ff438bd6cb3c31 GIT binary patch literal 7107 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 literal 0 HcmV?d00001 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 (