Compare commits

...
Author SHA1 Message Date
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 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
branderson 651fe4e95e ci: install the Docker CLI and allow a registry host override
The privileged runner's job image has no Docker client, which the login
and build actions need. The registry host can be set with a REGISTRY_HOST
repository variable when the runner reaches Gitea at an internal address.
2026-10-13 23:23:06 -07:00
branderson 0d08cde3c1 Merge pull request 'CI: log in to the registry as the repository owner' (#4) from ci-registry-login into main
Images / app (push) Failing after 1m10s
Images / streamer (push) Failing after 1m14s
Images / session-manager (push) Failing after 1m13s
Reviewed-on: #4
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 23:19:12 -07:00
branderson e1cf8aff29 ci: log in to the registry as the repository owner
Only REGISTRY_TOKEN is configured; the REGISTRY_USERNAME secret the
login step read doesn't exist, so it logged in with an empty username.
2026-10-13 23:17:36 -07:00
branderson 9b46a25802 Merge pull request 'CI: dejima runner labels and short job names' (#3) from ci-runner-labels into main
Images / app (push) Failing after 6m49s
Images / streamer (push) Failing after 1m7s
Images / session-manager (push) Failing after 1m9s
Reviewed-on: #3
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 22:23:15 -07:00
branderson 71682ae2b7 ci: run image builds on dejima-privileged, with short job names 2026-10-13 22:22:39 -07:00
branderson bac27cbc05 Merge pull request 'Dotcom Arcade: rename, one compose file, Ultimate setup script and docs' (#1) from rebrand-and-docs into main
Images / build (., dotcom-arcade) (push) Canceled after 0s
Images / build (streaming/session-manager, dotcom-arcade-session-manager) (push) Canceled after 0s
Images / build (streaming/streamer, dotcom-arcade-streamer) (push) Canceled after 0s
Reviewed-on: #1
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 22:17:00 -07:00
branderson c49fd1e4e9 Merge pull request 'Release prep: legal-safe setup, hosting requirements, in-app stream proxy, Gitea CI, cleanup' (#2) from release-prep into rebrand-and-docs
Reviewed-on: #2
Reviewed-by: branderson <brad@branderson.io>
2026-10-13 22:14:30 -07:00
13 changed files with 227 additions and 52 deletions
+33 -9
View File
@@ -1,8 +1,10 @@
# Builds the app, session manager and streamer images and pushes them to this
# Gitea instance's container registry, under the repository's owner.
#
# Needs repository secrets REGISTRY_USERNAME and REGISTRY_TOKEN: a user, and an
# access token of theirs with package write access.
# Needs a repository secret REGISTRY_TOKEN: an access token with package write
# access, belonging to the repository's owner. If the runner reaches Gitea at a
# different address than the registry's public one, set a repository variable
# REGISTRY_HOST to the registry's host name.
name: Images
on:
@@ -11,28 +13,50 @@ on:
tags: ['v*.*.*']
workflow_dispatch:
# Jobs that run docker builds use dejima-privileged; anything else should use
# dejima-confined.
jobs:
build:
runs-on: ubuntu-latest
name: ${{ matrix.name }}
runs-on: dejima-privileged
strategy:
fail-fast: false
matrix:
include:
- image: dotcom-arcade
- name: app
image: dotcom-arcade
context: .
- image: dotcom-arcade-session-manager
- name: session-manager
image: dotcom-arcade-session-manager
context: streaming/session-manager
- image: dotcom-arcade-streamer
- name: streamer
image: dotcom-arcade-streamer
context: streaming/streamer
steps:
- uses: actions/checkout@v4
# The registry lives on the same host as this Gitea instance
# The runner mounts the host's Docker socket, but its job image ships no
# Docker client, and the login and build actions shell out to `docker`.
# Only Docker's repository is refreshed, so the job needs no other mirrors.
- name: Install the Docker CLI
run: |
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
apt-get update -o Dir::Etc::sourcelist=sources.list.d/docker.list -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0
apt-get install -y --no-install-recommends docker-ce-cli
# The registry is this Gitea instance, unless REGISTRY_HOST says otherwise
- name: Locate the registry
id: registry
shell: bash
env:
REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
run: |
host=${GITHUB_SERVER_URL#*://}
echo "host=${host%/}" >> "$GITHUB_OUTPUT"
host=${REGISTRY_HOST:-${host%/}}
echo "host=$host" >> "$GITHUB_OUTPUT"
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
@@ -40,7 +64,7 @@ jobs:
- uses: docker/login-action@v3
with:
registry: ${{ steps.registry.outputs.host }}
username: ${{ secrets.REGISTRY_USERNAME }}
username: ${{ github.repository_owner }}
password: ${{ secrets.REGISTRY_TOKEN }}
- id: meta
+7 -4
View File
@@ -43,10 +43,13 @@ Then run `docker compose pull && docker compose up -d`.
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
pushes them to the Gitea instance's container registry, under the repository's
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
`1.2` and `latest`. It needs two repository secrets:
- `REGISTRY_USERNAME`: a user;
- `REGISTRY_TOKEN`: an access token of theirs with package write access.
`1.2` and `latest`. Its jobs run docker builds, so they need a runner labelled
`dejima-privileged`; jobs that don't build images should use `dejima-confined`.
It also needs a repository secret, `REGISTRY_TOKEN`: an access token with
package write access, belonging to the repository's owner, whom the workflow
logs in as. If the runner reaches Gitea at a different address than the
registry's public one, set a repository variable `REGISTRY_HOST` to the
registry's host name.
## Volumes
+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
+59 -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,11 @@ 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 exact size, and the display's, rounded up to even for H.264
`GAME_WIDTH=${size.width}`,
`GAME_HEIGHT=${size.height}`,
`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 +60,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 +72,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 +132,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')));
});
+5 -3
View File
@@ -52,6 +52,8 @@ 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; the height is the window's opening height less the movie's
(`GAME_HEIGHT`). 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,19 @@ 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 opens at the movie's size plus
# the menu bar, which gives the menu bar's height.
(
wid=$(xdotool search --sync --onlyvisible --name 'Flash Player' | head -1)
eval "$(xdotool getwindowgeometry --shell "$wid")"
read -r display_width display_height < <(xdotool getdisplaygeometry)
menu=$((HEIGHT - ${GAME_HEIGHT:-$display_height}))
((menu > 0 && menu < 100)) || menu=0
xdotool windowsize "$wid" "$display_width" $((display_height + menu)) \
windowmove "$wid" 0 $((-menu)) windowactivate "$wid"
) &
fi
cd "${argv[0]}"
exec "${argv[@]:1}" ;;