diff --git a/streaming/README.md b/streaming/README.md index 1dfab01..095e57e 100644 --- a/streaming/README.md +++ b/streaming/README.md @@ -50,6 +50,12 @@ in secure contexts. | `GET /stream//_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` | | `/stream//*` | browser | Proxied to the session's Selkies, including WebSockets | +A game is streamable when its platform is in `STREAM_PLATFORMS` (default +`Java,Shockwave,Flash`) and the streamer can resolve its launcher. The display +is sized from the content it launches: a Director movie's or projector's stage, +a SWF's stage, a page's applet or largest embed, with +fallbacks of 800x600, or 1024x768 for pages. + Saves are `{relative path: base64 contents}` for the files under the container's `/config/saves`. A session is removed if nothing connects within 2 minutes or 30 seconds after its last WebSocket closes. At most `MAX_SESSIONS` diff --git a/streaming/compose.yaml b/streaming/compose.yaml index 210f593..2d67dce 100644 --- a/streaming/compose.yaml +++ b/streaming/compose.yaml @@ -13,6 +13,7 @@ services: - FLASHPOINT_HOST_PATH - API_TOKEN=${API_TOKEN:?API_TOKEN is required} - MAX_SESSIONS=${MAX_SESSIONS:-4} + - STREAM_PLATFORMS=${STREAM_PLATFORMS:-Java,Shockwave,Flash} - PUID=${PUID:-1000} - PGID=${PGID:-1000} networks: diff --git a/streaming/session-manager/src/games.ts b/streaming/session-manager/src/games.ts index 48736c7..9ffef99 100644 --- a/streaming/session-manager/src/games.ts +++ b/streaming/session-manager/src/games.ts @@ -1,16 +1,18 @@ -import { readFile } from 'node:fs/promises'; +import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; import { buffer } from 'node:stream/consumers'; +import { constants as zlibConstants, inflateSync } from 'node:zlib'; import { DatabaseSync } from 'node:sqlite'; import yauzl from 'yauzl'; /** Java launch applications fp-launch can stream, lowercased. */ const JAVA_APPS = new Set(['fpsoftware\\startjava.bat', 'fpsoftware\\startjavainbrowser.bat']); -/** Windows games installed under LocalGames, which fp-launch runs under Wine. */ -const LOCAL_EXE = /^localgames\\.+\.exe$/i; - const DEFAULT_SIZE: Size = { width: 800, height: 600 }; +const BROWSER_SIZE: Size = { width: 1024, height: 768 }; + +/** A page's default body margin around embedded content. */ +const PAGE_MARGIN = 8; export interface Size { width: number; @@ -18,6 +20,7 @@ export interface Size { } interface LaunchRow { + platform: string; app: string; cmd: string; dateAdded: string | null; @@ -26,10 +29,13 @@ interface LaunchRow { /** Looks up streamable games in a Flashpoint install. */ export class GameCatalog { private readonly root: string; + private readonly platforms: Set; private readonly db: DatabaseSync; - constructor(root: string) { + /** @param platforms Platforms to stream, such as Java, Shockwave and Flash. */ + constructor(root: string, platforms: string[]) { this.root = root; + this.platforms = new Set(platforms.map((p) => p.toLowerCase())); this.db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite'), { readOnly: true }); } @@ -37,36 +43,161 @@ export class GameCatalog { async streamSize(gameId: string): Promise { const row = this.db .prepare( - `select coalesce(nullif(d.applicationPath, ''), g.applicationPath) as app, + `select g.platformName as platform, + coalesce(nullif(d.applicationPath, ''), g.applicationPath) as app, coalesce(nullif(d.launchCommand, ''), g.launchCommand) as cmd, d.dateAdded as dateAdded from game g left join game_data d on d.id = g.activeDataId where g.id = ?`, ) .get(gameId) as unknown as LaunchRow | undefined; - if (row && LOCAL_EXE.test(row.app)) { - const exe = await readFile(path.join(this.root, ...row.app.split('\\'))).catch(() => null); - return exe && (projectorStageSize(exe) ?? DEFAULT_SIZE); - } - if (!row || !JAVA_APPS.has(row.app.toLowerCase())) { + if (!row || !this.platforms.has((row.platform ?? '').toLowerCase())) { return null; } - const page = await this.readLaunchPage(gameId, row).catch(() => null); - return (page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE; + if (row.platform.toLowerCase() === 'java') { + if (!JAVA_APPS.has(row.app.toLowerCase())) { + return null; + } + const page = await this.readContent(gameId, row, row.cmd).catch(() => null); + return even((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE); + } + const program = await this.launchProgram(row); + if (!program) { + return null; + } + const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0]; + if (!url) { + const exe = await readFile(program).catch(() => null); + return even((exe && projectorStageSize(exe)) ?? DEFAULT_SIZE); + } + const content = await this.readContent(gameId, row, url).catch(() => null); + const extension = path.extname(new URL(url).pathname).toLowerCase(); + if (['.dcr', '.dir', '.dxr', '.cct', '.cst'].includes(extension)) { + return even((content && directorStageSize(content)) ?? DEFAULT_SIZE); + } + if (extension === '.swf') { + // The exact stage: in full screen, games that set noScale keep their native size + return even((content && swfStageSize(content)) ?? DEFAULT_SIZE); + } + const embed = content && embedSize(content.toString('latin1')); + return even(embed ? { width: embed.width + 2 * PAGE_MARGIN, height: embed.height + 2 * PAGE_MARGIN } : BROWSER_SIZE); } - private async readLaunchPage(gameId: string, row: LaunchRow): Promise { - const url = new URL(row.cmd); + /** + * Resolves the program fp-command would run, mirroring its rules: execs.json + * mappings, .bat to .sh, an .exe replaced by a same-named script, and + * OldCPUSimulator's target. Returns null if it doesn't exist. + */ + private async launchProgram(row: LaunchRow): Promise { + let relative = (await this.mappedApp(row.app)).replace(/\\/g, '/'); + if (/oldcpusimulator\/oldcpusimulator\.exe$/i.test(relative)) { + const software = row.cmd.match(/(?:-sw|--software)\s+("[^"]+"|\S+)/)?.[1]?.replace(/"/g, ''); + if (!software) { + return null; + } + relative = path.posix.join(path.posix.dirname(relative), software.replace(/\\/g, '/')); + } + if (/\.bat$/i.test(relative)) { + relative = relative.slice(0, -4) + '.sh'; + } + const program = await resolveCaseInsensitive(this.root, relative); + if (program || !/\.exe$/i.test(relative)) { + return program; + } + return resolveCaseInsensitive(this.root, relative.slice(0, -4) + '.sh'); + } + + private async mappedApp(app: string): Promise { + try { + const { execs } = JSON.parse(await readFile(path.join(this.root, 'Data', 'execs.json'), 'utf8')); + const mapping = execs.find((e: { win32: string; linux?: string }) => e.win32.toLowerCase() === app.toLowerCase()); + return mapping?.linux || app; + } catch { + return app; + } + } + + /** Reads a URL's file from the game's GameZIP, or from Legacy htdocs. */ + private async readContent(gameId: string, row: LaunchRow, launchUrl: string): Promise { + const url = new URL(launchUrl); const page = url.pathname.endsWith('/') ? `${url.pathname}index.html` : url.pathname; const file = url.hostname + decodeURIComponent(page); if (row.dateAdded) { const zip = path.join(this.root, 'Data', 'Games', `${gameId}-${new Date(row.dateAdded).getTime()}.zip`); - return readZipEntry(zip, `content/${file}`); + const entry = await readZipEntry(zip, `content/${file}`).catch(() => null); + if (entry) { + return entry; + } } return readFile(path.join(this.root, 'Legacy', 'htdocs', file)); } } +/** Finds a path under root, matching each part case-insensitively as Windows would. */ +async function resolveCaseInsensitive(root: string, relative: string): Promise { + let current = root; + for (const part of relative.split('/').filter((p) => p && p !== '.')) { + if (part === '..') { + current = path.dirname(current); + continue; + } + const names = await readdir(current).catch(() => [] as string[]); + const match = names.find((n) => n === part) ?? names.find((n) => n.toLowerCase() === part.toLowerCase()); + if (!match) { + return null; + } + current = path.join(current, match); + } + return (await stat(current).catch(() => null))?.isFile() ? current : null; +} + +/** H.264 needs even dimensions. */ +function even({ width, height }: Size): Size { + return { width: width + (width % 2), height: height + (height % 2) }; +} + +/** Reads the stage size from a SWF header: a rect of twips after the 8-byte header. */ +export function swfStageSize(swf: Buffer): Size | null { + const signature = swf.toString('latin1', 0, 3); + let body: Buffer; + if (signature === 'FWS') { + body = swf.subarray(8); + } else if (signature === 'CWS') { + try { + body = inflateSync(swf.subarray(8), { finishFlush: zlibConstants.Z_SYNC_FLUSH }); + } catch { + return null; + } + } else { + return null; + } + const bits = body[0] >> 3; + let value = 0n; + for (const byte of body.subarray(0, Math.ceil((5 + 4 * bits) / 8))) { + value = (value << 8n) | BigInt(byte); + } + const totalBits = BigInt(Math.ceil((5 + 4 * bits) / 8) * 8); + const field = (i: number) => + Number((value >> (totalBits - 5n - BigInt((i + 1) * bits))) & ((1n << BigInt(bits)) - 1n)); + const width = Math.round((field(1) - field(0)) / 20); + const height = Math.round((field(3) - field(2)) / 20); + return width > 0 && height > 0 ? { width, height } : null; +} + +/** Reads the size of the largest embed or object on a page, ignoring percentages. */ +export function embedSize(html: string): Size | null { + let best: Size | null = null; + for (const tag of html.match(/<(?:embed|object)\b[^>]*>/gi) ?? []) { + const attr = (name: string) => Number(tag.match(new RegExp(`\\b${name}\\s*=\\s*["']?(\\d+)(?![\\d%])`, 'i'))?.[1]); + const width = attr('width'); + const height = attr('height'); + if (width > 0 && height > 0 && (!best || width * height > best.width * best.height)) { + best = { width, height }; + } + } + return best; +} + /** Reads the width and height of the first applet on a page. */ export function appletSize(html: string): Size | null { const tag = html.match(/]*>/i)?.[0]; @@ -81,12 +212,13 @@ export function appletSize(html: string): Size | null { /** * Reads the stage size of the movie in a Director projector from its config - * chunk: DRCF in big-endian movies, FCRD in little-endian ones. The chunk body - * starts with its own length, the file version, then the stage rect as - * big-endian top, left, bottom, right. + * chunk: DRCF (VWCF before Director 7), with the tag reversed in little-endian + * movies. The chunk body starts with its own length, the file version, then the + * stage rect as big-endian top, left, bottom, right. */ export function projectorStageSize(exe: Buffer): Size | null { - for (const [tag, littleEndian] of [['FCRD', true], ['DRCF', false]] as const) { + const tags = [['FCRD', true], ['DRCF', false], ['FCWV', true], ['VWCF', false]] as const; + for (const [tag, littleEndian] of tags) { for (let i = exe.indexOf(tag); i >= 0 && i + 8 <= exe.length; i = exe.indexOf(tag, i + 1)) { const size = littleEndian ? exe.readUInt32LE(i + 4) : exe.readUInt32BE(i + 4); const body = i + 8; @@ -104,6 +236,103 @@ export function projectorStageSize(exe: Buffer): Size | null { return null; } +/** + * Reads the stage size of a Director movie: .dir, .dxr, .cst, or a projector + * embedding one, and Afterburner-compressed Shockwave (.dcr, .cct). + */ +export function directorStageSize(file: Buffer): Size | null { + const codec = file.toString('latin1', 8, 12); + if (['FGDC', 'FGDM', 'CDGF', 'MDGF'].includes(codec)) { + try { + return afterburnerStageSize(file); + } catch { + return null; + } + } + return projectorStageSize(file); +} + +/** Afterburner layout, after ProjectorRays' DirectorFile::readAfterburnerMap. */ +function afterburnerStageSize(file: Buffer): Size | null { + const littleEndian = file.toString('latin1', 0, 4) === 'XFIR'; + let pos = 12; + const varInt = (buf: Buffer, at: { pos: number }) => { + let value = 0; + let byte; + do { + byte = buf[at.pos++]; + value = value * 128 + (byte & 0x7f); + } while (byte & 0x80); + return value; + }; + const tag = (buf: Buffer, at: number) => { + const text = buf.toString('latin1', at, at + 4); + return littleEndian ? [...text].reverse().join('') : text; + }; + const section = (name: string) => { + if (tag(file, pos) !== name) { + throw new Error(`expected ${name}`); + } + const at = { pos: pos + 4 }; + const length = varInt(file, at); + return { start: at.pos, length }; + }; + + for (const name of ['Fver', 'Fcdr']) { + const { start, length } = section(name); + pos = start + length; + } + const abmp = section('ABMP'); + const abmpAt = { pos: abmp.start }; + varInt(file, abmpAt); // compression type + varInt(file, abmpAt); // uncompressed length + const map = inflateSync(file.subarray(abmpAt.pos, abmp.start + abmp.length)); + const at = { pos: 0 }; + varInt(map, at); + varInt(map, at); + const resources = new Map(); + for (let count = varInt(map, at); count > 0; count--) { + const id = varInt(map, at); + const offset = varInt(map, at); + const length = varInt(map, at); + varInt(map, at); // uncompressed length + varInt(map, at); // compression type + resources.set(id, { offset, length, tag: tag(map, at.pos) }); + at.pos += 4; + } + + pos = abmp.start + abmp.length; + if (tag(file, pos) !== 'FGEI') { + throw new Error('expected FGEI'); + } + const ilsAt = { pos: pos + 4 }; + varInt(file, ilsAt); + const ilsBody = ilsAt.pos; + const configId = [...resources].find(([, r]) => r.tag === 'DRCF' || r.tag === 'VWCF')?.[0]; + if (configId === undefined) { + return null; + } + let config: Buffer | undefined; + const ils = inflateSync(file.subarray(ilsBody, ilsBody + resources.get(2)!.length)); + for (const ilsAtPos = { pos: 0 }; ilsAtPos.pos < ils.length && !config; ) { + const id = varInt(ils, ilsAtPos); + const length = resources.get(id)?.length ?? 0; + if (id === configId) { + config = ils.subarray(ilsAtPos.pos, ilsAtPos.pos + length); + } + ilsAtPos.pos += length; + } + const { offset, length } = resources.get(configId)!; + config ??= file.subarray(ilsBody + offset, ilsBody + offset + length); + try { + config = inflateSync(config); + } catch { + // Stored uncompressed + } + const [top, left, bottom, right] = [4, 6, 8, 10].map((o) => config.readInt16BE(o)); + return right > left && bottom > top ? { width: right - left, height: bottom - top } : null; +} + /** Reads one entry from a zip, matching its name case-insensitively. */ function readZipEntry(zipPath: string, name: string): Promise { const wanted = name.toLowerCase(); diff --git a/streaming/session-manager/src/main.ts b/streaming/session-manager/src/main.ts index 4113ec5..9398207 100644 --- a/streaming/session-manager/src/main.ts +++ b/streaming/session-manager/src/main.ts @@ -18,7 +18,10 @@ const driver = new DockerDriver({ uid: Number(env('PUID', '1000')), gid: Number(env('PGID', '1000')), }); -const catalog = new GameCatalog(env('FLASHPOINT_PATH', '/flashpoint')); +const catalog = new GameCatalog( + env('FLASHPOINT_PATH', '/flashpoint'), + env('STREAM_PLATFORMS', 'Java,Shockwave,Flash').split(','), +); const sessions = new SessionManager(driver, catalog, Number(env('MAX_SESSIONS', '4'))); await driver.removeAll(); diff --git a/streaming/session-manager/test/games.test.ts b/streaming/session-manager/test/games.test.ts index 0d8b518..5df2584 100644 --- a/streaming/session-manager/test/games.test.ts +++ b/streaming/session-manager/test/games.test.ts @@ -5,21 +5,81 @@ import path from 'node:path'; import { finished } from 'node:stream/promises'; import { test } from 'node:test'; import { DatabaseSync } from 'node:sqlite'; +import { deflateSync } from 'node:zlib'; import yazl from 'yazl'; -import { appletSize, GameCatalog, projectorStageSize } from '../src/games.ts'; +import { + appletSize, + directorStageSize, + embedSize, + GameCatalog, + projectorStageSize, + swfStageSize, +} from '../src/games.ts'; -/** A fake projector holding a Director config chunk with the given stage rect. */ -function projector(tag: 'FCRD' | 'DRCF', rect: [number, number, number, number]): Buffer { +/** A Director config chunk body with the given stage rect. */ +function config(rect: [number, number, number, number]): Buffer { const body = Buffer.alloc(84); body.writeUInt16BE(84, 0); body.writeUInt16BE(0x163c, 2); rect.forEach((value, i) => body.writeInt16BE(value, 4 + i * 2)); + return body; +} + +/** A fake projector holding a Director config chunk with the given stage rect. */ +function projector(tag: 'FCRD' | 'DRCF' | 'VWCF', rect: [number, number, number, number]): Buffer { const header = Buffer.alloc(8); header.write(tag, 0, 'latin1'); if (tag === 'FCRD') header.writeUInt32LE(84, 4); else header.writeUInt32BE(84, 4); // A stray tag with a nonsense size must be skipped - return Buffer.concat([Buffer.from('MZ junk FCRD\xff\xff\xff\xff junk', 'latin1'), header, body]); + return Buffer.concat([Buffer.from('MZ junk FCRD\xff\xff\xff\xff junk', 'latin1'), header, config(rect)]); +} + +function varInt(value: number): Buffer { + const bytes = [value & 0x7f]; + for (value = Math.floor(value / 128); value > 0; value = Math.floor(value / 128)) { + bytes.unshift((value & 0x7f) | 0x80); + } + return Buffer.from(bytes); +} + +/** A big-endian Afterburner (.dcr) movie whose initial load segment holds a zlib-compressed config. */ +function afterburner(configTag: 'DRCF' | 'VWCF', rect: [number, number, number, number]): Buffer { + const compressed = deflateSync(config(rect)); + const resource = (id: number, length: number, tag: string) => + Buffer.concat([varInt(id), varInt(0xffffffff), varInt(length), varInt(length), varInt(0), Buffer.from(tag, 'latin1')]); + const ils = Buffer.concat([varInt(3), compressed]); + const ilsCompressed = deflateSync(ils); + const map = deflateSync( + Buffer.concat([ + varInt(0), + varInt(0), + varInt(2), + resource(2, ilsCompressed.length, 'ILS '), + resource(3, compressed.length, configTag), + ]), + ); + const section = (tag: string, body: Buffer) => Buffer.concat([Buffer.from(tag, 'latin1'), varInt(body.length), body]); + return Buffer.concat([ + Buffer.from('RIFX\0\0\0\0FGDM', 'latin1'), + section('Fver', varInt(0x400)), + section('Fcdr', deflateSync(Buffer.alloc(2))), + section('ABMP', Buffer.concat([varInt(0), varInt(0), map])), + Buffer.from('FGEI', 'latin1'), + varInt(0), + ilsCompressed, + ]); +} + +/** A SWF header with the given stage size, uncompressed or zlib-compressed. */ +function swf(signature: 'FWS' | 'CWS', width: number, height: number): Buffer { + const bits = 16; + const fields = [0, width * 20, 0, height * 20].map((v) => v.toString(2).padStart(bits, '0')).join(''); + const rectBits = (bits.toString(2).padStart(5, '0') + fields).padEnd(Math.ceil((5 + 4 * bits) / 8) * 8, '0'); + const rect = Buffer.from(rectBits.match(/.{8}/g)!.map((b) => parseInt(b, 2))); + const body = Buffer.concat([rect, Buffer.alloc(32)]); + const header = Buffer.from(`${signature}\x0a\0\0\0\0`, 'latin1'); + return Buffer.concat([header, signature === 'CWS' ? deflateSync(body) : body]); } test('appletSize reads the first applet tag', () => { @@ -29,46 +89,103 @@ test('appletSize reads the first applet tag', () => { assert.equal(appletSize(''), null); }); +test('embedSize reads the largest embed or object', () => { + assert.deepEqual( + embedSize(''), + { width: 640, height: 480 }, + ); + assert.equal(embedSize(''), null); +}); + test('projectorStageSize reads the stage rect from either byte order', () => { assert.deepEqual(projectorStageSize(projector('FCRD', [108, 112, 708, 912])), { width: 800, height: 600 }); assert.deepEqual(projectorStageSize(projector('DRCF', [0, 0, 480, 640])), { width: 640, height: 480 }); + assert.deepEqual(projectorStageSize(projector('VWCF', [0, 0, 300, 400])), { width: 400, height: 300 }); assert.equal(projectorStageSize(Buffer.from('MZ no config here')), null); assert.equal(projectorStageSize(Buffer.from('MZ truncated FCRD\x54')), null); }); -test('GameCatalog sizes streamable games from their GameZIP or htdocs', async () => { +test('directorStageSize reads Afterburner-compressed Shockwave movies', () => { + assert.deepEqual(directorStageSize(afterburner('DRCF', [0, 0, 425, 500])), { width: 500, height: 425 }); + assert.deepEqual(directorStageSize(afterburner('VWCF', [0, 0, 300, 400])), { width: 400, height: 300 }); + assert.equal(directorStageSize(Buffer.from('RIFX\0\0\0\0FGDMgarbage', 'latin1')), null); +}); + +test('swfStageSize reads uncompressed and zlib-compressed headers', () => { + assert.deepEqual(swfStageSize(swf('FWS', 500, 375)), { width: 500, height: 375 }); + assert.deepEqual(swfStageSize(swf('CWS', 550, 400)), { width: 550, height: 400 }); + assert.equal(swfStageSize(Buffer.from('ZWS lzma')), null); +}); + +test('GameCatalog streams allowed platforms whose launcher resolves, sized from their content', async () => { const root = mkdtempSync(path.join(tmpdir(), 'fp-')); - mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true }); - mkdirSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game'), { recursive: true }); - mkdirSync(path.join(root, 'LocalGames', 'aus'), { recursive: true }); - writeFileSync(path.join(root, 'LocalGames', 'aus', 'Game.exe'), projector('FCRD', [108, 112, 708, 912])); - writeFileSync(path.join(root, 'Legacy', 'htdocs', 'example.com', 'my game', 'index.html'), ''); + const file = (relative: string, data: string | Buffer = '') => { + mkdirSync(path.dirname(path.join(root, relative)), { recursive: true }); + writeFileSync(path.join(root, relative), data); + }; + file('Data/execs.json', JSON.stringify({ execs: [{ win32: 'FPSoftware\\Flash\\flashplayer_32_sa.exe', linux: 'FPSoftware/Flash/flash32.sh' }] })); + file('FPSoftware/Flash/flash32.sh'); + file('FPSoftware/Shockwave/PJ101/SPR.exe'); + file('FPSoftware/fpnavigator-portable/FPNavigator.exe'); + file('FPSoftware/OldCPUSimulator/OldCPUSimulator.exe'); + file('FPSoftware/FlashpointSecurePlayer.sh'); + file('FPSoftware/startUnity.sh'); + file('LocalGames/aus/Game.exe', projector('FCRD', [108, 112, 708, 912])); + file('Legacy/htdocs/example.com/my game/index.html', ''); + file('Legacy/htdocs/example.com/page.html', ''); + file('Legacy/htdocs/example.com/game.dcr', afterburner('DRCF', [0, 0, 425, 500])); + file('Legacy/htdocs/example.com/game.swf', swf('CWS', 500, 375)); + file('Legacy/htdocs/example.com/movie.dir', projector('DRCF', [0, 0, 480, 640])); const zip = new yazl.ZipFile(); zip.addBuffer(Buffer.from(''), 'content/www.popcap.com/Insaniquarium/Java/index.html'); zip.end(); + mkdirSync(path.join(root, 'Data', 'Games'), { recursive: true }); await finished(zip.outputStream.pipe(createWriteStream(path.join(root, 'Data', 'Games', 'zip-1690495544502.zip')))); const db = new DatabaseSync(path.join(root, 'Data', 'flashpoint.sqlite')); db.exec(` - create table game (id, applicationPath, launchCommand, activeDataId); + create table game (id, platformName, applicationPath, launchCommand, activeDataId); create table game_data (id integer primary key, applicationPath, launchCommand, dateAdded); - insert into game values ('zip', 'FPSoftware\\startJavaInBrowser.bat', 'http://www.popcap.com/Insaniquarium/Java/INDEX.html', 1); + insert into game values ('zip', 'Java', 'FPSoftware\\startJavaInBrowser.bat', 'http://www.popcap.com/Insaniquarium/Java/INDEX.html', 1); insert into game_data values (1, '', '', '2023-07-27T22:05:44.502567Z'); - insert into game values ('legacy', 'FPSoftware\\startJava.bat', 'http://example.com/my%20game/', null); - insert into game values ('missing', 'FPSoftware\\startJava.bat', 'http://example.com/none.html', null); - insert into game values ('flash', 'FPSoftware\\Flash\\flashplayer_32_sa.exe', 'http://example.com/a.swf', null); - insert into game values ('exe', 'LocalGames\\aus\\Game.exe', '', null); - insert into game values ('missing-exe', 'LocalGames\\gone\\Game.exe', '', null); + insert into game values ('legacy', 'Java', 'FPSoftware\\startJava.bat', 'http://example.com/my%20game/', null); + insert into game values ('missing', 'Java', 'FPSoftware\\startJava.bat', 'http://example.com/none.html', null); + insert into game values ('webstart', 'Java', 'FPSoftware\\startJavaWebStart.bat', 'http://example.com/a.jnlp', null); + insert into game values ('spr', 'Shockwave', 'FPSoftware\\Shockwave\\PJ101\\SPR.exe', 'http://example.com/game.dcr --setTheRunMode "Plugin"', null); + insert into game values ('oldcpu', 'Shockwave', 'FPSoftware\\OldCPUSimulator\\OldCPUSimulator.exe', '-t 366 -sw ..\\Shockwave\\PJ101\\SPR.exe http://example.com/game.dcr', null); + insert into game values ('secure', 'Shockwave', 'FPSoftware\\FlashpointSecurePlayer.exe', 'shockwave256color "http://example.com/movie.dir"', null); + insert into game values ('navigator', 'Shockwave', 'FPSoftware\\fpnavigator-portable\\fpnavigator.exe', 'http://example.com/page.html', null); + insert into game values ('flash', 'Flash', 'FPSoftware\\Flash\\flashplayer_32_sa.exe', 'http://example.com/game.swf', null); + insert into game values ('unity', 'Unity', 'FPSoftware\\startUnity.bat', 'http://example.com/a.unity3d', null); + insert into game values ('no-program', 'Shockwave', 'FPSoftware\\Shockwave\\PJ9\\SPR.exe', 'http://example.com/game.dcr', null); + insert into game values ('exe', 'Shockwave', 'LocalGames\\aus\\Game.exe', '', null); + insert into game values ('missing-exe', 'Shockwave', 'LocalGames\\gone\\Game.exe', '', null); `); db.close(); - const catalog = new GameCatalog(root); - assert.deepEqual(await catalog.streamSize('zip'), { width: 472, height: 354 }); - assert.deepEqual(await catalog.streamSize('legacy'), { width: 300, height: 200 }); - assert.deepEqual(await catalog.streamSize('missing'), { width: 800, height: 600 }); - assert.equal(await catalog.streamSize('flash'), null); - assert.deepEqual(await catalog.streamSize('exe'), { width: 800, height: 600 }); - assert.equal(await catalog.streamSize('missing-exe'), null); - assert.equal(await catalog.streamSize('unknown'), null); + const catalog = new GameCatalog(root, ['Java', 'Shockwave', 'Flash']); + const sizes = Object.fromEntries( + await Promise.all( + ['zip', 'legacy', 'missing', 'webstart', 'spr', 'oldcpu', 'secure', 'navigator', 'flash', 'unity', 'no-program', 'exe', 'missing-exe', 'unknown'].map( + async (id) => [id, await catalog.streamSize(id)], + ), + ), + ); + assert.deepEqual(sizes, { + zip: { width: 472, height: 354 }, + legacy: { width: 300, height: 200 }, + missing: { width: 800, height: 600 }, + webstart: null, + spr: { width: 500, height: 426 }, + oldcpu: { width: 500, height: 426 }, + secure: { width: 640, height: 480 }, + navigator: { width: 528, height: 400 }, + flash: { width: 500, height: 376 }, + unity: null, + 'no-program': null, + exe: { width: 800, height: 600 }, + 'missing-exe': null, + unknown: null, + }); }); diff --git a/streaming/streamer/Dockerfile b/streaming/streamer/Dockerfile index 9edcedf..3a95ceb 100644 --- a/streaming/streamer/Dockerfile +++ b/streaming/streamer/Dockerfile @@ -8,12 +8,15 @@ FROM ghcr.io/linuxserver/baseimage-selkies:debiantrixie RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + cpulimit \ fontconfig \ fonts-dejavu-core \ libasound2-plugins \ libfontconfig1 \ libfreetype6 \ libgl1 \ + libgtk2.0-0t64 \ + libnss3 \ libpulse0 \ libx11-6 \ libxcomposite1 \ @@ -26,7 +29,8 @@ RUN apt-get update && \ libxrender1 \ libxtst6 \ libxxf86vm1 \ - sqlite3 && \ + sqlite3 \ + xdotool && \ rm -rf /var/lib/apt/lists/* COPY root/ / diff --git a/streaming/streamer/README.md b/streaming/streamer/README.md index dcdc970..9cdd0a0 100644 --- a/streaming/streamer/README.md +++ b/streaming/streamer/README.md @@ -2,9 +2,10 @@ Runs a Flashpoint game natively on the server and streams it to the browser with [Selkies](https://github.com/selkies-project/selkies), for platforms that cannot -run in a browser. One container plays one game, set by `GAME_ID`. It supports -Java applets, and Windows games installed under `LocalGames/` (see -[local-games](../local-games/README.md)). +run in a browser. One container plays one game, set by `GAME_ID`. It launches +games the way Flashpoint's Linux launcher does, so Java applets, Shockwave, +Flash and Windows games installed under `LocalGames/` (see +[local-games](../local-games/README.md)) all run. ## Requirements @@ -40,10 +41,17 @@ Selkies restarts the game when it exits (`RESTART_APP`). `appletviewer` lacks two things applets got from the browser's Java plugin, so the image adds them: a `netscape.javascript.JSObject` stand-in whose window has a `document.cookie` that persists to `/config/saves/cookies.txt`, and a policy -allowing HTTP requests through the game server's proxy. The same jar (`applet-support/`) is loaded as a Java agent -that removes appletviewer's menu bar and status line. +allowing HTTP requests through the game server's proxy. The same jar +(`applet-support/`) is loaded as a Java agent that removes appletviewer's menu +bar and status line. -Windows games run under Flashpoint's bundled Wine from a writable copy of the -game's directory. Its `Prefs` directory, where Director projectors save, is -linked into `/config/saves`, so the session manager syncs it to the player's -browser. A player without a save starts from the `Prefs` the game ships with. +Other games go through `fp-command`, which applies the launcher's rules: it maps +the application path through `Data/execs.json`, swaps `.bat` for `.sh`, and runs +`.exe` files under Flashpoint's bundled Wine. Windows programs run from a +writable mirror of the install, since they write beside themselves; a +Director projector's `Prefs`, where it saves, is linked into `/config/saves`, as +is Flash Player's save directory, so the session manager syncs both to the +player's browser. OldCPUSimulator hangs under Wine, so its target runs under +`cpulimit` at the same share of the CPU. Flash projectors switch to full screen, +which hides their menu bar and scales the movie to the display, and Flashpoint +Navigator's toolbars are hidden. diff --git a/streaming/streamer/root/usr/local/bin/fp-command b/streaming/streamer/root/usr/local/bin/fp-command new file mode 100755 index 0000000..c6e3132 --- /dev/null +++ b/streaming/streamer/root/usr/local/bin/fp-command @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Prints, NUL-separated, the working directory and command line the Flashpoint +Linux launcher runs for an application path and launch command. + +Usage: fp-command APPLICATION_PATH LAUNCH_COMMAND (with FP_PATH set) + +Follows the launcher's rules: map the path through Data/execs.json, swap .bat +for .sh, and run .exe files under Wine. An .exe the Linux build replaces with a +script of the same name, like FlashpointSecurePlayer, runs that script. +OldCPUSimulator, which hangs under Wine, is replaced by running its target +under cpulimit at the same share of the host CPU. Exits 1 if the program +doesn't exist. + +With FP_WRITABLE set, an .exe runs from a mirror of FP_PATH under it: symlinks +to the read-only install, except the program's directory, which is copied +since Windows programs write beside themselves. Its Prefs directory, where +Director projectors save, is linked to ~/saves/Prefs, starting from the Prefs it +ships with. Flashpoint Navigator's profile gets a stylesheet hiding its +toolbars. +""" +import json +import os +import shlex +import shutil +import sys + +fp_path = os.environ['FP_PATH'] +app, cmd = sys.argv[1:3] + + +def mapped(app): + try: + with open(os.path.join(fp_path, 'Data', 'execs.json')) as f: + execs = json.load(f)['execs'] + except OSError: + return app + for e in execs: + if e['win32'].lower() == app.lower() and e.get('linux'): + return e['linux'] + return app + + +def resolve(relative): + """Finds a path under FP_PATH case-insensitively, as Windows and Wine would.""" + path = fp_path + for part in relative.replace('\\', '/').split('/'): + if part in ('', '.'): + continue + try: + names = os.listdir(path) + except OSError: + return None + match = next((n for n in names if n == part), None) or next((n for n in names if n.lower() == part.lower()), None) + if match is None: + return None + path = os.path.join(path, match) + return path + + +def split(cmd): + """Splits a launch command the way the launcher's escaped shell command is parsed: + double quotes group, paired single quotes group, and nothing else is special.""" + lexer = shlex.shlex(cmd, posix=True) + lexer.whitespace_split = True + lexer.escape = '' + lexer.escapedquotes = '' + lexer.quotes = '"\'' if cmd.count("'") % 2 == 0 else '"' + return list(lexer) + + +def host_mhz(): + """The CPU clock OldCPUSimulator's target rate is measured against.""" + try: + with open('/proc/cpuinfo') as f: + info = f.read().splitlines() + except OSError: + info = [] + mhz = [float(line.split(':')[1]) for line in info if line.startswith('cpu MHz')] + if mhz: + return max(mhz) + try: + with open('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq') as f: + return int(f.read()) / 1000 + except (OSError, ValueError): + pass + bogomips = [float(line.split(':')[1]) for line in info if line.lower().startswith('bogomips')] + return bogomips[0] / 2 if bogomips else 3000 + + +def old_cpu_simulator(args): + """Translates OldCPUSimulator's '-t RATE [options] -sw SOFTWARE ARGS...'.""" + rate, i = None, 0 + while i < len(args) and args[i] not in ('-sw', '--software'): + if args[i] in ('-t', '--target-rate', '-r', '--refresh-rate'): + if args[i] in ('-t', '--target-rate'): + rate = float(args[i + 1]) + i += 1 + i += 1 + if rate is None or i + 1 >= len(args): + sys.exit(1) + return rate, args[i + 1], args[i + 2:] + + +relative = mapped(app).replace('\\', '/') +args = split(cmd) +limit = None +if relative.lower().endswith('oldcpusimulator/oldcpusimulator.exe'): + rate, software, args = old_cpu_simulator(args) + relative = os.path.normpath(os.path.join(os.path.dirname(relative), software.replace('\\', '/'))) + limit = max(1, round(100 * rate / host_mhz())) +if relative.lower().endswith('.bat'): + relative = relative[:-4] + '.sh' +program = resolve(relative) +if program is None and relative.lower().endswith('.exe'): + program = resolve(relative[:-4] + '.sh') +if program is None or not os.path.isfile(program): + sys.exit(1) + +def writable_copy(program, root): + """Mirrors FP_PATH under root, copying only the program's directory.""" + parts = os.path.relpath(os.path.dirname(program), fp_path).split(os.sep) + src, dst = fp_path, root + for part in parts: + os.makedirs(dst, exist_ok=True) + for name in os.listdir(src): + if name != part and not os.path.lexists(os.path.join(dst, name)): + os.symlink(os.path.join(src, name), os.path.join(dst, name)) + src, dst = os.path.join(src, part), os.path.join(dst, part) + if not os.path.exists(dst): + shutil.copytree(src, dst, symlinks=True) + link_saves(os.path.join(dst, 'Prefs'), os.path.expanduser('~/saves/Prefs')) + profile = os.path.join(dst, 'User', 'flashpointnavigator', 'Profiles', 'Default') + if os.path.isdir(profile): + os.makedirs(os.path.join(profile, 'chrome'), exist_ok=True) + with open(os.path.join(profile, 'chrome', 'userChrome.css'), 'w') as f: + f.write('#toolbar-menubar, #TabsToolbar, #nav-bar, #PersonalToolbar { visibility: collapse !important; }\n') + return os.path.join(dst, os.path.basename(program)) + + +def link_saves(path, saves): + """Replaces path with a link to saves, which starts from path's contents.""" + if os.path.islink(path): + return + os.makedirs(saves, exist_ok=True) + if os.path.isdir(path): + if not os.listdir(saves): + shutil.copytree(path, saves, dirs_exist_ok=True) + shutil.rmtree(path) + os.symlink(saves, path) + + +if program.lower().endswith('.exe') and os.environ.get('FP_WRITABLE'): + program = writable_copy(program, os.environ['FP_WRITABLE']) +argv = [program, *args] +if program.lower().endswith('.exe'): + argv = ['wine', 'start', '/wait', '/unix', *argv] +if limit: + argv = ['cpulimit', '--include-children', '--limit', str(limit), '--', *argv] +sys.stdout.write('\0'.join([os.path.dirname(program), *argv]) + '\0') diff --git a/streaming/streamer/root/usr/local/bin/fp-launch b/streaming/streamer/root/usr/local/bin/fp-launch index c05e90f..e94ec9d 100755 --- a/streaming/streamer/root/usr/local/bin/fp-launch +++ b/streaming/streamer/root/usr/local/bin/fp-launch @@ -38,24 +38,18 @@ case ${app,,} in # and a window holding only the applet export JAVA_TOOL_OPTIONS="-javaagent:/opt/flashpoint/applet-support.jar -Djava.security.policy=/opt/flashpoint/applet.policy" FP_PATH=$FP_PATH exec "$FP_PATH/FPSoftware/startJava.sh" "$cmd" ;; - 'localgames\'*.exe) - # Windows games run under Flashpoint's bundled Wine from a writable copy. - # Prefs, where Director projectors save, lives in the synced saves directory. - exe=${app//\\//} - work=$HOME/game - [[ -d $work ]] || cp -r "$FP_PATH/$(dirname "$exe")" "$work" - if [[ ! -L $work/Prefs ]]; then - mkdir -p "$HOME/saves/Prefs" - # Players without a save start from the one the game ships with - if [[ -d $work/Prefs && -z $(ls -A "$HOME/saves/Prefs") ]]; then - cp -r "$work/Prefs/." "$HOME/saves/Prefs" - fi - rm -rf "$work/Prefs" - ln -s "$HOME/saves/Prefs" "$work/Prefs" - fi - export WINEPREFIX=$HOME/.wine WINEDEBUG=-all WINEDLLOVERRIDES='mscoree,mshtml=' - cd "$work" - exec "$FP_PATH/FPSoftware/Wine/bin/wine" "$(basename "$exe")" ;; *) - echo "Unsupported application: $app" >&2; exit 1 ;; + mapfile -d '' argv < <(FP_PATH=$FP_PATH FP_WRITABLE=$HOME/flashpoint fp-command "$app" "$cmd") || true + (( ${#argv[@]} > 1 )) || { echo "Unsupported application: $app" >&2; exit 1; } + export FP_PATH HTTP_PROXY=http://127.0.0.1:22500/ http_proxy=http://127.0.0.1:22500/ + export PATH=$FP_PATH/FPSoftware/Wine/bin:$PATH WINEDEBUG=-all WINEDLLOVERRIDES='mscoree,mshtml=' + # Flash Player keeps saves (SharedObjects) here + mkdir -p "$HOME/saves/Flash_Player" "$HOME/.macromedia" + [[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player" + if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then + # Full screen hides the projector's menu bar and scales the movie to the display + (xdotool search --sync --onlyvisible --name 'Flash Player' windowactivate --sync key ctrl+f) & + fi + cd "${argv[0]}" + exec "${argv[@]:1}" ;; esac