From 4f34dfd2746aa52dd2920e8fcb31763edd068ff9 Mon Sep 17 00:00:00 2001 From: Lulu Date: Fri, 31 Jul 2026 02:17:05 +0800 Subject: [PATCH] feat(stage-tamagotchi): package Steam redistributables and isolate userData Add Steam packaging plumbing so a Steam-distributed Electron build can place Steamworks SDK redistributables beside the app, use Steam-specific macOS entitlements (disable-library-validation), and keep Steam userData separate from direct/GitHub-release installs. This is one slice of PR #1966, split at maintainer request. It lands independently of the still-WIP Steam auth/FFI client PR and leaves CI/deploy workflow changes to existing PR #1827. Co-authored-by: Cursor --- .gitignore | 3 + .../build/entitlements.mac.steam.plist | 20 ++++ .../electron-builder.config.ts | 44 ++++++- .../scripts/pack-steam-redistributables.ts | 113 ++++++++++++++++++ .../src/main/app/userData.test.ts | 40 +++++++ .../stage-tamagotchi/src/main/app/userData.ts | 31 +++++ apps/stage-tamagotchi/src/main/index.ts | 7 +- apps/stage-tamagotchi/steam_appid.txt | 1 + 8 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 apps/stage-tamagotchi/build/entitlements.mac.steam.plist create mode 100644 apps/stage-tamagotchi/scripts/pack-steam-redistributables.ts create mode 100644 apps/stage-tamagotchi/src/main/app/userData.test.ts create mode 100644 apps/stage-tamagotchi/src/main/app/userData.ts create mode 100644 apps/stage-tamagotchi/steam_appid.txt diff --git a/.gitignore b/.gitignore index 46e455750..95c390614 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,9 @@ cert*/ # Test coverage coverage/ +# Steamworks SDK (download from Steamworks partner site; not redistributable in git) +apps/stage-tamagotchi/steamworks_sdk/ + # Build out/ bundle/ diff --git a/apps/stage-tamagotchi/build/entitlements.mac.steam.plist b/apps/stage-tamagotchi/build/entitlements.mac.steam.plist new file mode 100644 index 000000000..a9146d621 --- /dev/null +++ b/apps/stage-tamagotchi/build/entitlements.mac.steam.plist @@ -0,0 +1,20 @@ + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-dyld-environment-variables + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.camera + + com.apple.security.device.microphone + + com.apple.security.device.audio-input + + + diff --git a/apps/stage-tamagotchi/electron-builder.config.ts b/apps/stage-tamagotchi/electron-builder.config.ts index f8d55dd94..06558b475 100644 --- a/apps/stage-tamagotchi/electron-builder.config.ts +++ b/apps/stage-tamagotchi/electron-builder.config.ts @@ -2,10 +2,14 @@ import type { Configuration } from 'electron-builder' +import process from 'node:process' + import { execSync } from 'node:child_process' import { isMacOS } from 'std-env' +import { packSteamRedistributables } from './scripts/pack-steam-redistributables' + function hasXcode26OrAbove() { if (!isMacOS) return false @@ -40,6 +44,14 @@ else { console.warn('[electron-builder/config] Xcode version is 26 or above. Using .icon format for macOS app icon.') } +// NOTICE: +// Steam loads an unsigned libsteam_api.dylib from beside the .app, so only +// Steam builds disable hardened-runtime library validation. +// https://github.com/moeru-ai/airi/pull/1966#discussion_r3642547795 +const macEntitlementsFile = process.env.VITE_DISTRIBUTION === 'steam' + ? 'build/entitlements.mac.steam.plist' + : 'build/entitlements.mac.plist' + export default { appId: 'ai.moeru.airi', productName: 'AIRI', @@ -104,6 +116,35 @@ export default { filter: ['**/*'], }, ], + // NOTICE: + // Steam redistributables must be present before codesign/notarize finishes. + // On macOS they must NOT go under Contents/MacOS — codesign treats every file + // there as a code subcomponent, and steam_appid.txt fails with + // “code object is not signed at all”. Place them next to the .app instead + // (Steam sets cwd to the game folder that contains AIRI.app). + afterPack: async (context) => { + if (process.env.VITE_DISTRIBUTION !== 'steam') + return + + const electronPlatform = context.electronPlatformName + let platform: 'macos' | 'windows' | 'linux' + switch (electronPlatform) { + case 'darwin': + platform = 'macos' + break + case 'win32': + platform = 'windows' + break + case 'linux': + platform = 'linux' + break + default: + return + } + + // macOS: sibling of `.app` in appOutDir. Win/Linux: next to the executable. + await packSteamRedistributables(platform, context.appOutDir) + }, extraMetadata: { name: 'ai.moeru.airi', main: 'out/main/index.js', @@ -135,7 +176,8 @@ export default { runAfterFinish: true, }, mac: { - entitlementsInherit: 'build/entitlements.mac.plist', + entitlements: macEntitlementsFile, + entitlementsInherit: macEntitlementsFile, // NOTICE: Same channel rule as Windows. Keep `${arch}` here so generated metadata resolves // to architecture-specific update feeds on macOS (for example: `latest-x64-mac.yml`, `latest-arm64-mac.yml`). publish: { diff --git a/apps/stage-tamagotchi/scripts/pack-steam-redistributables.ts b/apps/stage-tamagotchi/scripts/pack-steam-redistributables.ts new file mode 100644 index 000000000..708ddb2ca --- /dev/null +++ b/apps/stage-tamagotchi/scripts/pack-steam-redistributables.ts @@ -0,0 +1,113 @@ +/** + * Write `steam_appid.txt` and the platform Steam API library into a depot folder. + * + * Preserves `steamworks_sdk/redistributable_bin/...` next to the app executable, + * as expected by `steamworks-ffi-node` when `process.cwd()` is that directory. + * + * Redistributables are fetched from the public mirror (`STEAMWORKS_SDK_MIRROR_*`). + * + * Steam CI injects these in electron-builder `afterPack` (before codesign/notarize) + * so macOS Gatekeeper does not see a broken seal. Do not copy them into an already + * signed `.app` in the depot packaging step. + * + * Usage: + * pnpm -F @proj-airi/stage-tamagotchi exec tsx scripts/pack-steam-redistributables.ts + * + * Local dev (`destDir` is the tamagotchi package root): + * pnpm -F @proj-airi/stage-tamagotchi exec tsx scripts/pack-steam-redistributables.ts macos . + */ + +import process from 'node:process' + +import { Buffer } from 'node:buffer' +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { errorMessageFrom } from '@moeru/std' + +// NOTICE: +// Keep in sync with apps/stage-tamagotchi/steam_appid.txt. Inlined here so the +// packaging script does not depend on the (separate) Steamworks FFI/auth module. +const STEAM_APP_ID = 3885340 + +// NOTICE: +// Temporary CI fallback is the public rlabrecque/SteamworksSDK mirror (Valve copyright). +// Replace with an org-private artifact store fed from partner.steamgames.com before long-term production use. +const DEFAULT_MIRROR_REPO = 'rlabrecque/SteamworksSDK' +const DEFAULT_MIRROR_REF = 'be6107f4b75bf996531415c53a6488a33a2a1be3' + +/** Relative path under `steamworks_sdk/redistributable_bin/`. */ +const redistributables: Record = { + windows: 'win64/steam_api64.dll', + macos: 'osx/libsteam_api.dylib', + linux: 'linux64/libsteam_api.so', +} + +export type SteamRedistributablePlatform = keyof typeof redistributables + +function mirrorBaseUrl(): string { + const repo = process.env.STEAMWORKS_SDK_MIRROR_REPO ?? DEFAULT_MIRROR_REPO + const ref = process.env.STEAMWORKS_SDK_MIRROR_REF ?? DEFAULT_MIRROR_REF + return `https://raw.githubusercontent.com/${repo}/${ref}/redistributable_bin` +} + +async function downloadFile(url: string, dest: string): Promise { + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to download ${url}: HTTP ${response.status}`) + } + + const bytes = Buffer.from(await response.arrayBuffer()) + if (bytes.length === 0) { + throw new Error(`Downloaded empty file from ${url}`) + } + + mkdirSync(dirname(dest), { recursive: true }) + writeFileSync(dest, bytes) +} + +/** + * Downloads the platform Steam API library and writes `steam_appid.txt` under `destDir`. + */ +export async function packSteamRedistributables( + platform: SteamRedistributablePlatform, + destDir: string, +): Promise { + const relativePath = redistributables[platform] + + // Resolve so relative dests are anchored to process.cwd() (pnpm -F exec uses the + // package root). Callers in CI should pass an absolute path into the depot tree. + const resolvedDestDir = resolve(destDir) + mkdirSync(resolvedDestDir, { recursive: true }) + writeFileSync(join(resolvedDestDir, 'steam_appid.txt'), `${STEAM_APP_ID}\n`, 'utf8') + + const dest = join(resolvedDestDir, 'steamworks_sdk', 'redistributable_bin', relativePath) + const url = `${mirrorBaseUrl()}/${relativePath}` + console.info(`[steam] downloading ${relativePath} from mirror -> ${dest}`) + await downloadFile(url, dest) +} + +async function main(): Promise { + const platform = process.argv[2] + const destDir = process.argv[3] + + if (!platform || !destDir) { + console.error('Usage: tsx pack-steam-redistributables.ts ') + process.exit(1) + } + + if (!(platform in redistributables)) { + console.error(`Unknown platform: ${platform}`) + process.exit(1) + } + + await packSteamRedistributables(platform as SteamRedistributablePlatform, destDir) +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error: unknown) => { + console.error(`[steam] pack failed: ${errorMessageFrom(error) ?? 'unknown error'}`) + process.exit(1) + }) +} diff --git a/apps/stage-tamagotchi/src/main/app/userData.test.ts b/apps/stage-tamagotchi/src/main/app/userData.test.ts new file mode 100644 index 000000000..070961423 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/app/userData.test.ts @@ -0,0 +1,40 @@ +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { resolveUserDataPath } from './userData' + +describe('resolveUserDataPath', () => { + it('keeps the Electron default for direct builds', () => { + expect(resolveUserDataPath({ + defaultPath: join('app-data', 'AIRI'), + distribution: 'direct', + })).toBeUndefined() + }) + + it('prefers an explicit operational override', () => { + expect(resolveUserDataPath({ + defaultPath: join('app-data', 'AIRI'), + distribution: 'steam', + overridePath: ` ${join('test-data', 'airi')} `, + })).toBe(join('test-data', 'airi')) + }) + + // https://github.com/moeru-ai/airi/pull/1966#discussion_r3432862899 + it('isolates Steam user data from direct installations for PR #1966', () => { + // ROOT CAUSE: + // + // Steam and direct builds both accepted Electron's default userData path, + // so a Steam launch could restore credentials and local state written by a + // direct installation before startup Steam authentication ran. + // + // Before the fix, this returned undefined and kept the shared default. + // + // We fixed this by deriving a Steam-only sibling directory while keeping + // the explicit APP_USER_DATA_PATH override authoritative. + expect(resolveUserDataPath({ + defaultPath: join('app-data', 'AIRI'), + distribution: 'steam', + })).toBe(join('app-data', 'AIRI-steam')) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/app/userData.ts b/apps/stage-tamagotchi/src/main/app/userData.ts new file mode 100644 index 000000000..3c9072895 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/app/userData.ts @@ -0,0 +1,31 @@ +import { basename, dirname, join } from 'node:path' + +/** + * Selects an explicit Electron user-data directory when the runtime requests + * one. Returning `undefined` preserves Electron's default directory. + * + * `APP_USER_DATA_PATH` is an operational override used by smoke tests and + * remains authoritative over build-distribution policy. Steam builds use a + * sibling directory so channel-local credentials, plugins, settings, and + * caches cannot be restored from a direct installation. + */ +export function resolveUserDataPath(params: { + defaultPath: string + distribution?: string + overridePath?: string +}): string | undefined { + const overridePath = params.overridePath?.trim() + if (overridePath) + return overridePath + + if (params.distribution === 'steam') { + // Derive from Electron's platform-specific default instead of hardcoding + // macOS, Windows, or Linux application-data locations. + return join( + dirname(params.defaultPath), + `${basename(params.defaultPath)}-steam`, + ) + } + + return undefined +} diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index e1bacb7cb..4659091e5 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -23,6 +23,7 @@ import icon from '../../resources/icon.png?asset' import { openDebugger, setupDebugger } from './app/debugger' import { nullFileLoggerHandle, setupFileLogger } from './app/file-logger' import { installSingleInstanceGuard } from './app/single-instance' +import { resolveUserDataPath } from './app/userData' import { createArtistryConfig } from './configs/artistry' import { createGlobalAppConfig } from './configs/global' import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle' @@ -65,7 +66,11 @@ setupDebugger() const log = useLogg('main').useGlobalConfig() -const appUserDataPath = env.APP_USER_DATA_PATH?.trim() +const appUserDataPath = resolveUserDataPath({ + defaultPath: app.getPath('userData'), + distribution: import.meta.env.VITE_DISTRIBUTION, + overridePath: env.APP_USER_DATA_PATH, +}) if (appUserDataPath) { app.setPath('userData', appUserDataPath) } diff --git a/apps/stage-tamagotchi/steam_appid.txt b/apps/stage-tamagotchi/steam_appid.txt new file mode 100644 index 000000000..4261998b0 --- /dev/null +++ b/apps/stage-tamagotchi/steam_appid.txt @@ -0,0 +1 @@ +3885340