Merge pull request 'Restore Flash and Shockwave streaming on main' (#7) from flash-shockwave-streaming into main
Reviewed-on: branderson/flashpoint-ultimate-server#7 Reviewed-by: branderson <brad@branderson.io>
This commit is contained in:
@@ -50,6 +50,12 @@ in secure contexts.
|
||||
| `GET /stream/<id>/_fp/saves` | browser | Server-sent events carrying `{files}` whenever saves change, then `end` |
|
||||
| `/stream/<id>/*` | 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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<string>;
|
||||
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<Size | null> {
|
||||
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<Buffer | null> {
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
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<Buffer | null> {
|
||||
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<string | null> {
|
||||
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(/<applet\b[^>]*>/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<number, { offset: number; length: number; tag: string }>();
|
||||
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<Buffer | null> {
|
||||
const wanted = name.toLowerCase();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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('<embed src="game.swf" width="550" height="400">'), null);
|
||||
});
|
||||
|
||||
test('embedSize reads the largest embed or object', () => {
|
||||
assert.deepEqual(
|
||||
embedSize('<object width="640" height="480"><embed src="a.dcr" width=640 height=480></object><embed width=10 height=10>'),
|
||||
{ width: 640, height: 480 },
|
||||
);
|
||||
assert.equal(embedSize('<embed src="a.swf" width="100%" height="100%">'), 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'), '<applet width=300 height=200>');
|
||||
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', '<applet width=300 height=200>');
|
||||
file('Legacy/htdocs/example.com/page.html', '<embed src="game.dcr" width="512" height="384">');
|
||||
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('<applet code="x" width="472" height="354">'), '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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/ /
|
||||
|
||||
@@ -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.
|
||||
|
||||
+159
@@ -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')
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user