mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 00:48:06 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
3885340
|
||||
Reference in New Issue
Block a user