Commit graph

16 commits

Author SHA1 Message Date
ScreenTinker 52ab04204a Preview a rotated display the way people see it, not the way its framebuffer is
#238: the dashboard preview of a 90/270 display was sideways while the panel on the
wall was right — the split that makes a preview useless, because a designer checking
portrait content can no longer tell a real fault from an artefact of the tool.

A portrait panel is a landscape framebuffer that the player rotates content INSIDE
(+90), hung turned the other way (-90); the two cancel and the viewer sees upright
portrait. The dashboard modelled only the first half. It iframed the player into a
box it had already given the finished 9/16 shape, so the player rotated a second time
inside a box that was pretending to be the finished picture, and nothing anywhere
stood in for the mount. Screenshots had the opposite half missing: they are the raw
framebuffer, shown untouched, so every portrait screen looked wrong on the cards and
in Now Playing too.

So each surface now has a stage (the panel's face) and a frame (its framebuffer),
with the frame turned by the INVERSE of the player's angle. Turning it the same way
is the tempting mistake and the worst kind of wrong: 90+90 lands upside-down, which
reads as nearly-right. The dimension swap is not cosmetic either — composing into the
real framebuffer shape is what makes the player lay content out in the same portrait
box the panel uses; hand it a portrait viewport instead and every zone and object-fit
decision is computed for a canvas no panel has.

The geometry is the players' own rule (server/lib/orientation-style.js), served to the
dashboard rather than re-derived, since a second copy of a rotation rule is exactly how
the two came to disagree. Covers the device preview modal, the playlist preview's
portrait toggle (same fault), Now Playing and the device cards. The Remote canvas stays
raw on purpose: taps are sent as fractions of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:38:31 -05:00
ScreenTinker 7747d7e051 Put Members in the nav, reveal titles on touch, and stop a stale heartbeat killing a socket
Three loose ends from the interface review.

Inviting a colleague is a core action and had no entry in the navigation at
all. The only route was an unlabelled icon beside the workspace name, or typing
the URL. There is now a Members item, translated, which resolves to the active
workspace so the static link needs no id. The Teams entry it sits near stays
hidden, since that feature is still switched off.

A native title= is hover-only, so the icon-only buttons — rename a wall, remove
a device from one, manage members — explained themselves on a desktop and said
nothing on a touchscreen. Long-pressing one now shows its label. The text was
already there and already translated; it simply had no way to reach a finger.

The last one is the bug that took a real screen dark. A device row can vanish
while its socket is still heartbeating, and the telemetry insert then failed a
foreign key. That throw was fatal in a way that is hard to guess: the
safe-socket wrapper reads a throwing handler as a broken one and disconnects
the socket server-side, and socket.io deliberately does not retry that kind of
disconnect — so the player sat doing nothing until a person reloaded it. A
heartbeat for a device that no longer exists is an ordinary race, not a fault
worth ending a connection over; the write is skipped and the register path
answers unpaired, which is the reply that actually helps the client recover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-07-28 20:00:35 -05:00
Fabian Mendoza e7483dfc24
feat(ui): show server URL in Add Display modal + GitHub Releases link on /download/apk (#210)
- Add Device modal now shows the server URL and full Smart TV player URL
- Smart TV note changed from bare /player to full URL (dynamic via JS)
- /download/apk error page now includes a download link to GitHub Releases
- i18n keys added in en + es, old smart_tv_note removed
2026-07-23 10:25:00 -05:00
screentinker 501ffb11c1
feat(device-owner): tier foundation + QR provisioning + content-expiry & device enhancements (#168)
Device-owner tier substrate + silent install, end-to-end QR provisioning (Android 12+ compliance, APK-derived checksum, URL pre-seed, zero-touch onboarding, guided a11y screen), content-expiry (#157) with player no-restart deferral, and the device-enhancement batch (#10/#12/#13/#14). Backward-compatible with 1.9.3 clients; all autonomous behaviors opt-in. QA + security review green. Closes #161, #157, #159.
2026-07-12 19:41:07 -05:00
Fabian Mendoza 34f1cb9e7c
feat(dashboard): version indicator + GHCR update check (#165)
Some checks failed
CI / Unit tests (node --test) (push) Has been cancelled
CI / OpenAPI spec lint (push) Has been cancelled
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Has been cancelled
CI / Boot smoke + version check (push) Has been cancelled
* feat(dashboard): version indicator + GHCR update check with admin panel

- Add server/lib/ghcr-check.js: GHCR tag poller (36h cache, semver filter)
- Extend /api/version with latest_version and update_available
- Add POST /api/admin/check-update (force GHCR poll)
- Add POST /api/admin/trigger-update (Docker compose or manual instructions)
- Sidebar footer: version label + amber badge when update available
- Admin > System: version comparison card with Check/Update buttons
- 14 new tests (10 unit + 4 integration), 68/68 passing

Closes #163

* fix(dashboard): gate trigger-update to platform-admin + add GHCR fetch timeout

Review follow-up on #165 (the two blockers):

- trigger-update runs `docker compose up -d` on the HOST via docker.sock
  (root-equivalent) but was behind requireAdmin, i.e. reachable by any
  workspace-level admin. On a multi-tenant host that's a customer, not the infra
  operator. Gate it with requirePlatformAdmin (DOCKER_UPDATE_ENABLED still gates
  it further). check-update stays requireAdmin — it's a read-only GHCR poll.

- ghcr-check.checkNow had no fetch timeout. Node's global fetch has no default
  timeout, so a hung GHCR connection never settled — leaving `inFlight` set
  forever (the finally never ran), which wedged the background poller AND hung
  any awaited checkNow (/api/admin/check-update). Add a 10s AbortController
  timeout on both requests so the try/catch/finally always fire.

All 405 server tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ScreenTinker <hello@screentinker.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:40:06 -05:00
ScreenTinker c5ddb82cba fix(dashboard): device-detail.js parse + runtime errors that killed the whole view
The #150 re-adopt commit (74e7062) left device-detail.js unparseable and, once parsed,
unexecutable — so the entire device-detail view's JS was dead (settings, #150 re-adopt UI, delete):
- SyntaxError at 768: `await api.getContent()` at the top level of the non-async setupActions()
  ("Unexpected reserved word") -> the whole module fails to parse. Fixed with the .then() pattern
  already used by the sibling playlist picker, keeping setupActions synchronous so every listener
  below it (save, #150 re-adopt, delete) still registers immediately (making it async would defer
  them behind the fetch).
- Stray `async` orphaned on its own line (was line 648) before showReAdoptModal's doc comment:
  parses, but executes as the bare identifier statement `async;` -> ReferenceError at module load,
  which would keep the view dead even after the parse fix. Removed it.
Also add <meta name="mobile-web-app-capable"> beside the apple- one (clears the deprecation warning).
Full frontend ES-module parse-scan clean; both bugs were confined to this file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:56:39 -05:00
ScreenTinker 674a34ba45 feat(config): HIDE_BILLING flag to hide the Subscription/billing UI (#116)
Opt-in, default-off UI gate (per strobe's spec; verified his file refs first).
When set, hides the Subscription sidebar item + billing view and bounces
#/billing to the dashboard. Billing shown by default -> existing deployments
unchanged. UI-only: /api/subscription/* untouched (internal usage reads stay).

- config.js: config.hideBilling from HIDE_BILLING (mirrors selfHosted).
- auth.js: surface hide_billing on GET /api/auth/me (client already fetches it
  at boot, stored on the user object).
- index.html: id="billingNavItem" on the Subscription <li> (mirrors adminNavItem).
- app.js: toggle billingNavItem in updateSidebarUser (next to the admin toggle);
  guard #/billing -> history.replaceState('#/') + render dashboard (replaceState
  so the back button doesn't loop into the guard).
- .env.example + README documented.

Spec assumptions verified against code: adminNavItem toggle pattern exists;
/me is fetched at boot and updateSidebarUser runs both at boot (cached user)
and post-/me, so no-flash holds on warm loads (one-time flash possible on the
first load after the flag flips — same as the admin nav, minor); route dispatch
is an if/else chain. Nav label is static (no data-i18n) so no i18n change.

Validated (headless Chrome, both states):
- flag unset -> Subscription tab present, #/billing renders (backward-compat).
- HIDE_BILLING=true -> tab hidden, #/billing redirects to #/.
- config maps HIDE_BILLING both ways; live /me default hide_billing=false.
- 149 server tests green. Default-off = zero change for existing deployments.

Known cosmetic (harmless): after the redirect the billing nav LINK keeps its
'active' class, but the nav item is display:none so it's never visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:19:24 -05:00
ScreenTinker 2de99a12e9 fix(branding): no ScreenTinker default flash on load/switch (#38)
The logo/title/theme/favicon are static 'ScreenTinker' in index.html, and
applyBranding() only overrode them AFTER an async /api/white-label fetch - that
network delay was the flash, on every load and on switch (which reloads).

Now applyBranding caches the resolved white-label per workspace (keyed by the
JWT's current_workspace_id), and a tiny same-origin brand-prime.js loads
render-blocking right after the logo - so it applies the cached colors/name/
title/favicon/custom-css BEFORE first paint. CSP-safe (external 'self' script,
not inline). applyBranding still runs to refresh + re-cache. First-ever visit to
an uncached branded workspace still shows the default once; every load after is
flash-free.
2026-06-09 11:43:42 -05:00
ScreenTinker 42966da973 feat(teams): temporarily disable Teams API while feature is redesigned
Teams in its pre-Workspaces form is being paused while the feature is
redesigned as a user-grouping primitive within the new Workspaces
architecture. The original Teams data model had no workspace-awareness
and was effectively non-functional after Phase 2.2 (every route migrated
away from team_id), but the UI remained reachable and allowed users to
accumulate orphan data while believing they were configuring access
control.

Hide the Teams sidebar nav entry to prevent new entries to the UI.
/api/teams now returns 503 Service Unavailable with a 'feature
redesign in progress' message. Existing teams/team_members/team_invites
table data is preserved indefinitely for forward migration to the
future teams design.

Bonus: requireAuth middleware fires before the catch-all so unauthenticated
callers see the standard 401 instead of the 503 redesign message - avoids
exposing the 'feature being redesigned' signal to unauthenticated probes
or fingerprint scanners.
2026-05-12 13:30:55 -05:00
ScreenTinker 0c91390e56 fix(frontend): workspace switcher (Phase 3 MVP) + SW network-first migration + platform_admin accessible_workspaces expansion + static render CSS cleanup. The switcher adds a sidebar dropdown for users who are members of multiple workspaces, renders as static text with a 'Workspace' label for single-workspace users, and muted 'No workspace' for zero. Uses existing /api/auth/me's accessible_workspaces and POST /api/auth/switch-workspace endpoints. Platform admin / superadmin users now see all workspaces in accessible_workspaces (closing the known regression from 88d91b1) via a LEFT JOIN that preserves workspace_role semantics (null = acting-as, role string = direct member). No cap on the list - deliberate for now, revisit at 50+ workspaces. SW fix bumps rd-admin-v1 -> rd-admin-v2 and switches fetch strategy from cache-first to network-first so the server's existing Cache-Control: no-cache + ETag headers actually get respected; preserves offline fallback. Static render CSS drops the bordered-box chrome that was making single-workspace users think the static text was clickable. Includes test fixture user switcher-test@local.test (credentials in fixture SQL header). Surfaced by semetra22 / Discord report about 'screens jumbled up' post-migration; root cause was the missing workspace switcher UI making devices in non-active workspaces appear missing. 2026-05-12 10:55:09 -05:00
ScreenTinker aebaacf2c1 i18n batch 7: index.html modal + player overlay
- Add-Display modal in index.html: marked translatable elements with
  data-i18n / data-i18n-placeholder / data-i18n-html attributes
- app.js: translateStaticDom() walks data-i18n* on init and on every
  language-changed event so static HTML stays in sync
- server/player/index.html: standalone player gets its own inline
  PLAYER_I18N table (en/es/fr/de/pt) with a tiny _t() helper. Reads
  rd_lang from localStorage (set by dashboard) so the player picks up
  the same language. Translates info overlay, setup screen, and
  status messages.
- 1018 keys total in dashboard locales, parity 100%.

This completes the wiring; Android resources are next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:06 -05:00
ScreenTinker 8ec33721f7 Security: sanitize notes, add CSP headers, tighten CORS
LOW 1 (notes XSS): device.notes textarea content now goes through
esc(). Notes weren't in the sanitizeBody allow-list at write time, so
HTML in the field would render unescaped on the device-detail page.

LOW 2 (CSP): enabled Helmet contentSecurityPolicy with default-src
'self', script-src 'self', style-src 'self' 'unsafe-inline', plus the
data:/blob:/https: image and media sources the player needs. Strict
script-src blocks <script> injection; script-src-attr 'unsafe-inline'
keeps existing inline onclick handlers working until they can be
refactored to addEventListener (TODO comment in code).

  CSP applies to /app and most other paths. Skipped on the public
  widget and kiosk render endpoints, the landing page, and /player —
  those legitimately need inline scripts/styles. upgrade-insecure-
  requests is explicitly disabled so HTTP-only self-hosted LAN
  deployments aren't broken.

  Refactored two inline onclick handlers in index.html to data-close-
  modal attributes wired by a delegated listener in app.js. Was the
  only blocker for /app under strict script-src.

LOW 3 (CORS): Express CORS now only allows screentinker.com (and
subdomains) + localhost in production. SELF_HOSTED=true bypasses the
allowlist (operator owns their deployment). Development mode stays
open. Same policy applied to the Socket.IO CORS config which was
previously origin: '*'. Native clients (Android, server-to-server,
kiosk iframes) send no Origin and pass through unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:37:31 -05:00
ScreenTinker 09dbb4b199 Mobile: sidebar polish (Commit 1/4)
- 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>
2026-04-21 15:49:49 -05:00
ScreenTinker 865a89836b Add Playlists nav entry to sidebar between Content and Layouts
Uses list icon (lines with dots). Positioned content-adjacent since
playlists are collections of content items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 21:15:25 -05:00
ScreenTinker b7d0c94313 Move player downloads into Add Display modal for discoverability
Player download links now appear directly in the Add Display modal below
the pairing form, so new users can find and install a player app without
hunting through Settings. Removed the duplicate downloads section from
the Settings page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:04:33 -05:00
ScreenTinker 1594a9d4a4 Initial open source release
ScreenTinker - open source digital signage management software.
MIT License, all features included, no license gates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 12:14:53 -05:00