diff --git a/Cargo.lock b/Cargo.lock
index 257db550..0a9e5a43 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -268,7 +268,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -1305,14 +1305,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
name = "rustls"
-version = "0.23.40"
+version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"rustls-pki-types",
@@ -1332,9 +1332,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.13"
+version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"ring",
"rustls-pki-types",
@@ -1606,7 +1606,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2102,7 +2102,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.52.0",
+ "windows-sys 0.61.2",
]
[[package]]
diff --git a/docs/clients.html b/docs/clients.html
index db31894c..6e3413d1 100644
--- a/docs/clients.html
+++ b/docs/clients.html
@@ -166,8 +166,11 @@
The native desktop app — the release form (as built; PRs #195, #204)
The console's release form is a Tauri 2 desktop app
(src-tauri/) that embeds the Director in-process. On launch it
runs the same gridfpv_app::director::run_director the hosted
- gridfpv binary runs, on a tokio task bound to a loopback ephemeral
- port (127.0.0.1:0), then opens a native window pointed at
+ gridfpv binary runs, on a tokio task bound to a fixed loopback
+ port (127.0.0.1:8080, GRIDFPV_ADDR to override; it was
+ an ephemeral 127.0.0.1:0 until 2026-10-12, which broke every OBS browser source
+ holding an overlay URL on each restart — an ephemeral port is now only the fallback when the
+ fixed one is taken), then opens a native window pointed at
http://127.0.0.1:<port>/. The window loads the Director's own HTTP origin
directly, so the SPA's same-origin API (/snapshot/…, /control/…) and
the realtime WebSocket work with no cross-origin shims. Because it is
diff --git a/docs/decisions.html b/docs/decisions.html
index 11374dd2..3da7b283 100644
--- a/docs/decisions.html
+++ b/docs/decisions.html
@@ -296,7 +296,10 @@ D9 · The native desktop app embeds the Director, ships portable-only, store
The release form is a Tauri 2 desktop app (src-tauri/; PRs
#195, #204) that embeds the Director in-process. On launch it runs the
same gridfpv_app::director::run_director the hosted binary runs, on a
- tokio task bound to a loopback ephemeral port (127.0.0.1:0), and
+ tokio task bound to a fixed loopback port (127.0.0.1:8080;
+ originally an ephemeral 127.0.0.1:0, changed 2026-10-12 because OBS browser
+ sources hold the overlay URL and a port that moved on every restart broke them — ephemeral is
+ now the logged fallback when the fixed port is taken), and
opens a native window pointed at http://127.0.0.1:<port>/. The
frontend is baked into the binary via the embed-assets cargo
feature, so the executable is a single self-contained file. Distribution is
diff --git a/docs/roadmap.html b/docs/roadmap.html
index db2798b0..1d7e8d2b 100644
--- a/docs/roadmap.html
+++ b/docs/roadmap.html
@@ -125,6 +125,20 @@ 2. The phases
GET /about + the console build-stamp; CONTRACT_VERSION stays
the independent wire integer).
+
+ Status (2026-10-12): v0.4.0 is released (the non-prerelease
+ v0.4.0, 2026-09-07),
+ and the first work after it is the first piece of the
+ streaming design: the race overlay
+ (#/overlay/race), an OBS browser source that follows the active event — any
+ event — and puts the clock, the heat and every pilot's laps in the video, reached from the
+ event workspace's new Overlays tab. It was first built as a "RaceGOW"
+ solo-run preset with its own screens; that mode was dropped (an ordinary event with one
+ pilot and a best-3-consecutive time-trial round is the better shape, and needs nothing new)
+ and the overlay kept and generalised. The desktop app also moved from an ephemeral port to
+ a fixed one (127.0.0.1:8080), because a browser source holds
+ the Director's address.
+
v0.1 — Walking skeleton
diff --git a/docs/streaming.html b/docs/streaming.html
index a2a160ca..f98a4b38 100644
--- a/docs/streaming.html
+++ b/docs/streaming.html
@@ -308,6 +308,28 @@ 5. Overlay catalogue & theming
Each gets a fuller spec as it is built; the list confirms the protocol exposes what they need.
+
+
+ As built (2026-09-26, generalised 2026-10-12): the first overlay is the race
+ timer, #/overlay/race. It is a route of the RD console SPA rather
+ than a separate app — the "one codebase, profiled three ways" frontend — rendered bare, on
+ a transparent document (html[data-overlay] lifts the console canvas), in the
+ .gridfpv-overlay token context. It is exactly the §2 shape: a read-only
+ client on the same session as the console, following the program (§2.1) — the
+ Director's active event, whatever kind of event it is — with no binding
+ of its own yet. It shows the race clock (a countdown when the round has a fixed end, as
+ the console's clocks do), the phase, the current heat's friendly name, and a row per pilot
+ off the served lap-list projection (laps, last, best — or the best n in a row when
+ the round scores BestConsecutive, plus the served round-wide best); a
+ one-pilot heat gets the long form with its recent laps. It began life as the RaceGOW
+ timer, tied to a solo-run preset; the preset was removed (an ordinary event does the job
+ better) and the overlay was made to follow any event. The console reaches it from
+ the event workspace's Overlays tab (#/event/overlays): the
+ catalogue, each page's URL with a contrast picker, and a live preview frame of the same
+ route. Nothing in §3 (broadcast control) is built.
+
+
+
5.1 Theming & chapter branding
Theming is presentation, never data. Overlays render from
diff --git a/frontend/apps/rd-console/src/App.svelte b/frontend/apps/rd-console/src/App.svelte
index 9826cc27..7ba556a1 100644
--- a/frontend/apps/rd-console/src/App.svelte
+++ b/frontend/apps/rd-console/src/App.svelte
@@ -45,6 +45,8 @@
import Marshaling from './screens/Marshaling.svelte';
import Results from './screens/Results.svelte';
import EventAudit from './screens/EventAudit.svelte';
+ import EventOverlays from './screens/EventOverlays.svelte';
+ import RaceOverlay from './screens/RaceOverlay.svelte';
import { openAudit } from './lib/auditFilter.svelte.js';
import type { Timer } from '@gridfpv/types';
import {
@@ -59,8 +61,11 @@
const session = new Session();
// App-wide race-day audio: tones + lap callouts follow the live stream on EVERY page (the
- // race must be audible while the RD is on rounds/marshaling/results, not just Live control).
- mountRaceDayAudio(session);
+ // race must be audible while the RD is on rounds/marshaling/results, not just Live control) —
+ // except an **overlay**. An overlay is a browser source inside OBS (or the Overlays tab's
+ // preview frame): a second copy of the start tones and callouts coming out of the stream, a
+ // beat behind the console's, is exactly what a transparent timer panel must not do.
+ if (parseHash(location.hash).kind !== 'overlay') mountRaceDayAudio(session);
// Role seam (#55/#80): a `?role=readonly` query selects the read-only-pilot tier, which hides
// every mutating control (the Marshaling actions, etc.). The Director is the enforced boundary;
@@ -218,7 +223,8 @@
| 'live'
| 'marshaling'
| 'results'
- | 'audit';
+ | 'audit'
+ | 'overlays';
const SCREENS: { id: ScreenId; label: string; key: string; icon: string }[] = [
{
id: 'classes-roster',
@@ -248,6 +254,14 @@
label: 'Timers',
key: '6',
icon: 'M12 8v4l2.5 2.5M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z'
+ },
+ // The OBS browser-source pages (streaming.html §2): their URLs, colors and a live preview. A
+ // broadcast surface sits with the equipment, after Timers; key '8', the next free digit.
+ {
+ id: 'overlays',
+ label: 'Overlays',
+ key: '8',
+ icon: 'M3 5h18v11H3zM8 20h8M12 16v4'
}
];
// The active workspace tab is derived from the route (the hash is the source of truth, #118):
@@ -346,6 +360,13 @@
{/if}
+{:else if route.kind === 'overlay'}
+
+
+
+
{:else if route.kind === 'page'}
+
+
+
+
+ {/each}
+
+
+
diff --git a/frontend/apps/rd-console/src/screens/RaceOverlay.svelte b/frontend/apps/rd-console/src/screens/RaceOverlay.svelte
new file mode 100644
index 00000000..dd112268
--- /dev/null
+++ b/frontend/apps/rd-console/src/screens/RaceOverlay.svelte
@@ -0,0 +1,548 @@
+
+
+
+
+
+ GridFPV
+ {#if active && event}
+ {event.name}{heatName ? ` · ${heatName}` : ''}
+ {:else if event}
+ {event.name} · waiting for a race…
+ {:else}
+ waiting for a race…
+ {/if}
+
+
+ {#if active}
+
+ {#if remainingMs !== undefined}
+
+ {formatMicros(clock.elapsedMs * 1000)}
+ {:else}
+
+ {/if}
+ {phaseLabel}
+
+
+ {#if solo}
+
{solo.name}
+
+ {#if windowN !== undefined}
+
+ Best {windowN} this heat
+
+ {micros(solo.window?.totalMicros)}
+
+
+
+ Best {windowN} today
+
+ {micros(solo.roundBest)}
+
+
+ {:else}
+
+ Laps
+ {solo.laps}
+
+
+ Best lap
+ {micros(solo.best)}
+
+ {/if}
+
+
+
+ {#each recent as lap, i (recentOffset + i)}
+ -
+ L{recentOffset + i + 1}
+ {formatMicros(lap)}
+
+ {/each}
+
+ {:else if rows.length > 0}
+
+
+
+ |
+ Pilot |
+ Laps |
+ Last |
+ {windowN === undefined ? 'Best' : `Best ${windowN}`} |
+
+
+
+ {#each rows as row, i (row.ref)}
+
+ | {i + 1} |
+ {row.name} |
+ {row.laps} |
+ {micros(row.last)} |
+
+ {windowN === undefined ? micros(row.best) : micros(row.window?.totalMicros)}
+ |
+
+ {/each}
+
+
+ {:else}
+
No pilots in this heat yet.
+ {/if}
+ {/if}
+
+
+
+
diff --git a/frontend/apps/rd-console/tests/EventOverlays.test.ts b/frontend/apps/rd-console/tests/EventOverlays.test.ts
new file mode 100644
index 00000000..730fd12f
--- /dev/null
+++ b/frontend/apps/rd-console/tests/EventOverlays.test.ts
@@ -0,0 +1,45 @@
+/**
+ * EventOverlays — the in-event Overlays tab: lists the race overlay with its URL for OBS, swaps the
+ * contrast into the URL, copies it, and frames a live preview of the same route.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { fireEvent, render, screen } from '@testing-library/svelte';
+import EventOverlays from '../src/screens/EventOverlays.svelte';
+import { makeTestSession } from './support.js';
+
+describe('EventOverlays', () => {
+ it('lists the race overlay with its OBS URL on the console origin, in either contrast', async () => {
+ const { session } = makeTestSession({});
+ render(EventOverlays, { session });
+ expect(screen.getByText('Race timer')).toBeInTheDocument();
+ const url = screen.getByLabelText('Race timer URL');
+ expect(url).toHaveTextContent(
+ new RegExp(`^${location.origin}${location.pathname}#/overlay/race$`)
+ );
+ await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' }));
+ expect(url).toHaveTextContent(/#\/overlay\/race\/light$/);
+ await fireEvent.click(screen.getByRole('radio', { name: 'Light text on dark' }));
+ expect(url).toHaveTextContent(/#\/overlay\/race$/);
+ });
+
+ it('frames the same route as a preview and links it to a new tab', async () => {
+ const { session } = makeTestSession({});
+ render(EventOverlays, { session });
+ const frame = screen.getByTitle('Race timer preview');
+ expect(frame).toHaveAttribute('src', expect.stringMatching(/#\/overlay\/race$/));
+ const link = screen.getByRole('link', { name: 'Open in a new tab' });
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('href', expect.stringMatching(/#\/overlay\/race$/));
+ await fireEvent.click(screen.getByRole('radio', { name: 'Dark text on light' }));
+ expect(frame).toHaveAttribute('src', expect.stringMatching(/#\/overlay\/race\/light$/));
+ });
+
+ it('copies the URL to the clipboard', async () => {
+ const writeText = vi.fn(async () => {});
+ Object.assign(navigator, { clipboard: { writeText } });
+ const { session } = makeTestSession({});
+ render(EventOverlays, { session });
+ await fireEvent.click(screen.getByRole('button', { name: 'Copy URL' }));
+ expect(writeText).toHaveBeenCalledWith(expect.stringMatching(/#\/overlay\/race$/));
+ });
+});
diff --git a/frontend/apps/rd-console/tests/RaceOverlay.test.ts b/frontend/apps/rd-console/tests/RaceOverlay.test.ts
new file mode 100644
index 00000000..5481aa49
--- /dev/null
+++ b/frontend/apps/rd-console/tests/RaceOverlay.test.ts
@@ -0,0 +1,187 @@
+/**
+ * RaceOverlay — the OBS browser source: idle with no heat on the timer; the long form (clock,
+ * recent laps, this heat's best window, the served round best) for a one-pilot heat of a
+ * best-consecutive round; and a row per pilot for a full heat of a race round.
+ */
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen, within } from '@testing-library/svelte';
+import { waitFor } from '@testing-library/dom';
+import type { EventMeta, HeatSummary, LapList, LiveRaceState, Pilot } from '@gridfpv/types';
+import RaceOverlay from '../src/screens/RaceOverlay.svelte';
+import { makeTestSession } from './support.js';
+
+const EVENT: EventMeta = {
+ id: 'club-ab12',
+ name: 'Tuesday Club Night',
+ created_at: 0,
+ persistent: true,
+ timers: ['rh-1'],
+ roster: ['p1', 'p2', 'p3'],
+ classes: ['mgp-whoop'],
+ rounds: [
+ {
+ id: 'tt-1',
+ label: 'Time trial',
+ classes: ['mgp-whoop'],
+ format: 'timed_qual',
+ params: { rounds: '0' },
+ win_condition: { BestConsecutive: { n: 3 } },
+ seeding: 'FromRoster'
+ },
+ {
+ id: 'race-1',
+ label: 'Mains',
+ classes: ['mgp-whoop'],
+ format: 'race',
+ params: {},
+ win_condition: { FirstToLaps: { n: 4 } },
+ seeding: 'FromRoster'
+ }
+ ]
+} as unknown as EventMeta;
+
+const HEATS: HeatSummary[] = [
+ {
+ heat: 'tt-1-3',
+ name: 'Time trial Heat 3',
+ lineup: ['p1'],
+ round: 'tt-1',
+ phase: 'Running',
+ is_current: true
+ },
+ {
+ heat: 'race-1-1',
+ name: 'Mains Heat 1',
+ lineup: ['p1', 'p2', 'p3'],
+ round: 'race-1',
+ phase: 'Running',
+ is_current: false
+ }
+];
+
+const PILOTS: Pilot[] = [
+ { id: 'p1', callsign: 'Recon', vtx_types: [] },
+ { id: 'p2', callsign: 'Bee', vtx_types: [] },
+ { id: 'p3', callsign: 'Cee', vtx_types: [] }
+] as unknown as Pilot[];
+
+function lapsOf(ref: string, seconds: number[]) {
+ return {
+ competitor: { adapter: 'rotorhazard', competitor: ref },
+ laps: seconds.map((s, i) => ({
+ number: i + 1,
+ duration_micros: Math.round(s * 1_000_000),
+ at: (i + 1) * 10_000_000,
+ start_ref: i,
+ end_ref: i + 1
+ }))
+ };
+}
+
+describe('RaceOverlay', () => {
+ it('renders idle with no event, and marks the document as an overlay', () => {
+ const { session } = makeTestSession({ noEnter: true });
+ render(RaceOverlay, { session });
+ expect(screen.getByText('waiting for a race…')).toBeInTheDocument();
+ expect(document.documentElement.dataset.overlay).toBe('race');
+ expect(screen.queryByRole('timer')).toBeNull();
+ });
+
+ it('names the event while it waits for a heat', () => {
+ const { session } = makeTestSession({ event: EVENT, live: { phase: 'Scheduled' } });
+ render(RaceOverlay, { session });
+ expect(screen.getByText('Tuesday Club Night · waiting for a race…')).toBeInTheDocument();
+ expect(screen.queryByRole('timer')).toBeNull();
+ });
+
+ it('shows a one-pilot best-consecutive heat in long form: name, LIVE, recent laps, both bests', async () => {
+ const SOLO: LapList = { competitors: [lapsOf('p1', [9, 7, 6.8, 6.8, 7.5, 7.1, 7.0, 6.9])] };
+ const live: LiveRaceState = {
+ phase: 'Running',
+ current_heat: 'tt-1-3',
+ active_pilots: ['p1'],
+ race_started_at: 1_000_000
+ };
+ const { session } = makeTestSession({
+ event: EVENT,
+ live,
+ listPilotsImpl: vi.fn(async () => PILOTS),
+ listHeatsImpl: vi.fn(async () => HEATS),
+ roundStandingsImpl: vi.fn(async () => [
+ {
+ competitor: 'p1',
+ position: 1,
+ best_lap_micros: 6_800_000,
+ laps: 8,
+ metric: { BestConsecutive: { n: 3, micros: 20_500_000 } }
+ }
+ ]),
+ heatFetches: { 'tt-1-3': { laps: SOLO } }
+ });
+ render(RaceOverlay, { session });
+
+ // The heat is named by the shared resolver's friendly name, never its id.
+ expect(await screen.findByText('Tuesday Club Night · Time trial Heat 3')).toBeInTheDocument();
+ expect(screen.getByText('LIVE')).toBeInTheDocument();
+ // No fixed end on this round: the clock counts up.
+ expect(screen.getByRole('timer', { name: /Race time/ })).toBeInTheDocument();
+ expect(await screen.findByText('Recon')).toBeInTheDocument();
+ // Only the last six of eight laps fit the frame; they keep their real lap numbers.
+ const laps = await screen.findByRole('list', { name: 'Recent laps' });
+ await waitFor(() => expect(within(laps).getAllByRole('listitem')).toHaveLength(6));
+ expect(within(laps).getByText('L3')).toBeInTheDocument();
+ expect(within(laps).getByText('L8')).toBeInTheDocument();
+ expect(within(laps).queryByText('L2')).not.toBeInTheDocument();
+ // This heat's best window (laps 2–4 = 20.600) and the served round best.
+ expect(screen.getByLabelText("This heat's best 3 consecutive laps")).toHaveTextContent(
+ '20.600'
+ );
+ await waitFor(() =>
+ expect(screen.getByLabelText('Best 3 consecutive laps, all heats')).toHaveTextContent(
+ '20.500'
+ )
+ );
+ expect(screen.queryByRole('table')).toBeNull();
+ });
+
+ it('shows a full heat as a row per pilot in running order, with laps, last and best lap', async () => {
+ const FIELD: LapList = {
+ competitors: [lapsOf('p1', [7, 6.5]), lapsOf('p2', [8]), lapsOf('p3', [7.2, 7.1, 7.0])]
+ };
+ const live: LiveRaceState = {
+ phase: 'Running',
+ current_heat: 'race-1-1',
+ active_pilots: ['p1', 'p2', 'p3'],
+ running_order: ['p3', 'p1', 'p2'],
+ race_started_at: 1_000_000
+ };
+ const { session } = makeTestSession({
+ event: EVENT,
+ live,
+ listPilotsImpl: vi.fn(async () => PILOTS),
+ listHeatsImpl: vi.fn(async () => HEATS),
+ heatFetches: { 'race-1-1': { laps: FIELD } }
+ });
+ render(RaceOverlay, { session });
+
+ expect(await screen.findByText('Tuesday Club Night · Mains Heat 1')).toBeInTheDocument();
+ const table = await screen.findByRole('table', { name: 'Pilots in this heat' });
+ const rows = within(table).getAllByRole('row').slice(1); // minus the header
+ await waitFor(() => expect(within(rows[0]).getByText('Cee')).toBeInTheDocument());
+ expect(within(rows[1]).getByText('Recon')).toBeInTheDocument();
+ expect(within(rows[2]).getByText('Bee')).toBeInTheDocument();
+ // Cee: 3 laps, last 7.000, best 7.000. Recon: 2 laps, last 6.500, best 6.500.
+ await waitFor(() => expect(rows[0]).toHaveTextContent(/3.*7\.000.*7\.000/));
+ expect(rows[1]).toHaveTextContent(/2.*6\.500.*6\.500/);
+ expect(rows[2]).toHaveTextContent(/1.*8\.000.*8\.000/);
+ // A race round scores no consecutive window: the column is the best lap.
+ expect(within(table).getByRole('columnheader', { name: 'Best' })).toBeInTheDocument();
+ expect(screen.queryByRole('list', { name: 'Recent laps' })).toBeNull();
+ });
+
+ it('takes the light contrast from its theme prop', () => {
+ const { session } = makeTestSession({ noEnter: true });
+ render(RaceOverlay, { session, theme: 'light' });
+ expect(screen.getByLabelText('Race overlay')).toHaveAttribute('data-theme', 'light');
+ });
+});
diff --git a/frontend/apps/rd-console/tests/overlay.test.ts b/frontend/apps/rd-console/tests/overlay.test.ts
new file mode 100644
index 00000000..92ac263c
--- /dev/null
+++ b/frontend/apps/rd-console/tests/overlay.test.ts
@@ -0,0 +1,208 @@
+/**
+ * Unit tests for the overlay helpers (`lib/overlay.ts`): the best-consecutive window the race
+ * overlay slides over served laps, how it finds a heat's round and scoring window, the per-pilot
+ * served figures, the frame order, and the OBS URL. The browser e2e (`e2e/overlay.spec.ts`) proves
+ * the page against a real Director; this pins the arithmetic it relies on.
+ */
+import { describe, expect, it } from 'vitest';
+import type { EventMeta, HeatSummary, LapList, RoundStanding } from '@gridfpv/types';
+import {
+ bestConsecutive,
+ competitorLaps,
+ consecutiveWindow,
+ heatRound,
+ overlayOrder,
+ overlayUrl,
+ servedRoundBestMicros,
+ OVERLAY_CATALOG
+} from '../src/lib/overlay.js';
+
+describe('bestConsecutive', () => {
+ it('is undefined with fewer laps than the window', () => {
+ expect(bestConsecutive([], 3)).toBeUndefined();
+ expect(bestConsecutive([7_000_000, 7_100_000], 3)).toBeUndefined();
+ });
+
+ it('finds the fastest 3-lap window and where it starts', () => {
+ // Laps 2–4 (indices 1–3) sum to 20.6 s; every other window is slower.
+ const laps = [9_000_000, 7_000_000, 6_800_000, 6_800_000, 7_500_000, 7_100_000];
+ expect(bestConsecutive(laps, 3)).toEqual({
+ start: 1,
+ laps: [7_000_000, 6_800_000, 6_800_000],
+ totalMicros: 20_600_000
+ });
+ });
+
+ it('gives a tie to the earliest window (the engine breaks ties by the earlier completion)', () => {
+ const laps = [7_000_000, 7_000_000, 7_000_000, 7_000_000];
+ expect(bestConsecutive(laps, 3)?.start).toBe(0);
+ });
+
+ it('honours a different window size, and refuses a zero one', () => {
+ const laps = [8_000_000, 6_000_000, 7_000_000];
+ expect(bestConsecutive(laps, 1)).toEqual({
+ start: 1,
+ laps: [6_000_000],
+ totalMicros: 6_000_000
+ });
+ expect(bestConsecutive(laps, 2)).toEqual({
+ start: 1,
+ laps: [6_000_000, 7_000_000],
+ totalMicros: 13_000_000
+ });
+ expect(bestConsecutive(laps, 0)).toBeUndefined();
+ });
+});
+
+describe('consecutiveWindow / heatRound', () => {
+ const EVENT = {
+ id: 'e',
+ name: 'Club night',
+ created_at: 0,
+ persistent: true,
+ timers: [],
+ roster: [],
+ classes: [],
+ rounds: [
+ {
+ id: 'r-tt',
+ label: 'Time trial',
+ format: 'timed_qual',
+ win_condition: { BestConsecutive: { n: 3 } }
+ },
+ { id: 'r-race', label: 'Race', format: 'race', win_condition: { FirstToLaps: { n: 4 } } }
+ ]
+ } as unknown as EventMeta;
+ const HEATS = [
+ {
+ heat: 'h-1',
+ name: 'Time trial Heat 1',
+ lineup: ['p1'],
+ round: 'r-tt',
+ phase: 'Final',
+ is_current: false
+ },
+ {
+ heat: 'h-2',
+ name: 'Race Heat 1',
+ lineup: ['p1', 'p2'],
+ round: 'r-race',
+ phase: 'Running',
+ is_current: true
+ },
+ { heat: 'free', name: 'free', lineup: ['p1'], phase: 'Scheduled', is_current: false }
+ ] as HeatSummary[];
+
+ it('reads the window only off a BestConsecutive condition', () => {
+ expect(consecutiveWindow({ BestConsecutive: { n: 3 } })).toBe(3);
+ expect(consecutiveWindow('BestLap')).toBeUndefined();
+ expect(consecutiveWindow({ Timed: { window_micros: 120_000_000 } })).toBeUndefined();
+ expect(consecutiveWindow({ FirstToLaps: { n: 4 } })).toBeUndefined();
+ expect(consecutiveWindow(undefined)).toBeUndefined();
+ });
+
+ it('finds a heat’s round through its round tag, and nothing for an untagged heat', () => {
+ expect(heatRound(EVENT, HEATS, 'h-1')?.id).toBe('r-tt');
+ expect(heatRound(EVENT, HEATS, 'h-2')?.id).toBe('r-race');
+ expect(heatRound(EVENT, HEATS, 'free')).toBeUndefined();
+ expect(heatRound(EVENT, HEATS, 'gone')).toBeUndefined();
+ expect(heatRound(undefined, HEATS, 'h-1')).toBeUndefined();
+ });
+});
+
+describe('competitorLaps / servedRoundBestMicros / overlayOrder', () => {
+ const LAPS: LapList = {
+ competitors: [
+ {
+ competitor: { adapter: 'sim', competitor: 'p2' },
+ laps: [{ number: 1, duration_micros: 8_000_000, at: 1, start_ref: 0, end_ref: 1 }]
+ },
+ {
+ competitor: { adapter: 'sim', competitor: 'p1' },
+ laps: [
+ { number: 1, duration_micros: 7_000_000, at: 1, start_ref: 0, end_ref: 1 },
+ { number: 2, duration_micros: 6_500_000, at: 2, start_ref: 1, end_ref: 2 }
+ ]
+ }
+ ]
+ };
+
+ it('picks one competitor’s served laps, in order, and nothing for a stranger', () => {
+ expect(competitorLaps(LAPS, 'p1').map((l) => l.duration_micros)).toEqual([
+ 7_000_000, 6_500_000
+ ]);
+ expect(competitorLaps(LAPS, 'p2')).toHaveLength(1);
+ expect(competitorLaps(LAPS, 'p9')).toEqual([]);
+ expect(competitorLaps(undefined, 'p1')).toEqual([]);
+ });
+
+ it('reads the served round figure per pilot: a best window, a best lap, or nothing for laps', () => {
+ const standings: RoundStanding[] = [
+ {
+ competitor: 'p1',
+ position: 1,
+ best_lap_micros: 6_500_000,
+ laps: 8,
+ metric: { BestConsecutive: { n: 3, micros: 20_500_000 } }
+ },
+ {
+ competitor: 'p2',
+ position: 2,
+ best_lap_micros: null,
+ laps: 1,
+ metric: { BestConsecutive: { n: 3, micros: null } }
+ },
+ {
+ competitor: 'p3',
+ position: 3,
+ best_lap_micros: 7_100_000,
+ laps: 3,
+ metric: { BestLap: { micros: 7_100_000 } }
+ },
+ {
+ competitor: 'p4',
+ position: 4,
+ best_lap_micros: null,
+ laps: 5,
+ metric: { MostLaps: { laps: 5 } }
+ }
+ ];
+ expect(servedRoundBestMicros(standings, 'p1')).toBe(20_500_000);
+ expect(servedRoundBestMicros(standings, 'p2')).toBeUndefined();
+ expect(servedRoundBestMicros(standings, 'p3')).toBe(7_100_000);
+ expect(servedRoundBestMicros(standings, 'p4')).toBeUndefined();
+ expect(servedRoundBestMicros(standings, 'p9')).toBeUndefined();
+ });
+
+ it('orders by the first source that names a pilot, appending the rest, without duplicates', () => {
+ expect(overlayOrder([['p2', 'p1'], ['p1', 'p3'], undefined, ['p4', 'p2']])).toEqual([
+ 'p2',
+ 'p1',
+ 'p3',
+ 'p4'
+ ]);
+ expect(overlayOrder([undefined, undefined])).toEqual([]);
+ });
+});
+
+describe('overlayUrl / the catalogue', () => {
+ it('points the overlay at the console origin + path, on the overlay hash', () => {
+ expect(overlayUrl({ origin: 'http://192.168.1.50:8080', pathname: '/' }, 'race')).toBe(
+ 'http://192.168.1.50:8080/#/overlay/race'
+ );
+ expect(overlayUrl({ origin: 'http://127.0.0.1:8080', pathname: '/console/' }, 'race')).toBe(
+ 'http://127.0.0.1:8080/console/#/overlay/race'
+ );
+ });
+
+ it('carries the light theme in the URL, and leaves the dark default bare', () => {
+ const loc = { origin: 'http://127.0.0.1:8080', pathname: '/' };
+ expect(overlayUrl(loc, 'race', 'light')).toBe('http://127.0.0.1:8080/#/overlay/race/light');
+ expect(overlayUrl(loc, 'race', 'dark')).toBe('http://127.0.0.1:8080/#/overlay/race');
+ });
+
+ it('lists the race timer as the one overlay built so far', () => {
+ expect(OVERLAY_CATALOG.map((o) => o.name)).toEqual(['race']);
+ expect(OVERLAY_CATALOG[0].title).toBe('Race timer');
+ });
+});
diff --git a/frontend/apps/rd-console/tests/route.test.ts b/frontend/apps/rd-console/tests/route.test.ts
index a8b11da5..e043a350 100644
--- a/frontend/apps/rd-console/tests/route.test.ts
+++ b/frontend/apps/rd-console/tests/route.test.ts
@@ -333,3 +333,66 @@ describe('resolveInitialRoute (hash is authoritative; #118)', () => {
}
});
});
+
+describe('overlay routes', () => {
+ it('parses the race overlay, case-insensitively', () => {
+ expect(parseHash('#/overlay/race')).toEqual({ kind: 'overlay', overlay: 'race' });
+ expect(parseHash('#/Overlay/RACE')).toEqual({ kind: 'overlay', overlay: 'race' });
+ });
+
+ it('parses the overlay theme: /light is the light panel, anything else is the dark default', () => {
+ expect(parseHash('#/overlay/race/light')).toEqual({
+ kind: 'overlay',
+ overlay: 'race',
+ theme: 'light'
+ });
+ expect(parseHash('#/overlay/race/LIGHT')).toEqual({
+ kind: 'overlay',
+ overlay: 'race',
+ theme: 'light'
+ });
+ // An explicit `dark`, or a typo, still renders the overlay — never bounces the scene to the hub.
+ expect(parseHash('#/overlay/race/dark')).toEqual({ kind: 'overlay', overlay: 'race' });
+ expect(parseHash('#/overlay/race/lite')).toEqual({ kind: 'overlay', overlay: 'race' });
+ });
+
+ it('degrades a bare or unknown overlay to the hub', () => {
+ expect(parseHash('#/overlay')).toEqual(DEFAULT_ROUTE);
+ expect(parseHash('#/overlay/leaderboard')).toEqual(DEFAULT_ROUTE);
+ });
+
+ it('round-trips each through formatHash', () => {
+ const routes: Route[] = [
+ { kind: 'overlay', overlay: 'race' },
+ { kind: 'overlay', overlay: 'race', theme: 'light' }
+ ];
+ for (const r of routes) expect(parseHash(formatHash(r))).toEqual(r);
+ expect(formatHash({ kind: 'overlay', overlay: 'race', theme: 'light' })).toBe(
+ '#/overlay/race/light'
+ );
+ expect(formatHash({ kind: 'overlay', overlay: 'race', theme: 'dark' })).toBe('#/overlay/race');
+ expect(formatHash({ kind: 'overlay', overlay: 'race' })).toBe('#/overlay/race');
+ });
+
+ it('never reconciles an overlay away — it sits in an OBS scene whether or not a race is on', () => {
+ expect(reconcileRoute({ kind: 'overlay', overlay: 'race' }, false)).toEqual({
+ kind: 'overlay',
+ overlay: 'race'
+ });
+ expect(resolveInitialRoute('#/overlay/race/light', false)).toEqual({
+ kind: 'overlay',
+ overlay: 'race',
+ theme: 'light'
+ });
+ });
+
+ it('has an Overlays workspace tab, reached like any other tab', () => {
+ expect(parseHash('#/event/overlays')).toEqual({ kind: 'workspace', tab: 'overlays' });
+ expect(formatHash({ kind: 'workspace', tab: 'overlays' })).toBe('#/event/overlays');
+ // With no active event the tab reconciles to Events like every workspace route.
+ expect(reconcileRoute({ kind: 'workspace', tab: 'overlays' }, false)).toEqual({
+ kind: 'page',
+ page: 'events'
+ });
+ });
+});
diff --git a/frontend/e2e/overlay.spec.ts b/frontend/e2e/overlay.spec.ts
new file mode 100644
index 00000000..7cb1ab54
--- /dev/null
+++ b/frontend/e2e/overlay.spec.ts
@@ -0,0 +1,124 @@
+/**
+ * The race overlay, end to end, in real headless chromium against the worker's real Director: an
+ * ordinary event's heat is scheduled and started over the control path, then the OBS browser-source
+ * page (`#/overlay/race`) renders it — the event and heat names, the clock, a row per pilot with
+ * climbing laps — read-only, on a transparent document, in both contrasts. Then the workspace's
+ * **Overlays** tab offers that page's URL and frames a live preview of it.
+ *
+ * Nothing about the event is special: the overlay follows the Director's active event, whatever it
+ * is. Importing `test`/`expect` from `./observability.js` means a failure carries the full-stack
+ * dump (browser console, page errors, the Director's log).
+ */
+import { expect, test } from './observability.js';
+
+const PILOTS = ['Ace', 'Bee', 'Cee'];
+const HEAT_ID = 'ov-1';
+const ROUND_LABEL = 'E2E-Overlay';
+
+test('the race overlay follows an ordinary event’s heat, and the Overlays tab hands out its URL', async ({
+ page,
+ director
+}) => {
+ // ── Make the event active, the way an RD does: open it from the Events page ────────────────
+ await page.goto('/');
+ await page.getByRole('button', { name: /Events/ }).click();
+ const liveNav = page.getByRole('button', { name: /Race control/ });
+ await expect(
+ page.getByRole('heading', { name: 'Choose an event' }).or(liveNav).first()
+ ).toBeVisible({ timeout: 15_000 });
+ if (
+ await page
+ .getByRole('heading', { name: 'Choose an event' })
+ .isVisible()
+ .catch(() => false)
+ ) {
+ await page
+ .getByRole('button', { name: /Practice/ })
+ .first()
+ .click();
+ }
+ await expect(liveNav).toBeVisible();
+
+ // ── A round and a heat of three, focused and started over the open control path ────────────
+ const roundRes = await page.request.post(`${director.eventRoot}/rounds`, {
+ headers: { 'Content-Type': 'application/json' },
+ data: {
+ label: ROUND_LABEL,
+ classes: [],
+ format: 'timed_qual',
+ params: { rounds: '1' },
+ win_condition: 'BestLap',
+ time_limit_secs: 60,
+ seeding: 'FromRoster',
+ channel_mode: 'PerHeat'
+ }
+ });
+ expect(roundRes.ok()).toBeTruthy();
+ const roundId = ((await roundRes.json()) as { id: string }).id;
+ for (const command of [
+ { ScheduleHeat: { heat: HEAT_ID, lineup: PILOTS, round: roundId } },
+ { SetCurrentHeat: { heat: HEAT_ID } },
+ { Stage: { heat: HEAT_ID } },
+ { Start: { heat: HEAT_ID } }
+ ]) {
+ const ack = await page.request.post(`${director.eventRoot}/control`, {
+ headers: { 'Content-Type': 'application/json' },
+ data: command
+ });
+ expect(ack.ok(), JSON.stringify(command)).toBeTruthy();
+ }
+
+ // ── The overlay: the OBS browser source follows the active event's heat, read-only ─────────
+ await page.goto(`/#/overlay/race`);
+ const overlay = page.getByLabel('Race overlay');
+ await expect(overlay).toBeVisible({ timeout: 15_000 });
+ // The event and the heat, by their friendly names — never a raw id.
+ await expect(page.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({ timeout: 15_000 });
+ // A 60 s race time: the primary clock counts down, as the console's does.
+ await expect(page.getByRole('timer', { name: /Time remaining/ })).toBeVisible({
+ timeout: 15_000
+ });
+ await expect(page.getByText('LIVE')).toBeVisible({ timeout: 15_000 });
+ // One row per pilot, named; their laps climb as the sim flies.
+ const table = page.getByRole('table', { name: 'Pilots in this heat' });
+ for (const pilot of PILOTS) {
+ await expect(table.getByText(pilot, { exact: true })).toBeVisible();
+ }
+ const lapCells = table.locator('tbody tr td:nth-child(3)');
+ const totalLaps = async () => {
+ const texts = await lapCells.allTextContents();
+ return texts.reduce((sum, t) => sum + (parseInt(t.trim(), 10) || 0), 0);
+ };
+ await expect
+ .poll(totalLaps, { timeout: 30_000, message: 'laps should appear on the overlay' })
+ .toBeGreaterThan(0);
+ // The document is flagged transparent for the keyer.
+ await expect(page.locator('html')).toHaveAttribute('data-overlay', 'race');
+ await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+
+ // The light variant: same overlay, dark text on a light panel, still transparent underneath.
+ await page.goto(`/#/overlay/race/light`);
+ await expect(page.getByLabel('Race overlay')).toHaveAttribute('data-theme', 'light');
+ await expect(page.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator('body')).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
+
+ // ── The Overlays tab inside the event: the URL for OBS, and a live preview of the page ─────
+ await page.goto(`/#/event/overlays`);
+ await expect(page.getByRole('heading', { name: 'Overlays' })).toBeVisible({ timeout: 15_000 });
+ const url = page.getByLabel('Race timer URL');
+ await expect(url).toHaveText(new RegExp(`^${director.baseUrl}/#/overlay/race$`));
+ await page.getByRole('radio', { name: 'Dark text on light' }).click();
+ await expect(url).toHaveText(/#\/overlay\/race\/light$/);
+ // The preview frames the real route; inside it, the same heat is rendering.
+ const preview = page.frameLocator('iframe[title="Race timer preview"]');
+ await expect(preview.getByLabel('Race overlay')).toBeVisible({ timeout: 15_000 });
+ await expect(preview.getByText(`Practice · ${ROUND_LABEL} Heat 1`)).toBeVisible({
+ timeout: 15_000
+ });
+
+ // ── Tidy: end the heat so the worker's Director is quiet for the next spec ─────────────────
+ await page.request.post(`${director.eventRoot}/control`, {
+ headers: { 'Content-Type': 'application/json' },
+ data: { ForceEnd: { heat: HEAT_ID } }
+ });
+});
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 2846031e..1e208098 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3107,9 +3107,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.43"
+version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"rustls-pki-types",
diff --git a/src-tauri/README.md b/src-tauri/README.md
index cfd75e44..0020030e 100644
--- a/src-tauri/README.md
+++ b/src-tauri/README.md
@@ -21,11 +21,20 @@ On launch the app:
1. Builds a multi-thread tokio runtime and spawns the **same** Director the hosted `gridfpv`
binary runs — via the shared `gridfpv_app::director::run_director` — as a background task.
- It binds **loopback on an ephemeral free port** (`127.0.0.1:0`), uses a **true-portable
- data dir** (`gridfpv-data/` next to the executable, with a per-user app-data fallback) for
- created events' SQLite files, and serves the **bundled** `rd-console` dist as the SPA.
-2. Waits for the Director to report its OS-assigned port, then opens the main window pointed
- at `http://127.0.0.1:/`.
+ It binds **loopback on a fixed port** (`127.0.0.1:8080` by default; `GRIDFPV_ADDR`
+ overrides it), uses a **true-portable data dir** (`gridfpv-data/` next to the executable,
+ with a per-user app-data fallback) for created events' SQLite files, and serves the
+ **bundled** `rd-console` dist as the SPA.
+2. Waits for the Director to report the port it bound, then opens the main window pointed at
+ `http://127.0.0.1:/`.
+
+**Why a fixed port.** The app used to bind `127.0.0.1:0` and take whatever port the OS gave
+it, which changed on every restart — and an **OBS browser source** holding an overlay URL
+(`http://127.0.0.1:/#/overlay/race`) broke every time. The port is now the same one the
+hosted binary defaults to, and the same after every launch. If it is taken, the app falls back
+to an ephemeral port **and says so in the log** rather than refusing to start; set
+`GRIDFPV_ADDR=127.0.0.1:` to pick a different fixed port (or a LAN address for OBS on
+another machine — off loopback, set `GRIDFPV_RD_TOKEN` to gate control).
Because the window loads the Director's HTTP origin directly (rather than the bundled assets
over `tauri://`), the SPA's **same-origin** API calls (`/snapshot/...`, `/control/...`) and
@@ -43,8 +52,8 @@ The Director's serve entry point was extracted into one reusable function,
- the hosted `gridfpv` binary (`crates/app/src/main.rs`) — env-resolved address, Ctrl-C
shutdown, prints the startup banner; behavior unchanged; and
-- this native app (`src-tauri/src/lib.rs`) — `127.0.0.1:0`, per-user data dir, bundled
- assets, and an `on_ready` that reads the ephemeral port.
+- this native app (`src-tauri/src/lib.rs`) — `127.0.0.1:8080` (or `GRIDFPV_ADDR`), the
+ portable data dir, bundled assets, and an `on_ready` that reads the bound port.
So the two share identical Director wiring (event registry, optional RD token, sim
lap-source bridge, presence reconciler, router, CORS).
@@ -120,16 +129,16 @@ chmod +x src-tauri/target/release/gridfpv-desktop
./src-tauri/target/release/gridfpv-desktop
```
-A native window opens with the RD console, backed by the in-process Director on a private
-loopback port. The maintainer runs this over RDP (which provides a display). A `gridfpv-data/`
+A native window opens with the RD console, backed by the in-process Director on loopback
+port 8080 (`http://127.0.0.1:8080/`, the address OBS browser sources and other tabs can hold). The maintainer runs this over RDP (which provides a display). A `gridfpv-data/`
folder appears next to the binary, holding created events' SQLite files.
> **Headless note:** the GUI window needs a display, so on a headless VM the window itself
> can't render. The **server half** (the embedded Director) needs no display and is covered
> by an integration test — `crates/app/tests/director.rs ::
> run_director_serves_health_on_loopback_ephemeral_port` — which drives the exact
-> `run_director` entry point the app uses, binds a loopback ephemeral port, and asserts
-> `/health` answers over a real socket. The full app can also be smoke-launched headless with
+> `run_director` entry point the app uses (on an ephemeral port, so the test never collides
+> with a running Director) and asserts `/health` answers over a real socket. The full app can also be smoke-launched headless with
> `xvfb-run` (set `WEBKIT_DISABLE_DMABUF_RENDERER=1 LIBGL_ALWAYS_SOFTWARE=1` to avoid the
> WebKit GPU path hanging under Xvfb).
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 7a383a3f..9cf9c7b6 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -9,16 +9,32 @@
//!
//! 1. Builds a multi-thread tokio runtime and spawns the **same** Director the hosted
//! `gridfpv` binary runs — via the shared [`gridfpv_app::director::run_director`] — as a
-//! background task. It binds **loopback on an ephemeral free port** (`127.0.0.1:0`), uses
-//! a **true-portable data dir** (`gridfpv-data/` beside the running executable, with a
+//! background task. It binds **loopback on a fixed, well-known port**
+//! ([`DEFAULT_ADDR`], `127.0.0.1:8080`, overridable with `GRIDFPV_ADDR`), uses a
+//! **true-portable data dir** (`gridfpv-data/` beside the running executable, with a
//! per-user app-data fallback when the exe dir isn't writable) for created events, and
//! serves the `rd-console` SPA **embedded into the binary** (`gridfpv-app`'s `embed-assets`
//! feature) — so this executable is self-contained, with no external dist folder beside it.
//! Loopback ⇒ the control path is open (no auth), per the GridFPV auth model (loopback =
//! trusted).
-//! 2. Waits (briefly) for the Director to report the OS-assigned port, then opens the main
+//! 2. Waits (briefly) for the Director to report the port it bound, then opens the main
//! window pointed at `http://127.0.0.1:/`.
//!
+//! # Why a fixed port, not an ephemeral one
+//!
+//! The app used to bind `127.0.0.1:0` and let the OS pick. That is fine for the window — the
+//! shell learns the port and points the webview at it — and wrong for everything else that
+//! holds the Director's address: an **OBS browser source** carrying an overlay URL
+//! (`#/overlay/race`), a bookmark, a second browser tab. Every restart handed out a new port
+//! and silently broke each of them, so the overlay in the pilot's recording went blank until
+//! they re-pasted a URL. The address is now stable across restarts, like the hosted binary's.
+//!
+//! If the fixed port is **taken** (another Director, a dev server), the app still comes up: it
+//! falls back to an ephemeral port and says so in the log, loudly, rather than refusing to
+//! start — a window with a one-off port beats no window. `GRIDFPV_ADDR` picks a different
+//! fixed port (or a LAN bind, for OBS on another machine — mind the auth model: off loopback,
+//! set `GRIDFPV_RD_TOKEN` to gate control).
+//!
//! Pointing the webview at the Director's HTTP origin (rather than loading the bundled
//! assets over the `tauri://` protocol) is deliberate: the SPA makes **same-origin**
//! relative calls to the protocol API (`/snapshot/...`, `/control/...`) and opens the
@@ -36,6 +52,7 @@
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
+use std::sync::{Arc, Mutex};
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
use tokio::sync::oneshot;
@@ -51,6 +68,35 @@ macro_rules! eprintln {
($($arg:tt)*) => { gridfpv_app::logging::record(::std::format_args!($($arg)*)) };
}
+/// The address the embedded Director binds by default: **loopback, port 8080** — the same
+/// port the hosted `gridfpv` binary defaults to, so the console's address is the one every
+/// guide already prints, and it is the **same after every restart** (see the module docs for
+/// why that matters: OBS browser sources hold the overlay URL).
+pub const DEFAULT_ADDR: SocketAddr =
+ SocketAddr::new(std::net::IpAddr::V4(Ipv4Addr::LOCALHOST), 8080);
+
+/// The env var that overrides [`DEFAULT_ADDR`] — the same name the hosted binary honours.
+pub const ADDR_ENV: &str = "GRIDFPV_ADDR";
+
+/// Resolve the address to bind: `GRIDFPV_ADDR` when set and valid, else [`DEFAULT_ADDR`]. An
+/// unparsable value is reported and ignored rather than aborting the launch — the fixed
+/// default is always a usable answer.
+fn requested_addr() -> SocketAddr {
+ match std::env::var(ADDR_ENV) {
+ Ok(value) => match value.trim().parse::() {
+ Ok(addr) => addr,
+ Err(err) => {
+ eprintln!(
+ "gridfpv-desktop: {ADDR_ENV}={value:?} is not a valid socket address ({err}) — \
+ using {DEFAULT_ADDR}"
+ );
+ DEFAULT_ADDR
+ }
+ },
+ Err(_) => DEFAULT_ADDR,
+ }
+}
+
/// The env var that opts into the Linux WebKitGTK rendering workarounds (#476).
///
/// Accepts (case-insensitive, comma-separated):
@@ -187,33 +233,63 @@ pub fn run() {
.build()
.expect("failed to build the Director tokio runtime");
- // `oneshot` carries the OS-assigned ephemeral port from the Director's
- // `on_ready` (inside the runtime) back out to the setup thread so we can open the
- // window at the right URL.
+ // `oneshot` carries the port the Director actually bound from its `on_ready`
+ // (inside the runtime) back out to the setup thread so we can open the window at
+ // the right URL. Shared between the first attempt and the fallback below.
let (port_tx, port_rx) = oneshot::channel::();
+ let port_tx = Arc::new(Mutex::new(Some(port_tx)));
- // Spawn the Director on the runtime. Bind `127.0.0.1:0` ⇒ loopback + ephemeral
- // free port (no auth, per the auth model). It runs until the process exits; the
- // app quitting tears the runtime (and the server) down.
+ // Spawn the Director on the runtime, on the FIXED loopback address (no auth, per
+ // the auth model) — the same one after every restart, so an OBS browser source
+ // holding an overlay URL keeps working. It runs until the process exits; the app
+ // quitting tears the runtime (and the server) down.
+ let requested = requested_addr();
runtime.spawn(async move {
- let mut port_tx = Some(port_tx);
- let result = gridfpv_app::director::run_director(
- SocketAddr::from((Ipv4Addr::LOCALHOST, 0)),
- Some(data_dir),
- assets_dir,
- move |ready| {
- // Hand the bound ephemeral port back to the setup thread.
- if let Some(tx) = port_tx.take() {
- let _ = tx.send(ready.bound.port());
+ let serve = |addr: SocketAddr, data_dir: PathBuf, assets_dir: PathBuf| {
+ let port_tx = port_tx.clone();
+ gridfpv_app::director::run_director(
+ addr,
+ Some(data_dir),
+ assets_dir,
+ move |ready| {
+ // Hand the bound port back to the setup thread.
+ if let Some(tx) = port_tx.lock().unwrap().take() {
+ let _ = tx.send(ready.bound.port());
+ }
+ },
+ // No graceful-shutdown trigger: the server lives for the app's
+ // lifetime and is torn down when the process exits.
+ std::future::pending::<()>(),
+ )
+ };
+ let bound_fixed = port_tx.clone();
+ // The error is flattened to a `String` before the next `.await`: `Box`
+ // is not `Send`, and holding it across the fallback's await would make this
+ // future unspawnable.
+ let result = serve(requested, data_dir.clone(), assets_dir.clone())
+ .await
+ .map_err(|err| err.to_string());
+ // `run_director` returns early only on a failure. If it never reported a port,
+ // the bind itself failed (the port is taken, or the address is not local):
+ // fall back to an ephemeral port so the window still opens — and say so, since
+ // every URL anyone pasted elsewhere is now wrong until the port is freed.
+ let never_bound = bound_fixed.lock().unwrap().is_some();
+ match result {
+ Err(err) if never_bound => {
+ eprintln!(
+ "gridfpv-desktop: could not bind {requested} ({err}). Falling back to \
+ an EPHEMERAL loopback port — the console's address will differ from \
+ the usual one until whatever holds {requested} is stopped (or set \
+ {ADDR_ENV} to another fixed port). OBS browser sources pointing at \
+ the usual address will not connect in this session."
+ );
+ let fallback = SocketAddr::from((Ipv4Addr::LOCALHOST, 0));
+ if let Err(err) = serve(fallback, data_dir, assets_dir).await {
+ eprintln!("gridfpv-desktop: Director exited with error: {err}");
}
- },
- // No graceful-shutdown trigger: the server lives for the app's lifetime
- // and is torn down when the process exits.
- std::future::pending::<()>(),
- )
- .await;
- if let Err(err) = result {
- eprintln!("gridfpv-desktop: Director exited with error: {err}");
+ }
+ Err(err) => eprintln!("gridfpv-desktop: Director exited with error: {err}"),
+ Ok(()) => {}
}
});
@@ -222,6 +298,12 @@ pub fn run() {
let port = port_rx
.blocking_recv()
.expect("the embedded Director failed to report its bound port");
+ if port != requested.port() {
+ eprintln!(
+ "gridfpv-desktop: NOTE — serving on port {port}, not the configured \
+ {requested}; see the bind failure above"
+ );
+ }
let url = format!("http://{}:{port}/", Ipv4Addr::LOCALHOST);
eprintln!("gridfpv-desktop: embedded Director ready — opening {url}");