Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e531228fa9 | ||
|
|
d1cebec142 | ||
|
|
3c4d3e7216 | ||
|
|
e697638827 | ||
|
|
1306a88b0e | ||
|
|
28d590ddd2 | ||
|
|
a236d7a1b0 | ||
|
|
3d93ef7304 | ||
|
|
c5b1f750bc | ||
|
|
26f920c0da | ||
|
|
9144395201 | ||
|
|
b982e0a541 | ||
|
|
73d2f2405a |
@@ -33,4 +33,9 @@ What changes:
|
|||||||
- **Streaming config.** Streaming is now part of the same compose file. Replace
|
- **Streaming config.** Streaming is now part of the same compose file. Replace
|
||||||
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
|
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
|
||||||
and `COMPOSE_PROFILES=streaming` (see [Streaming](../10-features/13-streaming.md)).
|
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)).
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ reachable only from the session manager.
|
|||||||
|
|
||||||
## Troubleshooting
|
## 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
|
- **No "Original Flash Player" button, or "needs the Flashpoint Launcher" on
|
||||||
Java and Shockwave games.** The session manager isn't running, the tokens
|
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
|
don't match, or it can't resolve the game's launcher; check that Infinity's
|
||||||
|
|||||||
@@ -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
|
playing belongs to that game, and it remembers that for next time. The app's own
|
||||||
storage is never included.
|
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
|
## Sync
|
||||||
|
|
||||||
Before a game starts, the app compares the browser's save with the account's
|
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', () => {
|
describe('GamePlayer player choice', () => {
|
||||||
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
|
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
|
||||||
render(<GamePlayer {...flashGame} streamable />);
|
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' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
|
||||||
expect(screen.getByText('stream')).toBeInTheDocument();
|
expect(screen.getByText('stream')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
|
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
|
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', () => {
|
it('offers no choice when the game cannot be streamed', () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { RufflePlayer } from './RufflePlayer';
|
|||||||
import { StreamPlayer } from './StreamPlayer';
|
import { StreamPlayer } from './StreamPlayer';
|
||||||
import { SavesDialog } from './SavesDialog';
|
import { SavesDialog } from './SavesDialog';
|
||||||
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
|
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
|
||||||
|
import { addStreamSavesToRuffle } from '@/lib/saves/flashSaves';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
||||||
import { PlatformIcon } from '@/components/ui/platform-icon';
|
import { PlatformIcon } from '@/components/ui/platform-icon';
|
||||||
@@ -137,6 +138,41 @@ function GamePlayerInternal({
|
|||||||
}
|
}
|
||||||
}, [contentUrl, platform]);
|
}, [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 canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
|
||||||
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
|
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
|
||||||
|
|
||||||
@@ -178,22 +214,6 @@ function GamePlayerInternal({
|
|||||||
|
|
||||||
const isAspectVideo = height === 'aspect-video';
|
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 (
|
return (
|
||||||
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
|
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
|
||||||
{showControls && (isFullscreen || !renderControls) ? (
|
{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">
|
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
|
||||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
<span>Loading your save...</span>
|
<span>Loading your save...</span>
|
||||||
@@ -336,7 +356,11 @@ function GamePlayerInternal({
|
|||||||
<Fragment key={saveSync.epoch}>
|
<Fragment key={saveSync.epoch}>
|
||||||
{/* Other platforms - stream from the server */}
|
{/* Other platforms - stream from the server */}
|
||||||
{isStreamed && gameId ? (
|
{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 ? (
|
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
||||||
<RufflePlayer
|
<RufflePlayer
|
||||||
swfUrl={contentUrl}
|
swfUrl={contentUrl}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { useEffect, useState } from 'react';
|
|||||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import { gamesApi } from '@/lib/api/games';
|
import { gamesApi } from '@/lib/api/games';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { flashSaveContext, mirrorToRuffle, withRuffleSaves } from '@/lib/saves/flashSaves';
|
||||||
import { loadSaves, storeSaves } from '@/lib/streamSaves';
|
import { loadSaves, storeSaves } from '@/lib/streamSaves';
|
||||||
import { getErrorMessage } from '@/types/api-error';
|
import { getErrorMessage } from '@/types/api-error';
|
||||||
|
|
||||||
export interface StreamPlayerProps {
|
export interface StreamPlayerProps {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** For a Flash game Ruffle can also play: its SWF, so saves carry between the two */
|
||||||
|
swfUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamState =
|
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
|
* 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.
|
* 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' });
|
const [state, setState] = useState<StreamState>({ status: 'starting' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let events: EventSource | undefined;
|
let events: EventSource | undefined;
|
||||||
|
const flash = swfUrl ? flashSaveContext(swfUrl, window.location.hostname) : null;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const session = await gamesApi.createStreamSession(gameId);
|
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`, {
|
const response = await fetch(`${session.path}_fp/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -40,10 +45,14 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
events = new EventSource(`${session.path}_fp/saves`);
|
events = new EventSource(`${session.path}_fp/saves`);
|
||||||
|
let previous = files;
|
||||||
events.onmessage = (event) => {
|
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)
|
logger.error('[StreamPlayer] Failed to store saves:', error)
|
||||||
);
|
);
|
||||||
|
if (flash) mirrorToRuffle(latest, previous, flash);
|
||||||
|
previous = latest;
|
||||||
};
|
};
|
||||||
events.addEventListener('end', () => events?.close());
|
events.addEventListener('end', () => events?.close());
|
||||||
setState({ status: 'running', path: session.path });
|
setState({ status: 'running', path: session.path });
|
||||||
@@ -56,7 +65,7 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
events?.close();
|
events?.close();
|
||||||
};
|
};
|
||||||
}, [gameId]);
|
}, [gameId, swfUrl]);
|
||||||
|
|
||||||
if (state.status === 'error') {
|
if (state.status === 'error') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ export interface DockerDriverOptions {
|
|||||||
const LABEL = 'flashpoint.stream-session';
|
const LABEL = 'flashpoint.stream-session';
|
||||||
const HOME = '/config';
|
const HOME = '/config';
|
||||||
const READY_TIMEOUT_MS = 60_000;
|
const READY_TIMEOUT_MS = 60_000;
|
||||||
|
const LOG_LINES = 30;
|
||||||
|
|
||||||
export class DockerDriver implements Driver {
|
export class DockerDriver implements Driver {
|
||||||
private readonly docker = new Docker();
|
private readonly docker = new Docker();
|
||||||
@@ -45,8 +46,9 @@ export class DockerDriver implements Driver {
|
|||||||
`PUID=${uid}`,
|
`PUID=${uid}`,
|
||||||
`PGID=${gid}`,
|
`PGID=${gid}`,
|
||||||
`SUBFOLDER=${streamPath(id)}`,
|
`SUBFOLDER=${streamPath(id)}`,
|
||||||
`SELKIES_MANUAL_WIDTH=${size.width}`,
|
// The game's size, rounded up to even for H.264
|
||||||
`SELKIES_MANUAL_HEIGHT=${size.height}`,
|
`SELKIES_MANUAL_WIDTH=${size.width + (size.width % 2)}`,
|
||||||
|
`SELKIES_MANUAL_HEIGHT=${size.height + (size.height % 2)}`,
|
||||||
'SELKIES_UI_SHOW_SIDEBAR=false',
|
'SELKIES_UI_SHOW_SIDEBAR=false',
|
||||||
'HARDEN_DESKTOP=true',
|
'HARDEN_DESKTOP=true',
|
||||||
'RESTART_APP=true',
|
'RESTART_APP=true',
|
||||||
@@ -56,7 +58,8 @@ export class DockerDriver implements Driver {
|
|||||||
Binds: [`${flashpointHostPath}:/flashpoint:ro`],
|
Binds: [`${flashpointHostPath}:/flashpoint:ro`],
|
||||||
NetworkMode: network,
|
NetworkMode: network,
|
||||||
ShmSize: 1024 ** 3,
|
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.putArchive(await toTar(saves, this.options.uid, this.options.gid), { path: HOME });
|
||||||
}
|
}
|
||||||
await container.start();
|
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> {
|
async getSaves(id: string): Promise<SaveFiles> {
|
||||||
@@ -112,14 +130,44 @@ function containerName(id: string): string {
|
|||||||
return `fp-stream-${id}`;
|
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;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const ok = await fetch(url).then((res) => res.ok, () => false);
|
if (await ready()) {
|
||||||
if (ok) {
|
|
||||||
return;
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class GameCatalog {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const page = await this.readContent(gameId, row, row.cmd).catch(() => 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);
|
const program = await this.launchProgram(row);
|
||||||
if (!program) {
|
if (!program) {
|
||||||
@@ -68,19 +68,19 @@ export class GameCatalog {
|
|||||||
const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0];
|
const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0];
|
||||||
if (!url) {
|
if (!url) {
|
||||||
const exe = await readFile(program).catch(() => null);
|
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 content = await this.readContent(gameId, row, url).catch(() => null);
|
||||||
const extension = path.extname(new URL(url).pathname).toLowerCase();
|
const extension = path.extname(new URL(url).pathname).toLowerCase();
|
||||||
if (['.dcr', '.dir', '.dxr', '.cct', '.cst'].includes(extension)) {
|
if (['.dcr', '.dir', '.dxr', '.cct', '.cst'].includes(extension)) {
|
||||||
return even((content && directorStageSize(content)) ?? DEFAULT_SIZE);
|
return ((content && directorStageSize(content)) ?? DEFAULT_SIZE);
|
||||||
}
|
}
|
||||||
if (extension === '.swf') {
|
if (extension === '.swf') {
|
||||||
// The exact stage: in full screen, games that set noScale keep their native size
|
// The exact stage, so the movie fills the display without scaling
|
||||||
return even((content && swfStageSize(content)) ?? DEFAULT_SIZE);
|
return ((content && swfStageSize(content)) ?? DEFAULT_SIZE);
|
||||||
}
|
}
|
||||||
const embed = content && embedSize(content.toString('latin1'));
|
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;
|
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. */
|
/** Reads the stage size from a SWF header: a rect of twips after the 8-byte header. */
|
||||||
export function swfStageSize(swf: Buffer): Size | null {
|
export function swfStageSize(swf: Buffer): Size | null {
|
||||||
const signature = swf.toString('latin1', 0, 3);
|
const signature = swf.toString('latin1', 0, 3);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export interface SessionTimings {
|
|||||||
savesPollMs: number;
|
savesPollMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where lifecycle events and failures are reported. */
|
||||||
|
export type Log = Pick<Console, 'log' | 'error'>;
|
||||||
|
|
||||||
export const DEFAULT_TIMINGS: SessionTimings = {
|
export const DEFAULT_TIMINGS: SessionTimings = {
|
||||||
connectTimeoutMs: 120_000,
|
connectTimeoutMs: 120_000,
|
||||||
idleGraceMs: 30_000,
|
idleGraceMs: 30_000,
|
||||||
@@ -46,17 +49,20 @@ export class SessionManager {
|
|||||||
private readonly catalog: Pick<GameCatalog, 'streamSize'>;
|
private readonly catalog: Pick<GameCatalog, 'streamSize'>;
|
||||||
private readonly maxSessions: number;
|
private readonly maxSessions: number;
|
||||||
private readonly timings: SessionTimings;
|
private readonly timings: SessionTimings;
|
||||||
|
private readonly log: Log;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
driver: Driver,
|
driver: Driver,
|
||||||
catalog: Pick<GameCatalog, 'streamSize'>,
|
catalog: Pick<GameCatalog, 'streamSize'>,
|
||||||
maxSessions: number,
|
maxSessions: number,
|
||||||
timings: SessionTimings = DEFAULT_TIMINGS,
|
timings: SessionTimings = DEFAULT_TIMINGS,
|
||||||
|
log: Log = console,
|
||||||
) {
|
) {
|
||||||
this.driver = driver;
|
this.driver = driver;
|
||||||
this.catalog = catalog;
|
this.catalog = catalog;
|
||||||
this.maxSessions = maxSessions;
|
this.maxSessions = maxSessions;
|
||||||
this.timings = timings;
|
this.timings = timings;
|
||||||
|
this.log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(gameId: string): Promise<{ id: string; size: Size }> {
|
async create(gameId: string): Promise<{ id: string; size: Size }> {
|
||||||
@@ -76,7 +82,8 @@ export class SessionManager {
|
|||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
throw err;
|
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 };
|
return { id, size };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,13 +99,16 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
session.state = 'starting';
|
session.state = 'starting';
|
||||||
session.saves = canonical(saves);
|
session.saves = canonical(saves);
|
||||||
|
const started = Date.now();
|
||||||
try {
|
try {
|
||||||
await this.driver.start(id, saves);
|
await this.driver.start(id, saves);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.teardown(id);
|
this.log.error(`Session ${id}: failed to start: ${(err as Error).message}`);
|
||||||
throw err;
|
await this.teardown(id, 'failed to start');
|
||||||
|
throw new SessionError(502, 'game failed to start');
|
||||||
}
|
}
|
||||||
session.state = 'running';
|
session.state = 'running';
|
||||||
|
this.log.log(`Session ${id}: ready in ${((Date.now() - started) / 1000).toFixed(1)}s`);
|
||||||
this.startPolling(session);
|
this.startPolling(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +123,7 @@ export class SessionManager {
|
|||||||
socketClosed(id: string): void {
|
socketClosed(id: string): void {
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
if (session && --session.sockets === 0) {
|
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);
|
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);
|
const session = this.sessions.get(id);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.log.log(`Session ${id}: removing (${reason})`);
|
||||||
this.sessions.delete(id);
|
this.sessions.delete(id);
|
||||||
clearTimeout(session.teardownTimer);
|
clearTimeout(session.teardownTimer);
|
||||||
clearInterval(session.pollTimer);
|
clearInterval(session.pollTimer);
|
||||||
@@ -143,7 +154,7 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async teardownAll(): Promise<void> {
|
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 {
|
private get(id: string): Session {
|
||||||
@@ -154,16 +165,16 @@ export class SessionManager {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private scheduleTeardown(session: Session, delayMs: number): void {
|
private scheduleTeardown(session: Session, delayMs: number, reason: string): void {
|
||||||
clearTimeout(session.teardownTimer);
|
clearTimeout(session.teardownTimer);
|
||||||
session.teardownTimer = setTimeout(() => {
|
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);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private startPolling(session: Session): void {
|
private startPolling(session: Session): void {
|
||||||
session.pollTimer = setInterval(() => {
|
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);
|
}, 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');
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ import type { SaveFiles } from '../src/saves.ts';
|
|||||||
export class FakeDriver implements Driver {
|
export class FakeDriver implements Driver {
|
||||||
readonly containers = new Map<string, { gameId: string; size: Size; saves: SaveFiles; started: boolean }>();
|
readonly containers = new Map<string, { gameId: string; size: Size; saves: SaveFiles; started: boolean }>();
|
||||||
readonly removed: string[] = [];
|
readonly removed: string[] = [];
|
||||||
|
/** Makes start() fail with this error, as a crashing streamer would */
|
||||||
|
failStart?: Error;
|
||||||
private server?: http.Server;
|
private server?: http.Server;
|
||||||
|
|
||||||
async listen(): Promise<void> {
|
async listen(): Promise<void> {
|
||||||
@@ -27,6 +29,9 @@ export class FakeDriver implements Driver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(id: string, saves: SaveFiles): Promise<void> {
|
async start(id: string, saves: SaveFiles): Promise<void> {
|
||||||
|
if (this.failStart) {
|
||||||
|
throw this.failStart;
|
||||||
|
}
|
||||||
Object.assign(this.containers.get(id)!, { saves: { ...saves }, started: true });
|
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 fastTimings = { connectTimeoutMs: 300, idleGraceMs: 100, savesPollMs: 20 };
|
||||||
|
|
||||||
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|||||||
@@ -177,11 +177,11 @@ test('GameCatalog streams allowed platforms whose launcher resolves, sized from
|
|||||||
legacy: { width: 300, height: 200 },
|
legacy: { width: 300, height: 200 },
|
||||||
missing: { width: 800, height: 600 },
|
missing: { width: 800, height: 600 },
|
||||||
webstart: null,
|
webstart: null,
|
||||||
spr: { width: 500, height: 426 },
|
spr: { width: 500, height: 425 },
|
||||||
oldcpu: { width: 500, height: 426 },
|
oldcpu: { width: 500, height: 425 },
|
||||||
secure: { width: 640, height: 480 },
|
secure: { width: 640, height: 480 },
|
||||||
navigator: { width: 528, height: 400 },
|
navigator: { width: 528, height: 400 },
|
||||||
flash: { width: 500, height: 376 },
|
flash: { width: 500, height: 375 },
|
||||||
unity: null,
|
unity: null,
|
||||||
'no-program': null,
|
'no-program': null,
|
||||||
exe: { width: 800, height: 600 },
|
exe: { width: 800, height: 600 },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import { SessionError, SessionManager } from '../src/sessions.ts';
|
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 () => {
|
test('rejects unstreamable games and enforces the session cap', async () => {
|
||||||
const sessions = new SessionManager(new FakeDriver(), catalog, 1, fastTimings);
|
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);
|
await sessions.teardown(id);
|
||||||
assert.deepEqual(seen, [{ 'cookies.txt': 'YQ==' }, { 'cookies.txt': 'Yg==' }, { 'cookies.txt': 'Yw==' }, null]);
|
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')));
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ RUN apt-get update && \
|
|||||||
libxtst6 \
|
libxtst6 \
|
||||||
libxxf86vm1 \
|
libxxf86vm1 \
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
|
x11-utils \
|
||||||
xdotool && \
|
xdotool && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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,
|
`cpulimit` at the same share of the CPU. A Flash projector's window is
|
||||||
which hides their menu bar and scales the movie to the display, and Flashpoint
|
stretched to the display and moved up by its menu bar's height, which hides the
|
||||||
Navigator's toolbars are hidden.
|
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"
|
mkdir -p "$HOME/saves/Flash_Player" "$HOME/.macromedia"
|
||||||
[[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player"
|
[[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player"
|
||||||
if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then
|
if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then
|
||||||
# Full screen hides the projector's menu bar and scales the movie to the display
|
# Hide the projector's menu bar by moving it above the display, and fill
|
||||||
(xdotool search --sync --onlyvisible --name 'Flash Player' windowactivate --sync key ctrl+f) &
|
# 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
|
fi
|
||||||
cd "${argv[0]}"
|
cd "${argv[0]}"
|
||||||
exec "${argv[@]:1}" ;;
|
exec "${argv[@]:1}" ;;
|
||||||
|
|||||||
Reference in New Issue
Block a user