Skip to content

Add Tilecast Player for Linux (Electron) - #193

Merged
gbyo merged 10 commits into
mainfrom
gibby/linux-player-build-6982b0
Jul 20, 2026
Merged

gbyo merged 10 commits into
mainfrom
gibby/linux-player-build-6982b0

Conversation

@gbyo

@gbyo gbyo commented Jul 20, 2026 •

Copy link
Copy Markdown
Owner

Full Electron/TypeScript player with content + operational parity to the Android player, minus Android/TV-hardware features.

Reliability: identity-verified pairing, credential store cleared only on confirmed invalid/revoked; WebSocket with liveness watchdog; jittered backoff; ETag manifest sync with resumable SHA-256-verified downloads and atomic activation at item boundary; cached-first boot; exactly-once commands (persisted idempotency, polled independently of the socket); playback-progress self-heal ladder + safe mode.

Content: images/videos/websites/YouTube; native widgets (clock, date, countdown, ticker, metric, cards, weather, menu/list/table/agenda, QR); multi-zone Layouts (v2); declarative v13 presentation (binding/repeat/conditional/charts). Runtime resolves a render-tree IR; the renderer is a dependency-free DOM interpreter (small footprint on 4 GiB).

Operational: active hours (overnight sleep), batched activity-event telemetry, live screen preview, LAN mDNS discovery, HTTPS-enforcing ServerUrlPolicy.

Low-end tuning for the ~2012 mini PC / 4 GiB / Intel HD 4000 target (VA-API decode, capped memory, fps limit; env-overridable). systemd unit is the outermost restart rung.

Excluded (Android/TV-only): CEC, device-owner kiosk, accessibility service, boot receiver, APK self-update; power_assist_*/restart_activity/install_player_update return unsupported_command.

84 unit tests; tsc -b clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a Linux kiosk Tilecast Player with pairing, authenticated manifest/config/command syncing, offline playback with cached media, weekly/one-time scheduling, emergency takeover, and multi-zone layouts with widgets/websites.
    • Implemented durable, exactly-once command execution with batched activity telemetry, a live screen preview uploader, and a playback self-heal supervisor (including safe mode).
    • Added LAN server discovery, verified resumable downloads, and low-end hardware tuning for performance.
  • Documentation
    • Added Linux player setup/configuration/deployment documentation.
  • Tests
    • Added extensive Vitest coverage for scheduling/selection, rendering, downloads, commands, networking behaviors, formatting, and recovery logic.
  • Chores
    • Added Linux player gitignore, Electron build scaffolding, and a systemd user unit for kiosk startup.

Implements a full Electron/TypeScript player with content and operational
parity to the Android player, minus Android/TV-hardware features.

Protocol & reliability:
- Identity-verified pairing, one-time enrollment, owner-only credential store
  cleared only on confirmed invalid/revoked
- Authenticated WebSocket (protocol v1) with a client liveness watchdog that
  kills half-open sockets; jittered backoff that resets after sustained health
- Manifest sync: ETag/304, resumable Range+If-Range downloads with SHA-256
  verification, atomic activation at the next item boundary, cached-first boot
- Commands polled independently of socket state, exactly-once via persisted
  idempotency keys; config sync with revision monotonicity
- Playback-progress self-heal ladder (reactivate/recreate-renderer/recreate-
  window/restart-process) with persisted escalation state and safe mode

Content rendering (runtime resolves a render-tree IR; renderer is a
dependency-free DOM interpreter, tiny footprint for low-end hardware):
- Images, videos, websites, YouTube
- Native widgets (clock, date, countdown, ticker, metric, cards, weather,
  menu/list/table/agenda, QR) across schema 11/12/13 data sources
- Multi-zone Layouts (schema v2) with primitives and independent playlist zones
- Declarative v13 presentation node tree with binding/repeat/conditional/charts

Operational parity:
- Active hours (overnight true-black sleep, emergency override)
- Batched activity-event telemetry (persisted, retried)
- On-demand live screen preview (downscaled window capture)
- LAN mDNS discovery on first-run setup; HTTPS-enforcing ServerUrlPolicy

Low-end hardware tuning for the ~2012 mini PC / 4 GiB target: Intel VA-API
decode, capped V8/GPU memory, frame-rate limit, one video decode at a time,
all env-overridable. systemd unit is the outermost restart rung.

Excluded (Android/TV-only): CEC, device-owner kiosk, accessibility service,
boot receiver, APK self-update; power_assist_*/restart_activity/
install_player_update report unsupported_command.

84 unit tests; tsc -b clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6382d79f-fd16-424e-9e2d-0233d0d291c2

📥 Commits

Reviewing files that changed from the base of the PR and between 7325326 and 910f6c7.

