Compare commits

..
Author SHA1 Message Date
branderson e531228fa9 fix(streaming): keep Flash Player's window in place while it's open
Flash Player moves its window back to the top of the screen once the
movie has loaded, bringing the menu bar back. The streamer now checks
the window twice a second and puts it back, logging the first few
times.
2026-10-14 18:10:11 -07:00
branderson d1cebec142 fix(streaming): measure Flash Player's menu bar from the movie's offset
GTK draws the projector's menu bar in its window, not in a child window,
so the previous lookup found nothing and, under set -e and pipefail,
silently ended the block that moves the window. The movie is the
window's largest child, and its offset is the menu bar's height (56px
on flashbox). The block no longer stops on errors, logs what it measured
and where the window ended up, and GAME_WIDTH/GAME_HEIGHT, unused now,
are dropped.
2026-10-14 18:03:52 -07:00
branderson 3c4d3e7216 Merge pull request 'Streaming: hide Flash Player's menu bar without full screen' (#9) from flash-windowed into main
Images / app (push) Successful in 4m53s
Images / session-manager (push) Successful in 1m46s
Images / streamer (push) Successful in 3m31s
Reviewed-on: #9
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:26:37 -07:00
branderson e697638827 Merge pull request 'Carry Flash saves between Ruffle and the original Flash Player' (#8) from flash-save-sync into main
Images / app (push) Successful in 4m55s
Images / session-manager (push) Successful in 1m41s
Images / streamer (push) Successful in 3m31s
Reviewed-on: #8
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 17:26:19 -07:00
branderson 1306a88b0e fix(streaming): hide Flash Player's menu bar without full screen
Some games break in Flash Player's full-screen mode: in Bloons darts
make a sound but never fly. Instead of pressing Ctrl+F, the streamer
stretches the projector's window to the display and moves it up by the
menu bar's height, measured from the window's opening height and the
movie's. The session manager now passes the game's exact size as
GAME_WIDTH/GAME_HEIGHT and rounds only the display size to even.
2026-10-14 17:25:27 -07:00
branderson 28d590ddd2 fix(saves): treat a wrapper's [[DYNAMIC]] save path as the same save
A SWF a wrapper loads from memory saves under [[DYNAMIC]]/<n>, and
MochiAds does that in Ruffle but not in the standalone Flash Player, so
Bloons kept its progress at different paths in each. Saves differing
only by that step are now one save: the stream starts with Ruffle's
copy (the dynamic one when both exist) at every path it has for it, and
only saves the stream changed are copied back, to every Ruffle key for
them.
2026-10-14 17:04:50 -07:00
branderson a236d7a1b0 fix(saves): keep Ruffle's copy of a Flash save current in both directions
The stream starts with Ruffle's copy of each save, but Ruffle starts
first, and a game with progress only in the stream would write a fresh
save there that then replaced it. Before Ruffle starts a game it now
gets the stream's saves it doesn't have yet. This replaces the
first-switch rule, which kept a stale stream save over Ruffle progress.
Moving the Flash content type above GamePlayer's early return also
fixes a conditional hook.
2026-10-14 16:45:47 -07:00
branderson 3d93ef7304 feat(saves): carry Flash saves between Ruffle and the original Flash Player
Ruffle keeps SharedObjects in localStorage and the streamed Flash Player
as .sol files, in the same format under different names. Starting the
stream now adds the game's Ruffle saves, translated to Flash Player's
paths, and stream save updates are copied back into Ruffle's storage.
Root-path saves carry over only when this game made them. A game's
first switch keeps the stream's copy of a save both sides already had.
2026-10-14 16:14:59 -07:00
branderson c5b1f750bc Merge pull request 'Streaming: log session lifecycles and why streamers fail' (#7) from session-manager-logging into main
Images / app (push) Successful in 4m52s
Images / session-manager (push) Successful in 1m44s
Images / streamer (push) Successful in 3m37s
Reviewed-on: #7
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 16:02:27 -07:00
branderson 26f920c0da Merge pull request 'Docs: drop the old /stream/ proxy route when upgrading' (#6) from docs-stream-route into main
Images / app (push) Successful in 4m48s
Images / session-manager (push) Successful in 1m46s
Images / streamer (push) Successful in 3m34s
Reviewed-on: #6
Reviewed-by: branderson <brad@branderson.io>
2026-10-14 16:02:05 -07:00
branderson 9144395201 feat(streaming): log session lifecycles and why streamers fail
The session manager logs each session's creation, readiness time and
removal with its reason. A streamer that exits during startup now fails
the start at once instead of after 60 seconds, and the log carries its
exit code and last output; containers are no longer auto-removed, so
that output survives until teardown. A failed start reaches the app as
a 502.
2026-10-14 00:52:36 -07:00
branderson b982e0a541 docs: tell upgraders to drop their /stream/ proxy route
The upgrade guide still said the /stream/ route was unchanged, but the
app now proxies streams itself and the session manager publishes no
port; an old route times out with a 504.
2026-10-14 00:47:25 -07:00
branderson 73d2f2405a Merge pull request 'CI: install the Docker CLI, allow a registry host override' (#5) from ci-docker-cli into main
Images / app (push) Successful in 5m26s
Images / session-manager (push) Successful in 1m51s
Images / streamer (push) Successful in 6m40s
Reviewed-on: #5
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 23:24:17 -07:00
18 changed files with 693 additions and 63 deletions
+6 -1
View File
@@ -33,4 +33,9 @@ What changes:
- **Streaming config.** Streaming is now part of the same compose file. Replace
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
and `COMPOSE_PROFILES=streaming` (see [Streaming](../10-features/13-streaming.md)).
The `/stream/` proxy route is unchanged.
- **No separate `/stream/` route.** The app now proxies streams to the session
manager itself, and the session manager no longer publishes port 8081.
Remove any `location /stream/` block from your reverse proxy, or streams
will time out with a 504. Move its WebSocket and timeout settings to the
location that serves the app (see
[Reverse proxy](../10-features/13-streaming.md#reverse-proxy)).
+5
View File
@@ -106,6 +106,11 @@ reachable only from the session manager.
## Troubleshooting
`docker compose logs session-manager` shows each session as it's created,
becomes ready (with how long it took) and is removed, with the reason. When a
streamer fails to start, the log says why: its exit code and its last lines of
output. The app then reports "game failed to start" to the player.
- **No "Original Flash Player" button, or "needs the Flashpoint Launcher" on
Java and Shockwave games.** The session manager isn't running, the tokens
don't match, or it can't resolve the game's launcher; check that Infinity's
+39
View File
@@ -22,6 +22,45 @@ The app works it out by watching instead: whatever changes while a game is
playing belongs to that game, and it remembers that for next time. The app's own
storage is never included.
## Ruffle and the original Flash Player
A Flash game's saves carry over when switching between Ruffle and the original
Flash Player (streamed). Both store SharedObjects in the same `.sol` format,
under different names:
- Ruffle keeps them in localStorage, keyed by the site's host and the path it
loaded the SWF from.
- Flash Player keeps them as files under `Flash_Player/#SharedObjects/` in the
streamed game's saves, keyed by the SWF's own host and path.
Starting the original Flash Player adds the game's Ruffle saves to the files the
stream starts with. While it plays, its saves are copied back into Ruffle's
storage as they change. Saves made with a `localPath` of `/` are shared by
every game in Ruffle, so only those seen changing while this game ran are
carried over.
Ruffle's copy of a save is kept up to date in both directions, so it's the one
the stream starts with:
- Before Ruffle starts a game, it's given any of the stream's saves it doesn't
have yet, such as progress made only in the stream. The game then loads that
progress instead of starting a fresh save, which would otherwise replace the
stream's progress on the next switch. Saves Ruffle already has are left alone.
- While the stream plays, the saves it changes are copied into Ruffle.
Some games save under a different path in each player. A SWF that a wrapper
(such as MochiAds) loads from memory saves under its loader's path plus
`[[DYNAMIC]]/<n>`, and a wrapper may do that in Ruffle but not in the
standalone Flash Player. Bloons, for example, keeps its progress under
`bloons.swf/[[DYNAMIC]]/1/` in Ruffle and under `bloons.swf/` in Flash Player.
Saves that differ only by that step are treated as one save:
- When the stream starts, a save is written to every path the stream has for
it. If Ruffle has both a dynamic and a plain copy, the dynamic one is used.
- A save the stream changes is copied to every key Ruffle has for it.
A stream save replaced this way is still in the account's backups.
## Sync
Before a game starts, the app compares the browser's save with the account's
@@ -17,14 +17,15 @@ const flashGame = {
describe('GamePlayer player choice', () => {
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
render(<GamePlayer {...flashGame} streamable />);
expect(screen.getByText('ruffle')).toBeInTheDocument();
// Ruffle starts once any streamed saves have been handed to it
expect(await screen.findByText('ruffle')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
expect(screen.getByText('stream')).toBeInTheDocument();
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
expect(screen.getByText('ruffle')).toBeInTheDocument();
expect(await screen.findByText('ruffle')).toBeInTheDocument();
});
it('offers no choice when the game cannot be streamed', () => {
+42 -18
View File
@@ -14,6 +14,7 @@ import { RufflePlayer } from './RufflePlayer';
import { StreamPlayer } from './StreamPlayer';
import { SavesDialog } from './SavesDialog';
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
import { addStreamSavesToRuffle } from '@/lib/saves/flashSaves';
import { useAuthStore } from '@/store/auth';
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
import { PlatformIcon } from '@/components/ui/platform-icon';
@@ -137,6 +138,41 @@ function GamePlayerInternal({
}
}, [contentUrl, platform]);
// Determine Flash content type: direct SWF vs HTML wrapper
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
const flashContentType = useMemo(() => {
if (platform !== 'Flash' || !contentUrl) return null;
const urlPath = contentUrl.toLowerCase();
const isSwfUrl = urlPath.includes('.swf');
const isHtmlWrapper =
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
if (isSwfUrl && !isHtmlWrapper) {
return 'swf' as const;
}
return 'html-wrapper' as const;
}, [platform, contentUrl]);
// Before Ruffle starts, it gets any saves the streamed Flash Player made that
// it doesn't have yet. Otherwise a game played only streamed would start from
// scratch here, and that fresh save would replace its progress on the next switch.
const ruffleSwf = gameId && flashContentType === 'swf' && contentUrl ? contentUrl : null;
const [ruffleSavesEpoch, setRuffleSavesEpoch] = useState<number | null>(null);
useEffect(() => {
if (!saveSync.ready || !ruffleSwf || !gameId) return;
let cancelled = false;
addStreamSavesToRuffle(gameId, ruffleSwf)
.catch((error) => logger.error('[GamePlayer] Failed to copy stream saves to Ruffle:', error))
.finally(() => {
if (!cancelled) setRuffleSavesEpoch(saveSync.epoch);
});
return () => {
cancelled = true;
};
}, [saveSync.ready, saveSync.epoch, ruffleSwf, gameId]);
const ruffleSavesReady = !ruffleSwf || ruffleSavesEpoch === saveSync.epoch;
const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
@@ -178,22 +214,6 @@ function GamePlayerInternal({
const isAspectVideo = height === 'aspect-video';
// Determine Flash content type: direct SWF vs HTML wrapper
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
const flashContentType = useMemo(() => {
if (platform !== 'Flash' || !contentUrl) return null;
const urlPath = contentUrl.toLowerCase();
const isSwfUrl = urlPath.includes('.swf');
const isHtmlWrapper =
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
if (isSwfUrl && !isHtmlWrapper) {
return 'swf' as const;
}
return 'html-wrapper' as const;
}, [platform, contentUrl]);
return (
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
{showControls && (isFullscreen || !renderControls) ? (
@@ -327,7 +347,7 @@ function GamePlayerInternal({
}
}
>
{!saveSync.ready ? (
{!saveSync.ready || (!isStreamed && !ruffleSavesReady) ? (
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
<Loader2 className="animate-spin" aria-hidden="true" />
<span>Loading your save...</span>
@@ -336,7 +356,11 @@ function GamePlayerInternal({
<Fragment key={saveSync.epoch}>
{/* Other platforms - stream from the server */}
{isStreamed && gameId ? (
<StreamPlayer gameId={gameId} title={title} />
<StreamPlayer
gameId={gameId}
title={title}
swfUrl={flashContentType === 'swf' ? contentUrl : undefined}
/>
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
<RufflePlayer
swfUrl={contentUrl}
@@ -2,12 +2,15 @@ import { useEffect, useState } from 'react';
import { AlertCircle, Loader2 } from 'lucide-react';
import { gamesApi } from '@/lib/api/games';
import { logger } from '@/lib/logger';
import { flashSaveContext, mirrorToRuffle, withRuffleSaves } from '@/lib/saves/flashSaves';
import { loadSaves, storeSaves } from '@/lib/streamSaves';
import { getErrorMessage } from '@/types/api-error';
export interface StreamPlayerProps {
gameId: string;
title: string;
/** For a Flash game Ruffle can also play: its SWF, so saves carry between the two */
swfUrl?: string;
}
type StreamState =
@@ -19,16 +22,18 @@ type StreamState =
* Plays a game that can't run in a browser by streaming it from a server-side
* container. Saves are kept in this browser and synced with the container.
*/
export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
export function StreamPlayer({ gameId, title, swfUrl }: StreamPlayerProps) {
const [state, setState] = useState<StreamState>({ status: 'starting' });
useEffect(() => {
let cancelled = false;
let events: EventSource | undefined;
const flash = swfUrl ? flashSaveContext(swfUrl, window.location.hostname) : null;
(async () => {
const session = await gamesApi.createStreamSession(gameId);
const files = await loadSaves(gameId);
const stored = await loadSaves(gameId);
const files = flash ? await withRuffleSaves(gameId, stored, flash) : stored;
const response = await fetch(`${session.path}_fp/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -40,10 +45,14 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
if (cancelled) return;
events = new EventSource(`${session.path}_fp/saves`);
let previous = files;
events.onmessage = (event) => {
storeSaves(gameId, JSON.parse(event.data).files).catch((error) =>
const { files: latest } = JSON.parse(event.data);
storeSaves(gameId, latest).catch((error) =>
logger.error('[StreamPlayer] Failed to store saves:', error)
);
if (flash) mirrorToRuffle(latest, previous, flash);
previous = latest;
};
events.addEventListener('end', () => events?.close());
setState({ status: 'running', path: session.path });
@@ -56,7 +65,7 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
cancelled = true;
events?.close();
};
}, [gameId]);
}, [gameId, swfUrl]);
if (state.status === 'error') {
return (
+163
View File
@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
addStreamSavesToRuffle,
flashSaveContext,
flashToRuffle,
mirrorToRuffle,
ruffleToFlash,
withRuffleSaves,
} from './flashSaves';
const syncState = vi.hoisted(() => ({ keys: [] as string[] }));
const streamSaves = vi.hoisted(() => ({ files: {} as Record<string, string> }));
vi.mock('@/lib/streamSaves', () => ({ loadSaves: async () => streamSaves.files }));
vi.mock('./gameSaves', () => ({
loadSyncState: async (gameId: string) => ({
gameId,
keys: syncState.keys,
databases: [],
syncedHash: null,
savedAt: null,
}),
}));
// Written by Ruffle for SharedObject.getLocal('pathsave') and getLocal('rootsave', '/')
// in a SWF loaded from /game-proxy/http://savetest.example/dir/test.swf
const PATH_KEY = 'arcade.example.com/game-proxy/http://savetest.example/dir/test.swf/pathsave';
const ROOT_KEY = 'arcade.example.com//rootsave';
const PATH_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcGF0aHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
const ROOT_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcm9vdHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
const PATH_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/dir/test.swf/pathsave.sol';
const ROOT_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/rootsave.sol';
const context = flashSaveContext(
'/game-proxy/http://savetest.example/dir/test.swf',
'arcade.example.com'
);
if (!context) throw new Error('expected a context');
beforeEach(() => {
localStorage.clear();
syncState.keys = [];
streamSaves.files = {};
});
describe('flash save names', () => {
it('reads the naming context from a proxied SWF URL only', () => {
expect(context).toEqual({
siteHost: 'arcade.example.com',
proxyRoot: '/game-proxy/http://savetest.example',
swfHost: 'savetest.example',
});
expect(flashSaveContext('/game-zip/abc/test.swf', 'arcade.example.com')).toBeNull();
});
it("maps Ruffle's saves to Flash Player files, root saves only when the game made them", () => {
const entries = { [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL, 'other-game': 'not a save' };
const folder = { 'Flash_Player/#SharedObjects/ABCD1234/other.example/x.sol': 'AL8x' };
expect(ruffleToFlash(entries, context, new Set(), folder)).toEqual({
[PATH_FILE]: PATH_SOL,
});
expect(ruffleToFlash(entries, context, new Set([ROOT_KEY]), folder)).toEqual({
[PATH_FILE]: PATH_SOL,
[ROOT_FILE]: ROOT_SOL,
});
});
it("maps Flash Player files back to Ruffle's keys, ignoring other sites and files", () => {
const files = {
[PATH_FILE]: PATH_SOL,
[ROOT_FILE]: ROOT_SOL,
'Flash_Player/#SharedObjects/ABCD1234/elsewhere.example/x.sol': PATH_SOL,
'Prefs/data.txt': 'eA==',
};
expect(flashToRuffle(files, context)).toEqual({ [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL });
});
});
describe('carrying saves between players', () => {
it("starts the stream with Ruffle's saves, in the stream's own folder", async () => {
localStorage.setItem(PATH_KEY, PATH_SOL);
const files = await withRuffleSaves('game', { 'Prefs/x': 'eA==' }, context);
expect(files).toEqual({
'Prefs/x': 'eA==',
'Flash_Player/#SharedObjects/DOTCOMAR/savetest.example/dir/test.swf/pathsave.sol': PATH_SOL,
});
});
it("replaces the stream's copy of a save with Ruffle's, in the stream's folder", async () => {
localStorage.setItem(PATH_KEY, PATH_SOL);
const streamCopy = { [PATH_FILE]: 'AL8stalecopy', [ROOT_FILE]: ROOT_SOL };
expect(await withRuffleSaves('game', streamCopy, context)).toEqual({
[PATH_FILE]: PATH_SOL,
[ROOT_FILE]: ROOT_SOL,
});
});
it("copies the saves the stream changed into Ruffle's storage, and only those", () => {
localStorage.setItem(ROOT_KEY, 'AL8ruffleprogress');
mirrorToRuffle(
{ [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL },
{ [ROOT_FILE]: ROOT_SOL },
context
);
expect(localStorage.getItem(PATH_KEY)).toBe(PATH_SOL);
expect(localStorage.getItem(ROOT_KEY)).toBe('AL8ruffleprogress');
});
it('gives Ruffle the stream-only saves before it starts, keeping its own', async () => {
window.history.replaceState(null, '', '/browse/game');
const host = window.location.hostname;
streamSaves.files = { [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL };
localStorage.setItem(`${host}//rootsave`, 'AL8ruffleprogress');
await addStreamSavesToRuffle('game', '/game-proxy/http://savetest.example/dir/test.swf');
expect(
localStorage.getItem(`${host}/game-proxy/http://savetest.example/dir/test.swf/pathsave`)
).toBe(PATH_SOL);
expect(localStorage.getItem(`${host}//rootsave`)).toBe('AL8ruffleprogress');
});
});
// Bloons runs inside a MochiAds wrapper: in Ruffle the game saves under
// [[DYNAMIC]]/1/, in the standalone Flash Player under the plain path
describe('saves a wrapper keeps under [[DYNAMIC]] in one player only', () => {
const bloons = flashSaveContext(
'/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf',
'arcade.example.com'
);
if (!bloons) throw new Error('expected a context');
const base =
'arcade.example.com/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf';
const files =
'Flash_Player/#SharedObjects/AQUKE2KN/games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf';
// A fresh profile, and one with levels cleared (shortened)
const FRESH =
'AL8AAABEVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQAAAAAABmxldmVsMQIABWNsZWFyAAAKbGV2ZWxiZXN0MQAAAAAAAAAAAAA=';
const PROGRESS = 'AL8AAAJJVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQ';
it("gives Flash Player's plain path Ruffle's dynamic progress", () => {
const ruffle = {
[`${base}/bloonsProfile`]: FRESH,
[`${base}/[[DYNAMIC]]/1/bloonsProfile`]: PROGRESS,
};
const stream = { [`${files}/bloonsProfile.sol`]: FRESH };
expect(ruffleToFlash(ruffle, bloons, new Set(), stream)).toEqual({
[`${files}/bloonsProfile.sol`]: PROGRESS,
[`${files}/[[DYNAMIC]]/1/bloonsProfile.sol`]: PROGRESS,
});
});
it("brings progress made in Flash Player back to Ruffle's dynamic path", () => {
localStorage.setItem(`${base}/bloonsProfile`, FRESH);
localStorage.setItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`, FRESH);
mirrorToRuffle(
{ [`${files}/bloonsProfile.sol`]: PROGRESS },
{ [`${files}/bloonsProfile.sol`]: FRESH },
bloons
);
expect(localStorage.getItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`)).toBe(PROGRESS);
expect(localStorage.getItem(`${base}/bloonsProfile`)).toBe(PROGRESS);
});
});
+221
View File
@@ -0,0 +1,221 @@
import { loadSyncState } from './gameSaves';
import { loadSaves, type SaveFiles } from '@/lib/streamSaves';
/**
* Ruffle and the original Flash Player (streamed) store SharedObjects in the
* same .sol format, under different names:
*
* - Ruffle, in localStorage, base64-encoded, keyed by the site's host and the
* path it loaded the SWF from: `<site>/game-proxy/http://<host>/<path>/<name>`,
* or `<site>//<name>` for a localPath of "/".
* - Flash Player, as files in the streamed game's saves directory:
* `Flash_Player/#SharedObjects/<folder>/<host>/<path>/<name>.sol`.
*
* A SWF loaded from memory, as by a MochiAds wrapper, saves under its loader's
* path plus `[[DYNAMIC]]/<n>`, and a wrapper may do that in one player and not
* the other: Bloons saves at `bloons.swf/[[DYNAMIC]]/1/` in Ruffle and at
* `bloons.swf/` in Flash Player. Saves differing only by that step are
* treated as one save.
*
* These helpers translate between the two so a game's saves follow it from one
* player to the other.
*/
const SHARED_OBJECTS = 'Flash_Player/#SharedObjects/';
/** Flash Player names this folder randomly when it creates it; an existing one is reused */
const DEFAULT_FOLDER = 'DOTCOMAR';
/** Every .sol file starts with the bytes 00 BF 00, which base64-encode to "AL8" */
const SOL_PREFIX = 'AL8';
export interface FlashSaveContext {
/** Host Ruffle keys saves by: the site's own */
siteHost: string;
/** Path Ruffle loads the game's SWF host under, e.g. /game-proxy/http://example.com */
proxyRoot: string;
/** Host Flash Player keys saves by: the SWF's own */
swfHost: string;
}
/** The naming context for a SWF played through the game proxy, or null for any other URL. */
export function flashSaveContext(swfUrl: string, siteHost: string): FlashSaveContext | null {
const match = swfUrl.match(/^(\/game-proxy\/https?:\/\/([^/]+))\//);
return match ? { siteHost, proxyRoot: match[1], swfHost: match[2] } : null;
}
/**
* A Ruffle key's save as a Flash Player path, `<host>/<path>/<name>.sol`, or
* null if it isn't this game's. Root-path saves are shared by every game, so
* only those in rootKeys count, unless rootKeys is null.
*/
function ruffleKeyToPath(
key: string,
context: FlashSaveContext,
rootKeys: ReadonlySet<string> | null
): string | null {
if (!key.startsWith(`${context.siteHost}/`)) return null;
const rest = key.slice(context.siteHost.length);
const slash = rest.lastIndexOf('/');
const localPath = rest.slice(0, slash) || '/';
const name = rest.slice(slash + 1);
if (localPath === '/') {
return rootKeys === null || rootKeys.has(key) ? `${context.swfHost}/${name}.sol` : null;
}
if (localPath.startsWith(`${context.proxyRoot}/`)) {
return `${context.swfHost}${localPath.slice(context.proxyRoot.length)}/${name}.sol`;
}
return null;
}
/** The Ruffle key for a Flash Player path, `<host>/<path>/<name>.sol`, or null for another host's. */
function pathToRuffleKey(path: string, context: FlashSaveContext): string | null {
const parts = path.split('/');
if (parts.length < 2 || parts[0] !== context.swfHost || !path.endsWith('.sol')) return null;
const name = parts[parts.length - 1].slice(0, -'.sol'.length);
const dirs = parts.slice(1, -1);
return dirs.length
? `${context.siteHost}${context.proxyRoot}/${dirs.join('/')}/${name}`
: `${context.siteHost}//${name}`;
}
/** A save file's folder and path below it, or null for files that aren't SharedObjects. */
function splitSaveFile(file: string): { folder: string; path: string } | null {
if (!file.startsWith(SHARED_OBJECTS) || !file.endsWith('.sol')) return null;
const rest = file.slice(SHARED_OBJECTS.length);
const slash = rest.indexOf('/');
return slash > 0 ? { folder: rest.slice(0, slash), path: rest.slice(slash + 1) } : null;
}
/** A save's path without its [[DYNAMIC]]/<n> steps, which one player may add and the other not. */
function saveIdentity(path: string): string {
return path.replace(/\/\[\[DYNAMIC\]\]\/\d+(?=\/)/g, '');
}
function isDynamic(path: string): boolean {
return saveIdentity(path) !== path;
}
function sharedObjectsFolder(files: SaveFiles): string {
for (const file of Object.keys(files)) {
const split = splitSaveFile(file);
if (split) return split.folder;
}
return DEFAULT_FOLDER;
}
/**
* Flash Player save files for a game's Ruffle SharedObjects. Each save is
* written to its own path and to every path the stream already has for it.
* Where Ruffle has a save under both a [[DYNAMIC]] path and the plain one, the
* dynamic copy is used: that's the game itself, the plain one its wrapper's.
*/
export function ruffleToFlash(
entries: Record<string, string>,
context: FlashSaveContext,
rootKeys: ReadonlySet<string>,
existing: SaveFiles = {}
): SaveFiles {
const folder = sharedObjectsFolder(existing);
const chosen = new Map<string, { value: string; dynamic: boolean }>();
const targets = new Map<string, Set<string>>();
const addTarget = (identity: string, file: string) =>
targets.set(identity, (targets.get(identity) ?? new Set()).add(file));
for (const [key, value] of Object.entries(entries)) {
if (!value.startsWith(SOL_PREFIX)) continue;
const path = ruffleKeyToPath(key, context, rootKeys);
if (path === null) continue;
const identity = saveIdentity(path);
const current = chosen.get(identity);
if (!current || (isDynamic(path) && !current.dynamic)) {
chosen.set(identity, { value, dynamic: isDynamic(path) });
}
addTarget(identity, `${SHARED_OBJECTS}${folder}/${path}`);
}
for (const file of Object.keys(existing)) {
const split = splitSaveFile(file);
if (split && chosen.has(saveIdentity(split.path))) addTarget(saveIdentity(split.path), file);
}
const files: SaveFiles = {};
for (const [identity, { value }] of chosen) {
for (const file of targets.get(identity) ?? []) files[file] = value;
}
return files;
}
/** Ruffle localStorage entries for a game's Flash Player save files, path for path. */
export function flashToRuffle(files: SaveFiles, context: FlashSaveContext): Record<string, string> {
const entries: Record<string, string> = {};
for (const [file, value] of Object.entries(files)) {
const split = splitSaveFile(file);
const key = split && pathToRuffleKey(split.path, context);
if (key) entries[key] = value;
}
return entries;
}
function readLocalStorage(): Record<string, string> {
const entries: Record<string, string> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = key === null ? null : localStorage.getItem(key);
if (key !== null && value !== null) entries[key] = value;
}
return entries;
}
/**
* The save files to start a streamed Flash game with: its stream saves, with
* its Ruffle saves on top. Ruffle is where a game is usually played, and the
* stream's own changes are copied back into Ruffle as they happen, so Ruffle's
* copy is the latest.
*/
export async function withRuffleSaves(
gameId: string,
files: SaveFiles,
context: FlashSaveContext
): Promise<SaveFiles> {
const { keys } = await loadSyncState(gameId);
return { ...files, ...ruffleToFlash(readLocalStorage(), context, new Set(keys), files) };
}
/**
* Copies the saves a streamed game changed since `previous` into Ruffle's
* storage: to the save's own key and to every key Ruffle already has for it.
*/
export function mirrorToRuffle(
files: SaveFiles,
previous: SaveFiles,
context: FlashSaveContext
): void {
const ruffleKeys = Object.keys(readLocalStorage());
for (const [file, value] of Object.entries(files)) {
if (previous[file] === value) continue;
const split = splitSaveFile(file);
const key = split && pathToRuffleKey(split.path, context);
if (!split || !key) continue;
const identity = saveIdentity(split.path);
const aliases = ruffleKeys.filter((k) => {
const path = ruffleKeyToPath(k, context, null);
return path !== null && saveIdentity(path) === identity;
});
for (const target of new Set([key, ...aliases])) {
if (localStorage.getItem(target) !== value) localStorage.setItem(target, value);
}
}
}
/**
* Before Ruffle plays a game, gives it the streamed Flash Player's saves it
* doesn't have yet, so progress made only in the stream isn't started over.
* Saves Ruffle already has are left alone.
*/
export async function addStreamSavesToRuffle(gameId: string, swfUrl: string): Promise<void> {
const context = flashSaveContext(swfUrl, window.location.hostname);
if (!context) return;
for (const [key, value] of Object.entries(flashToRuffle(await loadSaves(gameId), context))) {
if (localStorage.getItem(key) === null) {
localStorage.setItem(key, value);
}
}
}
+57 -9
View File
@@ -25,6 +25,7 @@ export interface DockerDriverOptions {
const LABEL = 'flashpoint.stream-session';
const HOME = '/config';
const READY_TIMEOUT_MS = 60_000;
const LOG_LINES = 30;
export class DockerDriver implements Driver {
private readonly docker = new Docker();
@@ -45,8 +46,9 @@ export class DockerDriver implements Driver {
`PUID=${uid}`,
`PGID=${gid}`,
`SUBFOLDER=${streamPath(id)}`,
`SELKIES_MANUAL_WIDTH=${size.width}`,
`SELKIES_MANUAL_HEIGHT=${size.height}`,
// The game's size, rounded up to even for H.264
`SELKIES_MANUAL_WIDTH=${size.width + (size.width % 2)}`,
`SELKIES_MANUAL_HEIGHT=${size.height + (size.height % 2)}`,
'SELKIES_UI_SHOW_SIDEBAR=false',
'HARDEN_DESKTOP=true',
'RESTART_APP=true',
@@ -56,7 +58,8 @@ export class DockerDriver implements Driver {
Binds: [`${flashpointHostPath}:/flashpoint:ro`],
NetworkMode: network,
ShmSize: 1024 ** 3,
AutoRemove: true,
// Not auto-removed, so a container that crashes keeps its output to
// report; teardown removes it, and removeAll clears leftovers
},
});
}
@@ -67,7 +70,22 @@ export class DockerDriver implements Driver {
await container.putArchive(await toTar(saves, this.options.uid, this.options.gid), { path: HOME });
}
await container.start();
await waitForHttp(this.url(id) + streamPath(id), READY_TIMEOUT_MS);
const url = this.url(id) + streamPath(id);
try {
await waitUntilReady(
() => fetch(url).then((res) => res.ok, () => false),
async () => {
const { State } = await container.inspect();
return State.Running ? null : State.ExitCode;
},
READY_TIMEOUT_MS,
);
} catch (err) {
const output = await container
.logs({ stdout: true, stderr: true, tail: LOG_LINES })
.then((logs) => dockerLogText(logs as unknown as Buffer), () => '');
throw new Error(`${(err as Error).message}${output ? `; last output:\n${output}` : ''}`);
}
}
async getSaves(id: string): Promise<SaveFiles> {
@@ -112,14 +130,44 @@ function containerName(id: string): string {
return `fp-stream-${id}`;
}
async function waitForHttp(url: string, timeoutMs: number): Promise<void> {
/**
* Polls until `ready` is true, failing early once `exitCode` reports that the
* container has stopped (a number) rather than still running (null).
*/
export async function waitUntilReady(
ready: () => Promise<boolean>,
exitCode: () => Promise<number | null>,
timeoutMs: number,
intervalMs = 500,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const ok = await fetch(url).then((res) => res.ok, () => false);
if (ok) {
if (await ready()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 500));
const code = await exitCode();
if (code !== null) {
throw new Error(`streamer exited with code ${code}`);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
throw new Error(`timed out waiting for ${url}`);
throw new Error(`streamer not ready after ${timeoutMs / 1000}s`);
}
/**
* Turns Docker's log output into text. Without a TTY, each chunk is framed by
* an 8-byte header: stream type, three zero bytes, then the big-endian length.
*/
export function dockerLogText(logs: Buffer): string {
const framed = logs.length >= 8 && logs[0] <= 2 && logs[1] === 0 && logs[2] === 0 && logs[3] === 0;
if (!framed) {
return logs.toString('utf8').trimEnd();
}
const chunks: Buffer[] = [];
for (let i = 0; i + 8 <= logs.length; ) {
const length = logs.readUInt32BE(i + 4);
chunks.push(logs.subarray(i + 8, i + 8 + length));
i += 8 + length;
}
return Buffer.concat(chunks).toString('utf8').trimEnd();
}
+6 -11
View File
@@ -59,7 +59,7 @@ export class GameCatalog {
return null;
}
const page = await this.readContent(gameId, row, row.cmd).catch(() => null);
return even((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE);
return ((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE);
}
const program = await this.launchProgram(row);
if (!program) {
@@ -68,19 +68,19 @@ export class GameCatalog {
const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0];
if (!url) {
const exe = await readFile(program).catch(() => null);
return even((exe && projectorStageSize(exe)) ?? DEFAULT_SIZE);
return ((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);
return ((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);
// The exact stage, so the movie fills the display without scaling
return ((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);
return (embed ? { width: embed.width + 2 * PAGE_MARGIN, height: embed.height + 2 * PAGE_MARGIN } : BROWSER_SIZE);
}
/**
@@ -151,11 +151,6 @@ async function resolveCaseInsensitive(root: string, relative: string): Promise<s
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);
+20 -9
View File
@@ -12,6 +12,9 @@ export interface SessionTimings {
savesPollMs: number;
}
/** Where lifecycle events and failures are reported. */
export type Log = Pick<Console, 'log' | 'error'>;
export const DEFAULT_TIMINGS: SessionTimings = {
connectTimeoutMs: 120_000,
idleGraceMs: 30_000,
@@ -46,17 +49,20 @@ export class SessionManager {
private readonly catalog: Pick<GameCatalog, 'streamSize'>;
private readonly maxSessions: number;
private readonly timings: SessionTimings;
private readonly log: Log;
constructor(
driver: Driver,
catalog: Pick<GameCatalog, 'streamSize'>,
maxSessions: number,
timings: SessionTimings = DEFAULT_TIMINGS,
log: Log = console,
) {
this.driver = driver;
this.catalog = catalog;
this.maxSessions = maxSessions;
this.timings = timings;
this.log = log;
}
async create(gameId: string): Promise<{ id: string; size: Size }> {
@@ -76,7 +82,8 @@ export class SessionManager {
this.sessions.delete(id);
throw err;
}
this.scheduleTeardown(session, this.timings.connectTimeoutMs);
this.log.log(`Session ${id}: created for game ${gameId} at ${size.width}x${size.height}`);
this.scheduleTeardown(session, this.timings.connectTimeoutMs, 'nobody connected');
return { id, size };
}
@@ -92,13 +99,16 @@ export class SessionManager {
}
session.state = 'starting';
session.saves = canonical(saves);
const started = Date.now();
try {
await this.driver.start(id, saves);
} catch (err) {
await this.teardown(id);
throw err;
this.log.error(`Session ${id}: failed to start: ${(err as Error).message}`);
await this.teardown(id, 'failed to start');
throw new SessionError(502, 'game failed to start');
}
session.state = 'running';
this.log.log(`Session ${id}: ready in ${((Date.now() - started) / 1000).toFixed(1)}s`);
this.startPolling(session);
}
@@ -113,7 +123,7 @@ export class SessionManager {
socketClosed(id: string): void {
const session = this.sessions.get(id);
if (session && --session.sockets === 0) {
this.scheduleTeardown(session, this.timings.idleGraceMs);
this.scheduleTeardown(session, this.timings.idleGraceMs, 'player left');
}
}
@@ -125,11 +135,12 @@ export class SessionManager {
return () => session.listeners.delete(listener);
}
async teardown(id: string): Promise<void> {
async teardown(id: string, reason = 'requested'): Promise<void> {
const session = this.sessions.get(id);
if (!session) {
return;
}
this.log.log(`Session ${id}: removing (${reason})`);
this.sessions.delete(id);
clearTimeout(session.teardownTimer);
clearInterval(session.pollTimer);
@@ -143,7 +154,7 @@ export class SessionManager {
}
async teardownAll(): Promise<void> {
await Promise.all([...this.sessions.keys()].map((id) => this.teardown(id)));
await Promise.all([...this.sessions.keys()].map((id) => this.teardown(id, 'shutting down')));
}
private get(id: string): Session {
@@ -154,16 +165,16 @@ export class SessionManager {
return session;
}
private scheduleTeardown(session: Session, delayMs: number): void {
private scheduleTeardown(session: Session, delayMs: number, reason: string): void {
clearTimeout(session.teardownTimer);
session.teardownTimer = setTimeout(() => {
this.teardown(session.id).catch((err) => console.error(`Failed to tear down ${session.id}:`, err));
this.teardown(session.id, reason).catch((err) => this.log.error(`Session ${session.id}: failed to remove:`, err));
}, delayMs);
}
private startPolling(session: Session): void {
session.pollTimer = setInterval(() => {
this.pollSaves(session).catch((err) => console.error(`Failed to read saves for ${session.id}:`, err));
this.pollSaves(session).catch((err) => this.log.error(`Session ${session.id}: failed to read saves:`, err));
}, this.timings.savesPollMs);
}
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { dockerLogText, waitUntilReady } from '../src/docker.ts';
test('waitUntilReady resolves once the streamer answers', async () => {
let polls = 0;
await waitUntilReady(async () => ++polls >= 3, async () => null, 1000, 1);
assert.equal(polls, 3);
});
test('waitUntilReady fails as soon as the streamer exits', async () => {
const started = Date.now();
await assert.rejects(
waitUntilReady(async () => false, async () => 139, 5000, 1),
/exited with code 139/,
);
assert.ok(Date.now() - started < 1000);
});
test('waitUntilReady gives up after the timeout', async () => {
await assert.rejects(waitUntilReady(async () => false, async () => null, 30, 1), /not ready after/);
});
test('dockerLogText reads framed and plain log output', () => {
const frame = (stream: number, text: string) => {
const header = Buffer.alloc(8);
header[0] = stream;
header.writeUInt32BE(Buffer.byteLength(text), 4);
return Buffer.concat([header, Buffer.from(text)]);
};
assert.equal(dockerLogText(Buffer.concat([frame(1, 'starting\n'), frame(2, 'boom\n')])), 'starting\nboom');
assert.equal(dockerLogText(Buffer.from('plain output\n')), 'plain output');
});
+12
View File
@@ -9,6 +9,8 @@ import type { SaveFiles } from '../src/saves.ts';
export class FakeDriver implements Driver {
readonly containers = new Map<string, { gameId: string; size: Size; saves: SaveFiles; started: boolean }>();
readonly removed: string[] = [];
/** Makes start() fail with this error, as a crashing streamer would */
failStart?: Error;
private server?: http.Server;
async listen(): Promise<void> {
@@ -27,6 +29,9 @@ export class FakeDriver implements Driver {
}
async start(id: string, saves: SaveFiles): Promise<void> {
if (this.failStart) {
throw this.failStart;
}
Object.assign(this.containers.get(id)!, { saves: { ...saves }, started: true });
}
@@ -52,6 +57,13 @@ export const catalog = {
},
};
/** Collects log lines instead of printing them */
export class MemoryLog {
readonly lines: string[] = [];
log = (...args: unknown[]) => void this.lines.push(args.join(' '));
error = (...args: unknown[]) => void this.lines.push(args.join(' '));
}
export const fastTimings = { connectTimeoutMs: 300, idleGraceMs: 100, savesPollMs: 20 };
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+3 -3
View File
@@ -177,11 +177,11 @@ test('GameCatalog streams allowed platforms whose launcher resolves, sized from
legacy: { width: 300, height: 200 },
missing: { width: 800, height: 600 },
webstart: null,
spr: { width: 500, height: 426 },
oldcpu: { width: 500, height: 426 },
spr: { width: 500, height: 425 },
oldcpu: { width: 500, height: 425 },
secure: { width: 640, height: 480 },
navigator: { width: 528, height: 400 },
flash: { width: 500, height: 376 },
flash: { width: 500, height: 375 },
unity: null,
'no-program': null,
exe: { width: 800, height: 600 },
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { SessionError, SessionManager } from '../src/sessions.ts';
import { catalog, FakeDriver, fastTimings, sleep } from './fakes.ts';
import { catalog, FakeDriver, fastTimings, MemoryLog, sleep } from './fakes.ts';
test('rejects unstreamable games and enforces the session cap', async () => {
const sessions = new SessionManager(new FakeDriver(), catalog, 1, fastTimings);
@@ -58,3 +58,27 @@ test('starts with the given saves, pushes changes, and ends with a final push',
await sessions.teardown(id);
assert.deepEqual(seen, [{ 'cookies.txt': 'YQ==' }, { 'cookies.txt': 'Yg==' }, { 'cookies.txt': 'Yw==' }, null]);
});
test('logs a session from creation to removal', async () => {
const log = new MemoryLog();
const sessions = new SessionManager(new FakeDriver(), catalog, 4, fastTimings, log);
const { id } = await sessions.create('java');
await sessions.start(id, {});
await sessions.teardown(id, 'player left');
assert.equal(log.lines.length, 3);
assert.match(log.lines[0], new RegExp(`Session ${id}: created for game java at 472x354`));
assert.match(log.lines[1], /ready in \d+\.\ds/);
assert.match(log.lines[2], /removing \(player left\)/);
});
test('reports a streamer that fails to start, and removes it', async () => {
const driver = new FakeDriver();
driver.failStart = new Error('streamer exited with code 1; last output:\nno display');
const log = new MemoryLog();
const sessions = new SessionManager(driver, catalog, 4, fastTimings, log);
const { id } = await sessions.create('java');
await assert.rejects(sessions.start(id, {}), (e: SessionError) => e.status === 502);
assert.equal(sessions.has(id), false);
assert.deepEqual(driver.removed, [id]);
assert.ok(log.lines.some((line) => line.includes('failed to start: streamer exited with code 1') && line.includes('no display')));
});
+1
View File
@@ -30,6 +30,7 @@ RUN apt-get update && \
libxtst6 \
libxxf86vm1 \
sqlite3 \
x11-utils \
xdotool && \
rm -rf /var/lib/apt/lists/*
+8 -3
View File
@@ -52,6 +52,11 @@ 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.
`cpulimit` at the same share of the CPU. A Flash projector's window is
stretched to the display and moved up by its menu bar's height, which hides the
menu bar. GTK draws the menu bar in the window itself and the movie in a child
window below it, so the movie's offset, read with `xwininfo`, is the menu bar's
height. The window is made override-redirect first, since the window manager
keeps windows on screen, and is kept in place while it's open, since Flash
Player moves it back once the movie has loaded. Its full-screen mode isn't used, since some games, such
as Bloons, don't play right in it. Flashpoint Navigator's toolbars are hidden.
@@ -47,8 +47,42 @@ case ${app,,} in
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) &
# Hide the projector's menu bar by moving it above the display, and fill
# the display with the movie. Not full screen, which some games, such as
# Bloons, don't play right in. The window manager keeps windows on
# screen, so the window is taken out of its hands first.
(
set +eo pipefail
wid=$(xdotool search --sync --onlyvisible --name 'Flash Player' | head -1)
read -r width height < <(xdotool getdisplaygeometry)
# GTK draws the menu bar in the window itself, and the movie in the
# largest child window, below it: the movie's offset is the menu bar's height
menu=$(xwininfo -children -id "$wid" | awk '
match($0, /[0-9]+x[0-9]+\+-?[0-9]+\+-?[0-9]+/) {
split(substr($0, RSTART, RLENGTH), g, /[x+]/)
if (g[1] * g[2] > area) { area = g[1] * g[2]; top = g[4] }
}
END { print top + 0 }')
((menu >= 0 && menu < height)) || menu=0
echo "Flash Player window $wid: menu bar ${menu}px, display ${width}x${height}"
xdotool windowunmap --sync "$wid"
xdotool set_window --overrideredirect 1 "$wid"
xdotool windowmap --sync "$wid"
place() { xdotool windowsize "$wid" "$width" $((height + menu)) windowmove "$wid" 0 $((-menu)); }
place
xdotool windowfocus "$wid"
# Flash Player moves its window back once the movie has loaded, so it's
# kept in place for as long as it's open
moves=0
while geometry=$(xdotool getwindowgeometry --shell "$wid" 2>/dev/null); do
eval "$geometry"
if ((X != 0 || Y != -menu || WIDTH != width || HEIGHT != height + menu)); then
((++moves <= 3)) && echo "Flash Player window moved to $X,$Y (${WIDTH}x${HEIGHT}); putting it back"
place
fi
sleep 0.5
done
) &
fi
cd "${argv[0]}"
exec "${argv[@]:1}" ;;