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)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)} +
  1. + L{recentOffset + i + 1} + {formatMicros(lap)} +
  2. + {/each} +
+ {:else if rows.length > 0} + + + + + + + + + + + + {#each rows as row, i (row.ref)} + + + + + + + + {/each} + +
PilotLapsLast{windowN === undefined ? 'Best' : `Best ${windowN}`}
{i + 1}{row.name}{row.laps}{micros(row.last)} + {windowN === undefined ? micros(row.best) : micros(row.window?.totalMicros)} +
+ {: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}");