📒 Files selected for processing (43)
  • apps/dashboard/src/api/types.ts
  • apps/dashboard/src/forms/FormBuilder.tsx
  • apps/dashboard/src/forms/FormFieldEditor.tsx
  • apps/dashboard/src/forms/FormRenderer.test.tsx
  • apps/dashboard/src/forms/FormRenderer.tsx
  • apps/dashboard/src/forms/formKeys.test.ts
  • apps/dashboard/src/pages/CreateFormDataSourcePage.tsx
  • apps/dashboard/src/pages/FormDataSource.test.tsx
  • apps/dashboard/src/pages/FormDataSourcePage.tsx
  • apps/dashboard/src/styles/forms.css
  • apps/player-linux/README.md
  • apps/player-linux/src/core/active-hours.test.ts
  • apps/player-linux/src/core/active-hours.ts
  • apps/player-linux/src/core/activity.test.ts
  • apps/player-linux/src/core/activity.ts
  • apps/player-linux/src/core/api.ts
  • apps/player-linux/src/core/commands.test.ts
  • apps/player-linux/src/core/commands.ts
  • apps/player-linux/src/core/config.ts
  • apps/player-linux/src/core/content-render.test.ts
  • apps/player-linux/src/core/datasource.ts
  • apps/player-linux/src/core/download.test.ts
  • apps/player-linux/src/core/download.ts
  • apps/player-linux/src/core/format.test.ts
  • apps/player-linux/src/core/format.ts
  • apps/player-linux/src/core/layout-render.ts
  • apps/player-linux/src/core/manifest.ts
  • apps/player-linux/src/core/pairing.ts
  • apps/player-linux/src/core/player.ts
  • apps/player-linux/src/core/presentation-render.ts
  • apps/player-linux/src/core/schedule.test.ts
  • apps/player-linux/src/core/schedule.ts
  • apps/player-linux/src/core/selection.test.ts
  • apps/player-linux/src/core/selection.ts
  • apps/player-linux/src/core/server-url.ts
  • apps/player-linux/src/core/storage.ts
  • apps/player-linux/src/core/types.ts
  • apps/player-linux/src/core/widget-render.ts
  • apps/player-linux/src/main/discovery.ts
  • apps/player-linux/src/main/hardware.ts
  • apps/player-linux/src/main/index.ts
  • apps/player-linux/src/renderer/renderer.ts
  • docs/openapi.yaml

📝 Walkthrough

Walkthrough

Introduces a Linux Electron kiosk player with typed protocols, pairing, synchronization, verified media caching, scheduling, rendering, command execution, telemetry, previews, recovery behavior, LAN discovery, deployment configuration, and documentation. Dashboard form-related files receive formatting-only changes.

Changes

Linux Player

