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 <cursoragent@cursor.com>
This commit is contained in:
Lulu
2026-08-07 23:21:44 +08:00
co-authored by Cursor
parent 5f1c52ec52
commit 4f34dfd274
8 changed files with 257 additions and 2 deletions
+3
View File
@@ -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/
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
@@ -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: {
@@ -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 <windows|macos|linux> <destDir>
*
* 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<string, string> = {
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<void> {
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<void> {
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<void> {
const platform = process.argv[2]
const destDir = process.argv[3]
if (!platform || !destDir) {
console.error('Usage: tsx pack-steam-redistributables.ts <windows|macos|linux> <destDir>')
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)
})
}
@@ -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'))
})
})
@@ -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
}
+6 -1
View File
@@ -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)
}
+1
View File
@@ -0,0 +1 @@
3885340