Turns the Raspberry Pi script from a basic Chromium kiosk launcher
into a full installer with two modes:
- All-in-One: installs Node.js, clones the repo, runs the server
on port 3001, and launches the kiosk pointing at localhost. One
Pi does everything.
- Player-Only: connects to an existing server; same kiosk behavior
as before but with better Chromium flags and crash-flag cleanup.
Other changes:
- Detects Pi OS Lite vs Desktop and adjusts strategy (startx + vt1
for Lite, plain kiosk launcher for Desktop)
- Auto-login on tty1 for Lite installs
- GPU memory, overscan, console-blanking, and watchdog tweaks
- screentinker-{status,update,logs} management commands
- MOTD with command hints
- Cleans up the legacy remotedisplay.service / kiosk script on
upgrade so old installs migrate cleanly
- set -euo pipefail, root check, architecture check, tee'd log at
/var/log/screentinker-setup.log
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- README + landing page footer now link to the community Discord
- Landing page feature grid gains Playlists, Directory Board,
Offline Resilience, and Mobile Dashboard cards; Scheduling and
Self-Hosted copy updated to mention group-level schedules and
the DISABLE_REGISTRATION env var
- Structured data featureList expanded to match; Organization
sameAs now includes Discord
- README feature list clarifies scheduling precedence, mobile
responsiveness scope, and the auth/IDOR/XSS audit work
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The JWT only carries { id, email, role } and the server reads plan_id
fresh from the DB per request, but the frontend cached the user object
in localStorage at login and never refreshed it. After an admin changed
a user's plan, the dashboard kept rendering the old plan until the
user logged out and back in.
Added api.getMe() and a refreshCurrentUser() helper that runs at
startup and on every hashchange. Settings page now fetches the user
fresh via api.getMe() on render, with localStorage as fallback.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Root cause: the Settings page loaded /api/white-label into the form
inputs but never applied the saved values (primary_color, bg_color,
brand_name, favicon, custom_css) to the actual document. Nothing in
app.js bootstrap touched branding. So the save hit the DB correctly,
reload kept the DB value correctly, but the page always rendered the
hardcoded defaults from css/variables.css and the static "ScreenTinker"
label in index.html — which looked like the save had reverted.
Fix: new frontend/js/branding.js module that fetches /api/white-label
once at startup (app.js) and applies values to:
- --accent and --bg-primary CSS vars
- document.title and the .sidebar-header .logo span text
- all <link rel="icon">/apple-touch-icon hrefs
- a <style id="wl-custom-css"> tag for custom_css
- the theme-color meta tag
Settings save now calls resetBranding() after POST so changes apply
immediately without a reload.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.
GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Extend the public /api/content/:id/file gate to unlock content referenced
by widgets (previously only playlists unlocked it), so device browsers
and kiosk iframes can fetch logos and background images that widgets
embed.
Security: scope the widget lookup to the content owner's widgets only
(w.user_id = content.user_id). Otherwise a user could unlock another
user's content file by creating their own widget whose config references
the victim's content UUID. The pre-existing playlist gate has the same
shape and is left for a separate fix.
Also adds a 30/min rate limit on POST /api/widgets/preview, which
inlines user content as base64 and is memory-intensive.
Perf note: the widgets.config LIKE scan is O(n). Fine at current scale;
revisit with a content_widget_refs join table if the widget table grows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inline editor with:
- Collapsible categories, reorder up/down, delete
- Entries with identifier, name, subtitle, available toggle
- Add/remove with auto-focus on new row
- Empty state prompts first category
- Theme, scroll speed, column count selectors
- Reusable content picker (single/multi-select) against user's image library
- Logo picker + background image picker (multi) via that picker
- Preview button posts unsaved config to /widgets/preview and shows the
returned HTML in a modal iframe (srcdoc + injected <base> so relative
content URLs resolve against our origin)
- Delete confirms with widget name
Also escapes w.name / typeMeta.name / w.id in the widget grid to prevent
stored XSS against admins viewing other users' widgets.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lobby-style tenant/room directory with vertical marquee, seamless loop via
content cloning, pixel shift + bg pulse for anti-burn-in, rotating background
images with crossfade. Supports logo, title, footer, subtitles per entry,
and Available (green) state. All user strings rendered via textContent in
browser — no server-side HTML escaping of entries needed.
Also refactors render dispatch into renderWidgetHtml() and adds a POST
/preview endpoint that inlines user-owned image content as base64 data
URIs so the editor can preview unsaved widgets. Preview is gated by:
- image/* MIME only
- 10 MB size cap
- user_id ownership check
- path traversal guard via basename + resolve
Unknown widget_type on /preview returns 400.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Retarget primary keywords ("open-source", "CMS") in title, description,
OG/Twitter tags and hero h1
- Swap OG/Twitter image from icon-512 to dashboard-preview.png with
width/height/alt metadata
- Add GitHub link in nav (icon), hero (secondary btn), footer, and a
new Open Source callout section
- Wrap content in <main> landmark; add width/height on screenshot for
CLS; add third-party license page to sitemap; Organization schema
sameAs now points to the GitHub repo
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Swaps the live-app iframe for a static PNG of the Displays view.
Faster load, no auth flash, looks sharp.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Removes the far-right floating position; Sign In sits in the nav
cluster alongside the CTA instead of pinned to the viewport edge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign In now lives outside the nav-links cluster with margin-left:auto,
pinning it to the top-right corner with visible separation from the
primary CTA.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pixel 8 Pro portrait (~412px) was clipping Sign In because logo + both
buttons overflowed. Hide logo text below 420px, shorten 'Start Free Trial'
to 'Try Free' on mobile, nowrap nav-links with tighter padding.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously hidden behind the primary CTA; now shows alongside it with
tighter padding on small screens.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a per-user Account section in Settings with name edit and password
change. Password change requires current password; local auth only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/reset-admin.js signed a JWT with a synthetic id ("recovery-XXX")
and instructed the operator to paste it into localStorage. But the
requireAuth middleware always SELECTs the user row by id, so every
authed API call under the recovery token returned 401 "User not found"
and the recovery flow was effectively dead.
Fix:
- reset-admin.js now sets a `recovery: true` claim on the JWT.
- requireAuth / optionalAuth short-circuit the DB lookup when
decoded.recovery === true and synthesize a req.user record in
memory (role: admin, plan_id: enterprise). The synthetic user is
never persisted, so FK-constrained writes that expect a real
user (creating devices, etc.) will still fail — which is fine,
recovery is only meant to let the operator reset a password or
create a fresh admin via the Settings UI.
Security: a recovery token still requires the jwtSecret to sign,
so only someone with filesystem access to the server can mint one.
Token TTL remains 1h.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Login view:
- Remove `margin-left: calc(-1 * var(--sidebar-width))` from the
centering wrapper. It was a hack to compensate for the sidebar
offset, but app.js already zeros the app margin on the login
route. On mobile this was pushing the login card ~240px off
the left edge of the viewport.
- Use min-height + padding so the card breathes on short screens.
- Drop inline font-size:11px on the support-token input so the
global .input 16px mobile rule applies (iOS focus-zoom prevention).
app.js:
- Hide the mobile hamburger button on the login route; it has no
function there since the sidebar is already hidden.
Landing page:
- Scope the old blanket `.nav-links { display: none }` to hide only
the section anchors + secondary Sign In button, so the primary
"Start Free Trial" CTA stays visible on mobile.
- Wrap the 5-column Compare table in a horizontal-scroll container
and set min-width:560px so it scrolls instead of overflowing
the page.
- Add min-height:44px to .btn on mobile, tighten section padding
to 16px (from 24px) so content doesn't feel cramped against
the viewport edge.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Inline width:NNNpx beats the .modal { width: 95vw } mobile rule due to
specificity. Convert to max-width:NNNpx;width:95vw on the three affected
modals so they cap at their desktop size but still shrink on mobile:
- playlists.js add-item modal (560px)
- device-detail.js assign-playlist modal (650px)
- content-library.js edit-content modal (500px)
Same fix pattern for fixed-width form controls flagged in QA — selects
and inputs change to max-width:NNNpx;width:100% so they keep their
desktop size but shrink to container on mobile:
- admin.js role/plan selects (120/130px)
- teams.js member role + add-device selects (100/200px)
- content-library.js search input + folder filter (250/180px)
- onboarding.js pairing code + display name inputs (240px)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Toast now announces via role="status"/aria-live="polite" by default,
and role="alert"/aria-live="assertive" for errors. Screen readers
previously got nothing when notifications appeared.
- Move playlist-item flex-wrap:wrap from inline style into the
@media (max-width: 768px) block so desktop rows don't wrap controls
when the viewport is intermediate-narrow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds accessible up/down arrow buttons alongside the existing drag-to-
reorder handle on each playlist item. Touch users (and keyboard users)
now have a reliable way to re-order without relying on HTML5 drag-drop,
which is effectively unusable on mobile. First/last items have the
respective arrow disabled.
Uses the same /reorder API the drag handler uses, so behavior stays
consistent. flex-wrap on the item container prevents control overflow
on narrow screens.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Wrap wide tables (admin, settings, reports) in .table-wrap with
min-width on the table so they scroll horizontally on narrow screens
instead of collapsing rows.
- Add global .table-wrap { overflow-x: auto } utility.
- Mobile: add mask-image fade on .tabs right edge to hint scrollability
when tabs overflow; flex-shrink:0 on .tab keeps labels intact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Buttons: min-height 44px (36px for .btn-sm, 40px for .btn-icon) on mobile
- Inputs/selects/textarea: font-size 16px (prevents iOS focus zoom), min-height 44px
- Pairing input: scaled letter-spacing down so 6 digits fit at 375px width
- Modals at 95vw: tighter header/body/footer padding so content breathes
- Toast container: bar-style full-width (left/right:12px) instead of
fixed-right 280px that clipped below 400px viewports
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Move hamburger click + backdrop click out of inline onclick into app.js
- Add aria-label/aria-expanded/aria-controls to hamburger button
- Close drawer on Escape keypress
- Bump hamburger button to 44px, nav-link min-height to 44px (tap targets)
- Bump .content top padding to 68px on mobile to match 44px hamburger
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The CREATE INDEX on schedules(group_id) in schema.sql ran before the
phase4 migration added the group_id column, crashing on existing databases.
Move the index creation to the migration which already handles it.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Phase 4 group scheduling: schema migration adds group_id to schedules with
CHECK constraint, scheduler evaluates group+device schedules with priority,
group deletion converts schedules to per-device copies. Dashboard gets
playlist assignment dropdown and current playlist label on group headers.
Player persists audio unlock state in localStorage so version reloads
don't lose audio on unattended displays.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Player polls /api/version every 30s and reloads if the hash changes.
Server hash now includes player/index.html and sw.js so player code
updates are detected without requiring a hard refresh.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Clear pending advance timers when switching content items to prevent stale
image/widget duration timers from interrupting video playback. Also skip
showing "Connecting..." overlay when cached playlist is already playing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The SW was causing "unexpected error" on video/image fetches due to
range request handling, opaque response caching, and stale SW races.
Fix: SW now ONLY caches player page + socket.io JS for offline boot.
Content files are left to browser native HTTP cache (server already
sets Cache-Control: public, max-age=2592000, immutable).
Also: auto-reload player when new SW activates so deploys take effect
immediately without manual hard refresh.
Bumped cache to v5 — activate purges all old caches (including the
broken rd-content-v1 content cache).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bug 1 (SW): Rewrote service worker fetch handler:
- Skip range requests (video seeking) to avoid caching partial responses
- Skip non-GET requests entirely
- Use ignoreSearch on cache match to avoid query-param misses
- Don't cache opaque cross-origin responses
- Outer catch on Cache API failures
- Don't intercept catch-all requests (let browser handle natively)
- Bump cache version to v4 to purge broken cached responses
Bug 2 (auth): Playlist refresh register was missing device_token,
causing auth rejection every 5 minutes. Fixed by including token
in the refresh-register emit. Added diagnostic logging on both
client and server for token validation failures.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Web player:
- Cache playlist JSON to localStorage on every update
- Restore and start playing immediately on boot before connecting
- Clear cache on unpair/reset
Android app:
- Cache playlist JSON to EncryptedSharedPreferences on every update
- Restore cached playlist on cold-start, play from disk-cached content
- Update cache on content deletion, clear on unpair
Server (device socket):
- Fingerprint reconnect: issue fresh token instead of rejecting
- Send device:paired on fingerprint recovery for claimed devices
- Add status logging and dashboard notification on fingerprint reconnect
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Security fixes:
- Critical: Add ownership checks to assignments PUT/:id and DELETE/:id (IDOR)
- Critical: Add ownership checks to assignments copy-to endpoint for both devices
- High: Validate device ownership when adding to device groups
- High: UUID-validate content ID before LIKE query + scope to owner's playlists
- Low: Handle FK violations gracefully in playlist discard (deleted content/widgets)
- Low: Escape mime_type with esc() in playlist item display (XSS)
Bug fix:
- Device-detail mutation handlers now reload full page to show draft banner
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove autoPublish from assignments.js and device-groups.js. All item
mutations (add, update, delete, reorder, copy) now call markDraft
regardless of which UI the edit comes from. Users must explicitly
click Publish to push changes to devices.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Schema: add status and published_snapshot columns to playlists table.
Migration snapshots all existing playlists as published (idempotent via schema_migrations).
Devices always receive the published_snapshot, not live playlist_items.
Edits from device-detail/groups auto-publish immediately (display updates instantly).
Edits from playlist detail page go to draft (requires explicit publish).
POST /playlists/:id/publish snapshots and pushes to all devices.
POST /playlists/:id/discard reverts playlist_items from published snapshot.
Content deletion scrubs references from all published snapshots.
Frontend: draft badge in playlist list, prominent yellow banner with publish/discard
buttons on playlist detail and device detail pages.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Follows up on the security audit remediation (afbe113) which added
device_token auth to the WebSocket /device namespace.
Android player (ServerConfig.kt, WebSocketService.kt):
- Persist device_token in EncryptedSharedPreferences alongside device_id
- Send device_token in device:register on reconnect and playlist refresh
- Save/overwrite token from device:registered response (handles legacy
devices getting their first token)
- Handle device:auth-error by clearing credentials and showing pairing screen
- clearDeviceCredentials() method wipes device_id, device_token, is_paired
Web player (player/index.html):
- Save deviceToken in localStorage config from device:registered response
- Send device_token in register() payload on reconnect
- Handle device:auth-error and device:unpaired events — clear config and
show re-pair UI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New schema_migrations table (id TEXT PK, ran_at INTEGER) tracks which
one-time migrations have executed. The Phase 2 playlist migration now
checks for 'phase2_playlist_migration' in this table instead of
inferring state from devices.playlist_id. Records the migration ID
after successful completion. Eliminates ffprobe overhead on subsequent
startups.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reports devices migrated, total playlist items created, videos probed
via ffprobe, and existing schedules. Helps verify the migration ran
correctly on first startup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Migration (database.js): switched from sync execFileSync to async execFile
with promise wrapper, matching the pattern in playlists.js. Probes each
video content item, backfills content.duration_sec, and uses the real
duration in playlist_items. Falls back to the assignment's original
duration_sec if the probe fails or content isn't a video.
V1 import (status.js): moved assignment-to-playlist conversion out of the
synchronous db.transaction() so async ffprobe can run. Content files are
already on disk from the transaction, so probing works. Same fallback logic.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Videos were getting the default 10s from the assignments table. Now ffprobe
runs for each video content item during migration, backfills the content
table, and uses the real duration in playlist_items.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Users need to see migrated playlists immediately. The toggle still
allows hiding them if desired.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Devices can now switch between playlists via a dropdown in the playlist
section header. Populates from getPlaylists API, shows auto-generated
label and item count. Selection triggers assignPlaylistToDevice and
refreshes the content list.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
List view: auto-generated playlists hidden by default with toggle checkbox.
Cards show 'auto' badge and display count. Detail view shows display count
in the header.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
List and detail endpoints now include display_count (devices using this playlist).
New POST /:id/assign endpoint sets a playlist on a device and pushes the update.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Export now includes playlists, playlist_items, and device.playlist_id.
Format bumped to screentinker-export-v2. Schedules include playlist_id.
V1 import converts old assignments array into per-device playlists inline.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Schedule CRUD now includes playlist_id field. List queries join playlist name.
Scheduler tracks active overrides per device and reverts to original
playlist/layout when no schedule is active.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
assign-content now adds content to each device's playlist (auto-creating if needed).
New POST /:id/assign-playlist sets a shared playlist on all group devices.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>