Layer / File(s) Summary
Contracts, storage, transport, and rendering
apps/player-linux/src/core/*
Adds player contracts, durable storage, HTTP/WebSocket clients, reconnect policy, datasource normalization, formatting, widgets, presentations, layouts, and render-tree payloads.
Synchronization and runtime lifecycle
apps/player-linux/src/core/pairing.ts, config.ts, manifest.ts, download.ts, commands.ts, activity.ts, supervisor.ts, preview.ts, active-hours.ts, schedule.ts, selection.ts, player.ts
Adds enrollment, cached configuration and manifests, verified resumable downloads, scheduling, active-hours evaluation, persistent commands, telemetry, previews, recovery escalation, and lifecycle orchestration.
Electron shell and deployment
apps/player-linux/src/main/*, apps/player-linux/src/preload.ts, apps/player-linux/src/renderer/renderer.ts, apps/player-linux/static/index.html, apps/player-linux/package.json, apps/player-linux/deploy/*
Adds the kiosk window, IPC bridge, DOM renderer, media protocol, LAN discovery, hardware tuning, packaging, systemd startup, and static player entrypoint.
Linux player documentation and configuration
apps/player-linux/README.md, apps/player-linux/.gitignore, apps/player-linux/tsconfig.json
Documents Linux player behavior and adds project build configuration and ignored output paths.

Dashboard formatting cleanup

Layer / File(s) Summary
Forms, API, styles, and OpenAPI formatting
apps/dashboard/src/api/types.ts, apps/dashboard/src/forms/*, apps/dashboard/src/pages/*Form*, apps/dashboard/src/styles/forms.css, docs/openapi.yaml
Reformats existing TypeScript, React, CSS, test, and OpenAPI structures without changing behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the change but misses the required template sections and checklist structure. Add the required ## Summary and ## Validation sections, and fill in each checklist item with concrete pass/fail notes.
Docstring Coverage ⚠️ Warning Docstring coverage is 17.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding the Linux Electron Tilecast player.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gibby/linux-player-build-6982b0

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 oasdiff (1.23.0)
docs/openapi.yaml

Error: failed to load base spec from "/tmp/coderabbit-oasdiff-base.Wltq70": failed to unmarshal data: json error: invalid character 'o' looking for beginning of value, yaml error: error converting YAML to JSON: yaml: unmarshal errors:
line 910: mapping key "description" already defined at line 910


Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (5)
apps/player-linux/src/main/hardware.ts (2)

28-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Comment/value mismatch: the comment says 512 MiB but the switch sets --max-old-space-size=384. Align the comment with the actual budget.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/main/hardware.ts` around lines 28 - 29, Update the
memory budget comment above app.commandLine.appendSwitch to state 384 MiB,
matching the configured --max-old-space-size value.

38-48: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Env var name mismatch: docs reference TILECAST_DISABLE_VAAPI, but the code only reads TILECAST_HW_DECODE. The VA-API override is gated on envFlag("TILECAST_HW_DECODE", true); TILECAST_DISABLE_VAAPI is never read, so an operator following the docs to force a software fallback in the field gets no effect. Align comment and README with the implemented variable (note the inverted polarity: TILECAST_HW_DECODE=0 disables).

  • apps/player-linux/src/main/hardware.ts#L38-L48: update the L38 comment to say set TILECAST_HW_DECODE=0 instead of TILECAST_DISABLE_VAAPI=1.
  • apps/player-linux/README.md#L76-L77: replace TILECAST_DISABLE_VAAPI with TILECAST_HW_DECODE (matching the env table at L109).

As per coding guidelines: "Update relevant documentation with implementation changes rather than as an approximation afterward."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/main/hardware.ts` around lines 38 - 48, The VA-API
fallback documentation uses the wrong environment variable name and polarity. In
apps/player-linux/src/main/hardware.ts lines 38-48, update the comment near
envFlag("TILECAST_HW_DECODE", true) to document TILECAST_HW_DECODE=0; in
apps/player-linux/README.md lines 76-77, replace TILECAST_DISABLE_VAAPI with
TILECAST_HW_DECODE to match the implementation and existing environment table.

Source: Coding guidelines

apps/player-linux/src/core/download.ts (1)

102-141: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

No download timeout: a half-open connection can hang the sync indefinitely.

Neither fetchImpl (Lines 102-105) nor the body reader loop (Lines 129-141) enforces a timeout when request.signal is absent — and the manifest call site passes no signal. A stalled peer leaves this promise unresolved, blocking manifest reconciliation with no DownloadError to retry on. Consider deriving an AbortSignal with a stall/overall timeout (e.g. AbortSignal.timeout) combined with any caller signal.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/download.ts` around lines 102 - 141, The download
flow around fetchImpl and the response.body reader must enforce a timeout even
when request.signal is absent. Derive a timeout-backed AbortSignal, combine it
with request.signal when provided, and pass it to fetchImpl so stalled
connections abort during both request and body reading; ensure the resulting
abort is surfaced as a retryable DownloadError.
apps/player-linux/src/core/manifest.ts (1)

116-119: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Inline structural cast bypasses the real Layout type.

Casting layouts to an ad-hoc Array<{...}> shape instead of using the actual ManifestLayout/document type from ./types loses type-checking on this data path (e.g. it would have masked the missing-field issue above at compile time). Consider importing and reusing the real type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/manifest.ts` around lines 116 - 119, Replace the
inline structural cast on layouts with the actual ManifestLayout/document type
exported from ./types, importing that type as needed. Preserve the existing
combination of manifest.layouts and manifest.layout while ensuring the resulting
data path is checked against the real manifest layout contract.
apps/player-linux/src/core/active-hours.ts (1)

41-71: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate Intl-based "local time in timezone" parsing logic across two files. active-hours.ts's localNow() and schedule.ts's localTimeIn() both use Intl.DateTimeFormat(...).formatToParts() to extract local weekday/hour/minute for an IANA timezone, differing only in weekday numbering and whether a date string is also extracted. One shared helper (returning both numbering schemes, or a generic { year, month, day, weekday, hour, minute } shape) would avoid maintaining two near-identical implementations.

  • apps/player-linux/src/core/active-hours.ts#L41-L71: extract a shared local-time-in-timezone helper instead of localNow()'s inline Intl parsing.
  • apps/player-linux/src/core/schedule.ts#L36-L73: reuse the same shared helper instead of localTimeIn()'s duplicated Intl parsing.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/active-hours.ts` around lines 41 - 71, The
Intl-based timezone parsing is duplicated between localNow() and localTimeIn().
In apps/player-linux/src/core/active-hours.ts:41-71, replace localNow()'s inline
parsing with a shared helper; in apps/player-linux/src/core/schedule.ts:36-73,
update localTimeIn() to reuse that helper. Have the shared result provide the
date and time fields both callers need, preserving each function’s existing
weekday numbering and return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/player-linux/src/core/download.ts`:
- Line 125: Move the recursive destination-directory creation from after the
write in the download flow to before fs.open(partPath, …), ensuring
dirname(request.destination) exists before opening the .part file. Remove the
now-late mkdir block while preserving its existing recursive behavior and
permissions.
- Around line 86-100: The partial-file handling before header construction must
treat an existing .part whose size equals request.expectedSizeBytes as complete:
promote it to the destination or remove it before any ranged request is issued.
Update the offset/resume logic near fileSize(partPath) so Range is only set for
offsets strictly below the expected size, while preserving oversized-file
cleanup and validator requirements.

In `@apps/player-linux/src/core/format.ts`:
- Around line 44-55: Update the date parsing flow in the format function to
detect the date-only regex form before calling Date.parse. Route valid date-only
values through the existing UTC-based formatInstant path, while preserving the
current timezone-aware handling for other values and the existing fallback for
invalid inputs.

In `@apps/player-linux/src/core/manifest.ts`:
- Around line 106-139: Update the layouts collection in requiredDownloads to
also include manifest.directFallbackLayout, alongside manifest.layouts and
manifest.layout. Preserve the existing background and image-placement scanning
so layouts selected through the direct fallback path have their required assets
added.

In `@apps/player-linux/src/core/player.ts`:
- Around line 592-608: Update evaluatePresentation so its deduplication key
excludes the playing presentation’s changing generation value, while preserving
generation increments for actual playing presentations. Use a normalized copy of
buildPresentation() for comparison, then continue presenting the original next
value with the current generation; unchanged content must return early without
re-presenting.

In `@apps/player-linux/src/core/qr.ts`:
- Around line 25-47: Update qrDataUri to handle qrcode-generator overflow when
qr.make() fails for user-controlled errorCorrection values, especially quartile
or high. Catch the exception around QR construction/data insertion/make and
return the existing safe empty-string fallback, or cap the input length based on
ECC before calling qr.make(); preserve normal SVG generation for values that
fit.

In `@apps/player-linux/src/core/selection.ts`:
- Around line 41-46: Update addDays to validate the input date and resulting
Date before calling toISOString, handling malformed or non-YYYY-MM-DD values
without throwing. Ensure windowForMode’s custom_range selection remains
resilient when customEnd is invalid, using the existing fallback or empty-result
behavior rather than propagating a RangeError.

In `@apps/player-linux/src/core/server-url.test.ts`:
- Around line 25-40: Update isPrivateOrLocalHost and its tests to validate IPv6
addresses using proper parsing and private/link-local range checks instead of
hostname prefix matching for fc, fd, or fe80. Preserve HTTP allowance for
genuine private/local hosts while rejecting public names such as fcevil.example
from HTTP downgrade.

In `@apps/player-linux/src/core/server-url.ts`:
- Around line 23-26: Restrict the IPv6 prefix checks in the hostname
classification logic to actual IPv6 literals, rather than applying
startsWith("fc"), startsWith("fd"), or startsWith("fe80") to arbitrary DNS
names. Update the relevant helper around the "::1" check to validate
IPv6-literal syntax before recognizing unique-local or link-local prefixes,
while preserving existing handling for valid IPv6 loopback and scoped addresses.

In `@apps/player-linux/src/core/widget-render.ts`:
- Around line 475-477: Update textLeftGrow to set grow: 1 in the returned text
style, replacing the no-op empty-object spread. Preserve the existing color,
fontSize, and left alignment so header cells use the same growing layout
behavior as row cells.

In `@apps/player-linux/src/main/hardware.ts`:
- Around line 60-63: The max-gum-fps switch in the hardware initialization only
limits getUserMedia capture, not signage rendering or compositor frame rate.
Replace this configuration with a render-loop or animation-frame throttle to
enforce the intended FPS cap; alternatively, rename TILECAST_MAX_FPS and update
its related comments and README to accurately describe capture-only behavior.

---

Nitpick comments:
In `@apps/player-linux/src/core/active-hours.ts`:
- Around line 41-71: The Intl-based timezone parsing is duplicated between
localNow() and localTimeIn(). In
apps/player-linux/src/core/active-hours.ts:41-71, replace localNow()'s inline
parsing with a shared helper; in apps/player-linux/src/core/schedule.ts:36-73,
update localTimeIn() to reuse that helper. Have the shared result provide the
date and time fields both callers need, preserving each function’s existing
weekday numbering and return behavior.

In `@apps/player-linux/src/core/download.ts`:
- Around line 102-141: The download flow around fetchImpl and the response.body
reader must enforce a timeout even when request.signal is absent. Derive a
timeout-backed AbortSignal, combine it with request.signal when provided, and
pass it to fetchImpl so stalled connections abort during both request and body
reading; ensure the resulting abort is surfaced as a retryable DownloadError.

In `@apps/player-linux/src/core/manifest.ts`:
- Around line 116-119: Replace the inline structural cast on layouts with the
actual ManifestLayout/document type exported from ./types, importing that type
as needed. Preserve the existing combination of manifest.layouts and
manifest.layout while ensuring the resulting data path is checked against the
real manifest layout contract.

In `@apps/player-linux/src/main/hardware.ts`:
- Around line 28-29: Update the memory budget comment above
app.commandLine.appendSwitch to state 384 MiB, matching the configured
--max-old-space-size value.
- Around line 38-48: The VA-API fallback documentation uses the wrong
environment variable name and polarity. In
apps/player-linux/src/main/hardware.ts lines 38-48, update the comment near
envFlag("TILECAST_HW_DECODE", true) to document TILECAST_HW_DECODE=0; in
apps/player-linux/README.md lines 76-77, replace TILECAST_DISABLE_VAAPI with
TILECAST_HW_DECODE to match the implementation and existing environment table.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4448436d-34b0-4011-9c6c-7173946b2f78

📥 Commits

Reviewing files that changed from the base of the PR and between d380482 and 7325326.

⛔ Files ignored due to path filters (1)
  • apps/player-linux/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (50)
  • apps/player-linux/.gitignore
  • apps/player-linux/README.md
  • apps/player-linux/deploy/tilecast-player.service
  • apps/player-linux/package.json
  • apps/player-linux/src/core/active-hours.test.ts
  • apps/player-linux/src/core/active-hours.ts
  • apps/player-linux/src/core/activity.test.ts
  • apps/player-linux/src/core/activity.ts
  • apps/player-linux/src/core/api.ts
  • apps/player-linux/src/core/backoff.test.ts
  • apps/player-linux/src/core/backoff.ts
  • apps/player-linux/src/core/commands.test.ts
  • apps/player-linux/src/core/commands.ts
  • apps/player-linux/src/core/config.ts
  • apps/player-linux/src/core/content-render.test.ts
  • apps/player-linux/src/core/content-types.ts
  • apps/player-linux/src/core/datasource.ts
  • apps/player-linux/src/core/download.test.ts
  • apps/player-linux/src/core/download.ts
  • apps/player-linux/src/core/format.test.ts
  • apps/player-linux/src/core/format.ts
  • apps/player-linux/src/core/layout-render.ts
  • apps/player-linux/src/core/log.ts
  • apps/player-linux/src/core/manifest.test.ts
  • apps/player-linux/src/core/manifest.ts
  • apps/player-linux/src/core/pairing.ts
  • apps/player-linux/src/core/player.ts
  • apps/player-linux/src/core/presentation-render.ts
  • apps/player-linux/src/core/preview.ts
  • apps/player-linux/src/core/qr.ts
  • apps/player-linux/src/core/render-tree.ts
  • apps/player-linux/src/core/schedule.test.ts
  • apps/player-linux/src/core/schedule.ts
  • apps/player-linux/src/core/selection.test.ts
  • apps/player-linux/src/core/selection.ts
  • apps/player-linux/src/core/server-url.test.ts
  • apps/player-linux/src/core/server-url.ts
  • apps/player-linux/src/core/socket.ts
  • apps/player-linux/src/core/storage.ts
  • apps/player-linux/src/core/supervisor.test.ts
  • apps/player-linux/src/core/supervisor.ts
  • apps/player-linux/src/core/types.ts
  • apps/player-linux/src/core/widget-render.ts
  • apps/player-linux/src/main/discovery.ts
  • apps/player-linux/src/main/hardware.ts
  • apps/player-linux/src/main/index.ts
  • apps/player-linux/src/preload.ts
  • apps/player-linux/src/renderer/renderer.ts
  • apps/player-linux/static/index.html
  • apps/player-linux/tsconfig.json

Comment on lines +86 to +100
let offset = (await fileSize(partPath)) ?? 0;
if (offset > request.expectedSizeBytes) {
await fs.rm(partPath, { force: true });
offset = 0;
}

const headers: Record<string, string> = { ...request.headers };
if (offset > 0 && request.etag) {
headers["Range"] = `bytes=${offset}-`;
headers["If-Range"] = request.etag;
} else if (offset > 0) {
// Without a validator we cannot safely resume.
await fs.rm(partPath, { force: true });
offset = 0;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the relevant file with line numbers around the cited range.
sed -n '1,220p' apps/player-linux/src/core/download.ts | cat -n

# Locate any handling of HTTP 416 / retryable download errors in this file.
rg -n "416|Range Not Satisfiable|retryable|DownloadError|expectedSizeBytes|If-Range|Range:" apps/player-linux/src/core/download.ts

# Inspect any callers / helpers that decide whether to retry download errors.
rg -n "DownloadError|retryable|download\(" apps/player-linux/src/core

Repository: Gibsonmb71/tilecast

Length of output: 9349


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '240,340p' apps/player-linux/src/core/manifest.ts | cat -n

rg -n "retryable|DownloadError|416|downloadVerified|variant downloaded|retry" apps/player-linux/src/core/manifest.ts apps/player-linux/src/core/download.test.ts

Repository: Gibsonmb71/tilecast

Length of output: 4858


Handle complete .part files before resuming

If destination.part already matches expectedSizeBytes, this path still sends Range: bytes=<size>-. On a compliant server that returns 416, the error is treated as retryable and the manifest sync keeps retrying against the same stale .part. Delete or promote a full .part before issuing the ranged request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/download.ts` around lines 86 - 100, The
partial-file handling before header construction must treat an existing .part
whose size equals request.expectedSizeBytes as complete: promote it to the
destination or remove it before any ranged request is issued. Update the
offset/resume logic near fileSize(partPath) so Range is only set for offsets
strictly below the expected size, while preserving oversized-file cleanup and
validator requirements.

throw new DownloadError("empty response body", true);
}

const handle = await fs.open(partPath, offset > 0 ? "r+" : "w", 0o600);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Create the destination directory before opening the .part file.

fs.open(partPath, …) at Line 125 writes into dirname(request.destination), but that directory is only created at Line 162 — after the write. The first download into a not-yet-existing cache subdirectory throws a raw ENOENT (not a DownloadError) at Line 125, so the manifest pipeline sees an unclassified failure instead of a retryable one. The recursive: true mkdir already signals that subdirectories aren't guaranteed to exist.

🐛 Move the mkdir ahead of the part-file open
+  await fs.mkdir(path.dirname(request.destination), { recursive: true });
   const handle = await fs.open(partPath, offset > 0 ? "r+" : "w", 0o600);
-  await fs.mkdir(path.dirname(request.destination), { recursive: true });
   await fs.rename(partPath, request.destination);

Also applies to: 162-163

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/download.ts` at line 125, Move the recursive
destination-directory creation from after the write in the download flow to
before fs.open(partPath, …), ensuring dirname(request.destination) exists before
opening the .part file. Remove the now-late mkdir block while preserving its
existing recursive behavior and permissions.

Comment on lines +44 to +55
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
// Date-only string like "2026-07-20": parse as local calendar date.
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!m) {
return value;
}
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return formatInstant(d, "UTC", style);
}
return formatInstant(new Date(parsed), timezone, style);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Date-only branch is unreachable; date-only values can shift a day.

Date.parse("2026-07-20") returns a finite value (parsed as UTC midnight), so the date-only regex branch at Lines 47-52 never executes for ISO date strings. The value then flows through formatInstant with the caller's timezone, so a date-only string can render as the previous day in negative-offset zones (e.g. America/New_York). Detect the date-only form before calling Date.parse.

🐛 Proposed fix
 ): string {
-  const parsed = Date.parse(value);
-  if (!Number.isFinite(parsed)) {
-    // Date-only string like "2026-07-20": parse as local calendar date.
-    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
-    if (!m) {
-      return value;
-    }
-    const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
-    return formatInstant(d, "UTC", style);
-  }
+  // Date-only string like "2026-07-20": pin to UTC so it never shifts a day.
+  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
+  if (m) {
+    const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
+    return formatInstant(d, "UTC", style);
+  }
+  const parsed = Date.parse(value);
+  if (!Number.isFinite(parsed)) {
+    return value;
+  }
   return formatInstant(new Date(parsed), timezone, style);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
// Date-only string like "2026-07-20": parse as local calendar date.
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (!m) {
return value;
}
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return formatInstant(d, "UTC", style);
}
return formatInstant(new Date(parsed), timezone, style);
}
// Date-only string like "2026-07-20": pin to UTC so it never shifts a day.
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim());
if (m) {
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return formatInstant(d, "UTC", style);
}
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) {
return value;
}
return formatInstant(new Date(parsed), timezone, style);
}
🧰 Tools
🪛 OpenGrep (1.25.0)

[ERROR] 47-47: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/format.ts` around lines 44 - 55, Update the date
parsing flow in the format function to detect the date-only regex form before
calling Date.parse. Route valid date-only values through the existing UTC-based
formatInstant path, while preserving the current timezone-aware handling for
other values and the existing fallback for invalid inputs.

Comment on lines +106 to +139
// Website fallback images must be verified before activation.
for (const website of manifest.websites ?? []) {
if (website.fallbackImageAssetId) {
addAsset(website.fallbackImageAssetId, website.fallbackVariantId);
}
}

// Layout-referenced images (asset placements and canvas backgrounds) must
// be cached so a Layout renders correctly offline. Videos inside layout
// zones follow their own playlist item's delivery policy, handled above.
const layouts = [
...(manifest.layouts ?? []),
...(manifest.layout ? [manifest.layout] : []),
] as Array<{ document?: { canvas?: { backgroundAssetId?: string | null }; placements?: Array<{ type: string; assetId?: string | null }> } }>;
for (const layout of layouts) {
const document = layout?.document;
if (!document) {
continue;
}
if (document.canvas?.backgroundAssetId) {
addAsset(document.canvas.backgroundAssetId, null);
}
for (const placement of document.placements ?? []) {
if (placement.type === "asset" && placement.assetId) {
// Only images are required upfront; a zone video may stream.
const variant = manifest.assets.find(
(a) => a.assetId === placement.assetId && a.mimeType.startsWith("image/"),
);
if (variant) {
addAsset(placement.assetId, variant.variantId);
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

requiredDownloads misses manifest.directFallbackLayout, breaking offline rendering for that fallback.

The playlists scan explicitly includes both the direct playlist and its fallback (manifest.playlist, manifest.directFallbackPlaylist, plus manifest.playlists), but the layouts scan only includes manifest.layouts and manifest.layout — manifest.directFallbackLayout (used by resolveSelection in schedule.ts as manifest.layout ?? manifest.directFallbackLayout) is never scanned for required background/placement images. If a screen ever falls back to directFallbackLayout, its images will not have been downloaded and verified ahead of activation, so it can render with missing images offline — contradicting the docstring's "Layout renders correctly offline" guarantee.

🐛 Proposed fix
   const layouts = [
     ...(manifest.layouts ?? []),
     ...(manifest.layout ? [manifest.layout] : []),
+    ...(manifest.directFallbackLayout ? [manifest.directFallbackLayout] : []),
   ] as Array<{ document?: { canvas?: { backgroundAssetId?: string | null }; placements?: Array<{ type: string; assetId?: string | null }> } }>;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Website fallback images must be verified before activation.
for (const website of manifest.websites ?? []) {
if (website.fallbackImageAssetId) {
addAsset(website.fallbackImageAssetId, website.fallbackVariantId);
}
}
// Layout-referenced images (asset placements and canvas backgrounds) must
// be cached so a Layout renders correctly offline. Videos inside layout
// zones follow their own playlist item's delivery policy, handled above.
const layouts = [
...(manifest.layouts ?? []),
...(manifest.layout ? [manifest.layout] : []),
] as Array<{ document?: { canvas?: { backgroundAssetId?: string | null }; placements?: Array<{ type: string; assetId?: string | null }> } }>;
for (const layout of layouts) {
const document = layout?.document;
if (!document) {
continue;
}
if (document.canvas?.backgroundAssetId) {
addAsset(document.canvas.backgroundAssetId, null);
}
for (const placement of document.placements ?? []) {
if (placement.type === "asset" && placement.assetId) {
// Only images are required upfront; a zone video may stream.
const variant = manifest.assets.find(
(a) => a.assetId === placement.assetId && a.mimeType.startsWith("image/"),
);
if (variant) {
addAsset(placement.assetId, variant.variantId);
}
}
}
}
// Website fallback images must be verified before activation.
for (const website of manifest.websites ?? []) {
if (website.fallbackImageAssetId) {
addAsset(website.fallbackImageAssetId, website.fallbackVariantId);
}
}
// Layout-referenced images (asset placements and canvas backgrounds) must
// be cached so a Layout renders correctly offline. Videos inside layout
// zones follow their own playlist item's delivery policy, handled above.
const layouts = [
...(manifest.layouts ?? []),
...(manifest.layout ? [manifest.layout] : []),
...(manifest.directFallbackLayout ? [manifest.directFallbackLayout] : []),
] as Array<{ document?: { canvas?: { backgroundAssetId?: string | null }; placements?: Array<{ type: string; assetId?: string | null }> } }>;
for (const layout of layouts) {
const document = layout?.document;
if (!document) {
continue;
}
if (document.canvas?.backgroundAssetId) {
addAsset(document.canvas.backgroundAssetId, null);
}
for (const placement of document.placements ?? []) {
if (placement.type === "asset" && placement.assetId) {
// Only images are required upfront; a zone video may stream.
const variant = manifest.assets.find(
(a) => a.assetId === placement.assetId && a.mimeType.startsWith("image/"),
);
if (variant) {
addAsset(placement.assetId, variant.variantId);
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/manifest.ts` around lines 106 - 139, Update the
layouts collection in requiredDownloads to also include
manifest.directFallbackLayout, alongside manifest.layouts and manifest.layout.
Preserve the existing background and image-placement scanning so layouts
selected through the direct fallback path have their required assets added.

Comment on lines +592 to +608
evaluatePresentation(force = false): void {
const next = this.buildPresentation();
const key = JSON.stringify(next);
if (!force && key === this.lastPresentedKey) {
return;
}
this.lastPresentedKey = key;
if (next.state === "playing") {
this.generation += 1;
this.playbackState = "playing";
this.host.present({ ...next, generation: this.generation });
} else {
this.playbackState = next.state;
this.host.present(next);
}
}
private lastPresentedKey = "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Generation in the dedup key defeats deduplication — playing content restarts every ~30s.

buildPresentation() embeds generation: this.generation in the playing object, and evaluatePresentation() increments this.generation on every playing present. So on the next SELECTION_EVAL_INTERVAL tick (and every supervisorTick reactivation) the JSON key differs only because generation changed, the early-return never fires, and an identical presentation is re-sent with a bumped generation. startPlaying() in the renderer resets index = 0, so a running playlist restarts from the first item roughly every 30 seconds.

Normalize generation out of the comparison key so unchanged content dedups:

🐛 Proposed fix
   evaluatePresentation(force = false): void {
     const next = this.buildPresentation();
-    const key = JSON.stringify(next);
+    // generation is a presentation-instance counter, not content; exclude it
+    // from the key so identical playing content does not re-present each tick.
+    const key = JSON.stringify(
+      next.state === "playing" ? { ...next, generation: 0 } : next,
+    );
     if (!force && key === this.lastPresentedKey) {
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
evaluatePresentation(force = false): void {
const next = this.buildPresentation();
const key = JSON.stringify(next);
if (!force && key === this.lastPresentedKey) {
return;
}
this.lastPresentedKey = key;
if (next.state === "playing") {
this.generation += 1;
this.playbackState = "playing";
this.host.present({ ...next, generation: this.generation });
} else {
this.playbackState = next.state;
this.host.present(next);
}
}
private lastPresentedKey = "";
evaluatePresentation(force = false): void {
const next = this.buildPresentation();
// generation is a presentation-instance counter, not content; exclude it
// from the key so identical playing content does not re-present each tick.
const key = JSON.stringify(
next.state === "playing" ? { ...next, generation: 0 } : next,
);
if (!force && key === this.lastPresentedKey) {
return;
}
this.lastPresentedKey = key;
if (next.state === "playing") {
this.generation += 1;
this.playbackState = "playing";
this.host.present({ ...next, generation: this.generation });
} else {
this.playbackState = next.state;
this.host.present(next);
}
}
private lastPresentedKey = "";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/player.ts` around lines 592 - 608, Update
evaluatePresentation so its deduplication key excludes the playing
presentation’s changing generation value, while preserving generation increments
for actual playing presentations. Use a normalized copy of buildPresentation()
for comparison, then continue presenting the original next value with the
current generation; unchanged content must return early without re-presenting.

Comment on lines +41 to +46
function addDays(date: string, days: number): string {
const [y, m, d] = date.split("-").map(Number);
const dt = new Date(Date.UTC(y!, m! - 1, d!));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard addDays against malformed dates.

custom_range feeds customEnd (config-derived) into addDays. A non-YYYY-MM-DD value yields NaN components, making Date.UTC(...) an Invalid Date and toISOString() throw a RangeError, which is unguarded in windowForMode and aborts selection.

🛡️ Proposed guard
 function addDays(date: string, days: number): string {
   const [y, m, d] = date.split("-").map(Number);
+  if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
+    return date;
+  }
   const dt = new Date(Date.UTC(y!, m! - 1, d!));
   dt.setUTCDate(dt.getUTCDate() + days);
   return dt.toISOString().slice(0, 10);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function addDays(date: string, days: number): string {
const [y, m, d] = date.split("-").map(Number);
const dt = new Date(Date.UTC(y!, m! - 1, d!));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}
function addDays(date: string, days: number): string {
const [y, m, d] = date.split("-").map(Number);
if (!Number.isFinite(y) || !Number.isFinite(m) || !Number.isFinite(d)) {
return date;
}
const dt = new Date(Date.UTC(y!, m! - 1, d!));
dt.setUTCDate(dt.getUTCDate() + days);
return dt.toISOString().slice(0, 10);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/selection.ts` around lines 41 - 46, Update addDays
to validate the input date and resulting Date before calling toISOString,
handling malformed or non-YYYY-MM-DD values without throwing. Ensure
windowForMode’s custom_range selection remains resilient when customEnd is
invalid, using the existing fallback or empty-result behavior rather than
propagating a RangeError.

Comment on lines +25 to +40
it("allows http on private ranges", () => {
for (const host of [
"http://192.168.1.10",
"http://10.0.0.5:8080",
"http://172.16.4.4",
"http://localhost:3000",
"http://tilecast.local",
"http://127.0.0.1",
]) {
expect(normalizeServerUrl(host).ok).toBe(true);
}
});

it("treats 172.32 as public (outside the /12)", () => {
expect(normalizeServerUrl("http://172.32.0.1").ok).toBe(false);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
node -e 'console.log(new URL("http://[::1]").hostname)'

Repository: Gibsonmb71/tilecast

Length of output: 163


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## server-url.ts\n'
git ls-files 'apps/player-linux/src/core/server-url.ts' 'apps/player-linux/src/core/server-url.test.ts'
printf '\n## outline\n'
ast-grep outline apps/player-linux/src/core/server-url.ts --view expanded || true
printf '\n## relevant excerpts\n'
nl -ba apps/player-linux/src/core/server-url.ts | sed -n '1,220p'
printf '\n## tests excerpt\n'
nl -ba apps/player-linux/src/core/server-url.test.ts | sed -n '1,220p'

Repository: Gibsonmb71/tilecast

Length of output: 606


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from urllib.parse import urlparse
for u in ["http://[::1]", "http://[fc00::1]", "http://fcevil.example", "http://[fcevil.example]"]:
    try:
        p = urlparse(u)
        print(u, "hostname=", p.hostname, "netloc=", p.netloc)
    except Exception as e:
        print(u, "ERR", e)
PY

Repository: Gibsonmb71/tilecast

Length of output: 412


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## server-url.ts excerpt\n'
sed -n '1,140p' apps/player-linux/src/core/server-url.ts

printf '\n## server-url.test.ts excerpt\n'
sed -n '1,120p' apps/player-linux/src/core/server-url.test.ts

Repository: Gibsonmb71/tilecast

Length of output: 4800


Cover hostname-prefix HTTP bypasses. isPrivateOrLocalHost treats any hostname starting with fc, fd, or fe80 as local, so public names like fcevil.example can be downgraded to HTTP. Use validated IPv6 address/range checks instead of prefix matching.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/server-url.test.ts` around lines 25 - 40, Update
isPrivateOrLocalHost and its tests to validate IPv6 addresses using proper
parsing and private/link-local range checks instead of hostname prefix matching
for fc, fd, or fe80. Preserve HTTP allowance for genuine private/local hosts
while rejecting public names such as fcevil.example from HTTP downgrade.

Comment on lines +23 to +26
// IPv6 loopback / unique-local / link-local.
if (h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80")) {
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

IPv6 prefix checks leak to DNS hostnames, weakening HTTPS enforcement.

startsWith("fc" | "fd" | "fe80") is applied to every hostname, not just IPv6 literals. A routable public name like fd-server.example.com or fcbank.com is classified as private/LAN-scoped, so Line 80 permits plain http://, and the device credential can cross the network in the clear — defeating the stated policy. Gate these prefixes on an actual IPv6 literal.

🔒 Proposed fix
-  // IPv6 loopback / unique-local / link-local.
-  if (h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80")) {
-    return true;
-  }
+  // IPv6 loopback / unique-local / link-local. Only an IPv6 literal (which
+  // contains ':') may match these prefixes; a DNS name that merely starts
+  // with "fc"/"fd"/"fe80" must not be treated as LAN-scoped.
+  if (h.includes(":")) {
+    if (h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80")) {
+      return true;
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// IPv6 loopback / unique-local / link-local.
if (h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80")) {
return true;
}
// IPv6 loopback / unique-local / link-local. Only an IPv6 literal (which
// contains ':') may match these prefixes; a DNS name that merely starts
// with "fc"/"fd"/"fe80" must not be treated as LAN-scoped.
if (h.includes(":")) {
if (h === "::1" || h.startsWith("fc") || h.startsWith("fd") || h.startsWith("fe80")) {
return true;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/server-url.ts` around lines 23 - 26, Restrict the
IPv6 prefix checks in the hostname classification logic to actual IPv6 literals,
rather than applying startsWith("fc"), startsWith("fd"), or startsWith("fe80")
to arbitrary DNS names. Update the relevant helper around the "::1" check to
validate IPv6-literal syntax before recognizing unique-local or link-local
prefixes, while preserving existing handling for valid IPv6 loopback and scoped
addresses.

Comment on lines +475 to +477
function textLeftGrow(value: string, color: string, fontSize: number): RenderNode {
return { t: "text", value, style: { color, fontSize, align: "left", ...({ } as object) } };
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

textLeftGrow never sets grow, so table headers misalign with rows.

The ...({} as object) spread is a no-op placeholder and no grow is applied. Row cells set grow: 1 (Line 457) while header cells built here do not, so header columns size to content and drift out of alignment with the data rows. The name implies the growing behavior was intended.

🐛 Proposed fix
 function textLeftGrow(value: string, color: string, fontSize: number): RenderNode {
-  return { t: "text", value, style: { color, fontSize, align: "left", ...({ } as object) } };
+  return { t: "text", value, style: { color, fontSize, align: "left", grow: 1 } };
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function textLeftGrow(value: string, color: string, fontSize: number): RenderNode {
return { t: "text", value, style: { color, fontSize, align: "left", ...({ } as object) } };
}
function textLeftGrow(value: string, color: string, fontSize: number): RenderNode {
return { t: "text", value, style: { color, fontSize, align: "left", grow: 1 } };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/core/widget-render.ts` around lines 475 - 477, Update
textLeftGrow to set grow: 1 in the returned text style, replacing the no-op
empty-object spread. Preserve the existing color, fontSize, and left alignment
so header cells use the same growing layout behavior as row cells.

Comment on lines +60 to +63
app.commandLine.appendSwitch(
"max-gum-fps",
process.env.TILECAST_MAX_FPS ?? "30",
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Chromium --max-gum-fps switch what does it control

💡 Result:

The Chromium command-line switch --max-gum-fps is used to override the maximum frame rate for media capture devices in calls to the getUserMedia API [1][2]. In the Chromium source code, it is defined as kWebRtcMaxCaptureFramerate, and it is primarily intended for testing or debugging scenarios where you need to force a specific frame rate limit [1][3]. The flag requires a numerical value to be specified, representing the desired frame rate (e.g., --max-gum-fps=17.5) [1][4]. "GUM" in the flag name stands for "GetUserMedia."

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== hardware.ts ==\n'
cat -n apps/player-linux/src/main/hardware.ts | sed -n '1,140p'

printf '\n== search TILECAST_MAX_FPS ==\n'
rg -n "TILECAST_MAX_FPS|max-gum-fps|max gum fps|max-gum" -S .

printf '\n== likely docs ==\n'
rg -n "fps|frame rate|frame-rate|capture" -S README.md docs apps/player-linux package.json .github || true

Repository: Gibsonmb71/tilecast

Length of output: 10788


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '== apps/player-linux/src/main/hardware.ts outline ==\n'
ast-grep outline apps/player-linux/src/main/hardware.ts --view expanded || true

Repository: Gibsonmb71/tilecast

Length of output: 370


max-gum-fps doesn’t cap rendering FPS
max-gum-fps only limits getUserMedia capture, so this won’t throttle signage playback or the compositor. If the goal is to cap frame rate, switch to a render-loop/animation throttle or rename TILECAST_MAX_FPS and the comment/README to match the actual behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/player-linux/src/main/hardware.ts` around lines 60 - 63, The max-gum-fps
switch in the hardware initialization only limits getUserMedia capture, not
signage rendering or compositor frame rate. Replace this configuration with a
render-loop or animation-frame throttle to enforce the intended FPS cap;
alternatively, rename TILECAST_MAX_FPS and update its related comments and
README to accurately describe capture-only behavior.

@gbyo
gbyo force-pushed the gibby/linux-player-build-6982b0 branch from 95d40b0 to 4029fee Compare July 20, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant