Adds scripts/backup.sh — atomic SQLite .backup + hard-linked point-in-time
content snapshots, daily (7) + monthly (12) retention, and an error log.
Env-configurable (SCREENTINKER_DIR/BACKUP_DIR/DB/UPLOADS/*_KEEP*) so any
self-hoster can use it; defaults target a /opt/screentinker install.
Hardens two real failure modes found in production:
- Content snapshots EXCLUDE uploads/screenshots/ and use rsync --link-dest
instead of cp -al. The per-device *_latest.jpg screenshots are rewritten
24/7; cp -al aborts when a file mutates mid-copy and the prior script
swallowed the error with 2>/dev/null, silently breaking content snapshots
for ~8 weeks. rsync --link-dest hard-links unchanged files but tolerates
in-flight changes; errors now go to backup.log.
- Retention sorts by NAME, not mtime: rsync -a / cp -al preserve the source
dir's (frozen) mtime, so ls -dt treated fresh snapshots as oldest and pruned
them. The timestamp is in the dir name, so name-sort is chronological.
README Backups section documents the cron setup + env knobs. Verified on prod.
- config.xml author email -> support@screentinker.net
- build-wgt.sh: stage app files only before signing (keeps README/build script
out of the .wgt), auto-add the Tizen CLI to PATH if installed.
- README: document the configured 'ScreenTinker' signing profile (self-signed
author + default Tizen distributor) — installs on dev-mode TVs / emulator;
production retail needs a Samsung distributor cert.
Signed .wgt + the author cert are not committed (build artifact / secret).
Ports the ScreenTinker player to a Tizen TV / signage web app, speaking the
SAME /device socket.io protocol as the Android player — no server changes; a
Tizen display pairs from the same dashboard.
- app.js: device protocol client — register (pairing_code | device_id+token),
device:registered/paired/unpaired/playlist-update, 15s heartbeat, keep-awake.
Always reaches the server prompt until the display is actually paired; a
saved-but-unreachable server falls back to the prompt (no blank screen); BACK
returns to it.
- player.js: fullscreen single-zone renderer — image (duration timer), video
(play-to-end + loop), YouTube (iframe embed), widget (iframe render endpoint).
- config.xml: Tizen TV manifest; build-wgt.sh packages (signs if Tizen CLI
present, else unsigned); README covers URL-Launcher and signed-.wgt deploy.
Validated: headless protocol test vs the live server passed end-to-end
(register -> pair -> reconnect-auth -> playlist(2) -> content 200); loads +
renders in Chromium with no JS errors.
Not yet ported (fullscreen single-zone covers most signage): multi-zone, video
walls, screenshots, remote control, self-OTA. .wgt is a build artifact (gitignored).
The player has a launcher (category.HOME) + a boot receiver, but auto-start was
unreliable where you can't set a home launcher (Android TV) and on Android 14+,
where USE_FULL_SCREEN_INTENT is auto-revoked for non-calling apps so the boot
full-screen launcher silently no-ops.
Boot launch:
- BootReceiver now does a direct background startActivity when 'display over other
apps' (SYSTEM_ALERT_WINDOW) is granted — a real exception to the bg-activity-launch
restriction, and the one path that works on Android TV. Full-screen-intent
notification kept as a fallback (locked screen / no overlay).
- Boot notification moved to a dedicated HIGH-importance channel (full-screen
intents are only honored from one), and it auto-dismisses once the UI is up.
Setup screen — new permission rows so operators can grant what boot-launch needs:
- Launch on Boot (USE_FULL_SCREEN_INTENT, shown on Android 14+)
- Background Activity (battery-optimization exemption)
- Display Over Apps (SYSTEM_ALERT_WINDOW)
Made the screen scrollable and ~50% smaller text/buttons so all rows + Continue
fit on one screen (incl. landscape signage). Install-Unknown-Apps subtitle now
states updates are signature-verified, so it doesn't read as 'install anything'.
Verified end-to-end on an Android 16 emulator: after reboot the app auto-launched
(Direct launch via overlay) and the boot notice cleared itself; all rows toggle.
The OTA downloaded + verified the new APK and committed a PackageInstaller
session, but never handled STATUS_PENDING_USER_ACTION (which Android 13+ returns
for non-device-owner installers) — so the session stalled and the update never
installed. Reproduced on an Android 13 emulator: device stayed on the old version.
- UpdateChecker: register a receiver for the session's INSTALL_COMPLETE broadcast;
on PENDING_USER_ACTION launch the system confirm dialog (and log SUCCESS).
- PowerAccessibilityService: when the package-installer dialog appears, auto-click
the confirm button (by id, then label) so unattended kiosk screens update
without a human tap. Scoped strictly to the package installer.
Verified end-to-end on Android 13: device auto-updated 1.7.10 -> 1.7.11 with no
interaction (receiver launched the dialog, accessibility confirmed it). Ships as
1.7.10 (also carries the Android 14+ crash + YouTube 152 fixes).
NOTE: existing 1.7.7 devices still need a one-time manual reinstall to reach a
build that has this fix; from 1.7.10 onward OTA is fully automatic.
Rebuilds and redistributes the player APK so the fixes actually reach devices:
- #5 Android 14+ mediaProjection FGS crash (committed in source but the SERVED
ScreenTinker.apk was a stale 1.7.7 build from before it — modern devices
couldn't launch the app at all).
- YouTube error 152 (embed base domain).
versionCode 11->12, versionName 1.7.8->1.7.9, VERSION file 1.7.7->1.7.9 so the
update check offers it; signed with the same release key (OTA signature check
passes). Verified on a Pixel 10 / Android 16 emulator: launches without crashing,
YouTube plays.
The player loaded the YouTube embed via loadDataWithBaseURL with base
https://www.youtube.com, so the embedding page claimed to BE youtube.com hosting
a youtube.com iframe. YouTube rejects that as an invalid embed context -> 'This
video is unavailable / Error 152 - 4' for every video (reproduced on a Pixel 10
/ Android 16 emulator with multiple known-embeddable videos).
Load the embed under a real third-party domain (EMBED_BASE = the product domain)
so the referrer is a legitimate embedding site. The iframe still points at
youtube.com/embed. Verified: video now plays. (The earlier base=youtube.com was
the Error 153 fix; this supersedes it - a normal domain referrer fixes 153 too.)
How to run the AI design feature fully local + free: Ollama (OpenAI-compatible
LLM) for text/layout and stable-diffusion.cpp (Vulkan) for images, plus the
SELF_HOSTED requirement for localhost endpoints, an OpenAI fallback, and GPU
troubleshooting (incl. the Blackwell CUDA-fails/Vulkan-works note). Linked from
the README integrations section.
Image generation reused the single (text-endpoint) API key, which breaks the
common 'local LLM with no key + OpenAI for images' setup. Add an optional
image_api_key (encrypted, write-only, never returned); generate-design uses it
for image calls and falls back to the main key when blank (all-OpenAI setups).
Local sd.cpp / ComfyUI still need no key. Schema column + migration.
A prompt now produces a full sign: the LLM writes the design AND image prompts,
the server generates the images and composites them with the crisp text layer.
- lib/image-gen.js: text-to-image with 3 BYO/self-hostable backends, all behind
the SSRF guard: 'sdcpp' (local stable-diffusion.cpp OpenAI-compatible server,
exact small sizes that fit VRAM), 'openai' (cloud / OpenAI-compatible, snapped
sizes), 'comfyui' (prompt/history/view API).
- ai.js: prompt asks for a background_prompt (preferred — full-bleed atmosphere)
and an optional foreground image element; after the design is normalized, the
bg + fg images are generated best-effort (a failed image never fails the sign)
and returned as data URLs. New image_* settings (provider/base_url/model),
image_provider whitelist, schema column + migration.
- designer.js: AI-images section in settings; generate applies the background
image; publish bakes the background image into the HTML so it survives.
- server.js: raise JSON body limit to 12mb for embedded image data URLs.
Verified end-to-end on local Vulkan SDXL (RTX 5090): prompt -> bg+fg images on
the canvas -> publish creates a widget with the images embedded. 63/63.
Note: prod (not self-hosted) requires a PUBLIC image endpoint (e.g. OpenAI); the
SSRF guard blocks localhost there. Follow-up: upload generated images to the
content store and reference by URL to avoid multi-MB widget configs.
Models sometimes stacked text lines at the same y (unreadable) and emitted accent
shapes after text, so a band could hide the words.
- deoverlapTexts: push a line down only when it also overlaps horizontally
(leaves side-by-side text alone), with conservative line-height clearance so
real rendering doesn't re-overlap; shift the stack up if it ran past the bottom.
- Order shapes before text in the output so accent bands always render behind the
words.
Verified: 0 text-on-text overlaps across multiple prompts (Playwright DOM check);
unit test asserts overlapping lines get separated + shapes precede text. 63/63.
Text could run off the edge (long/large headlines, nowrap) and shapes placed at
the far edge (e.g. a bottom band at y=100) spilled over.
- Server-side fit pass on every generated element: shrink text fontSize so it
fits the canvas width (chars*fontSize*0.075, tuned for bold/uppercase
headlines) and height (incl. line-height), then nudge x/y within 4% margins;
clamp shapes so x+width<=100 and y+height<=100. Deterministic - doesn't rely on
the model getting layout right.
- Designer preview: vw -> cqw (+ container-type on the canvas) so text scales to
the canvas, not the browser window. The preview was overstating size vs what
actually publishes; now it matches. Published widget keeps vw (scales on the
player).
Verified: Playwright DOM check shows zero elements overflowing the canvas after
generation; unit test asserts long text is shrunk + repositioned in-bounds. 62/62.
- POST /api/ai/models lists the configured endpoint's models (OpenAI-compatible
/models) so the settings modal can populate a 'Load models' dropdown instead of
requiring users to type the model name. Combobox (datalist) so they can still
type a custom one. Admin only; same SSRF guard; uses the posted or saved key.
- Bump generate-design timeout 120s -> 180s for slow local endpoints.
Competitor pressure (Mandoe 'AI Magic Create'): prompt -> signage. We answer it
in a way that's actually BETTER for signage and costs the operator nothing.
Key idea: don't generate raw images (AI garbles text - fatal for menus/promos).
The LLM returns a STRUCTURED design spec (headline, supporting text, accent
shapes, palette) that the existing Designer renders with real fonts - crisp and
fully editable. Reuses the whole Designer.
BYOK, fully under the customer's control: each workspace configures its own
OpenAI-COMPATIBLE endpoint + key - OpenAI cloud OR self-hosted (Ollama / LM Studio
/ llama.cpp). Operator bears zero AI cost/liability.
- server/lib/secretbox.js: AES-256-GCM for the key at rest (never returned).
- routes/ai.js: GET/PUT /api/ai/settings (admin; key write-only) + POST
/generate-design (editor+). Output is strictly validated/normalized (cap count,
clamp ranges, px->%, strip HTML, validate colors) - never trust the model.
SSRF guard: hosted instances block private/internal targets; self-hosted (the
whole point of local AI) may point at localhost/LAN.
- Designer: an 'AI generate' panel (prompt + Generate) + a settings modal.
Verified end-to-end against local Ollama (llama3.1:8b): prompt -> editable design
on the canvas. Unit tests cover normalization + the SSRF guard. Suite 61/61.
Phase 2 (next): AI background images (OpenAI images / AUTOMATIC1111).
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.
After uploading, content thumbnails were blank until the item was added to a
playlist/widget. The public /api/content/:id/thumbnail (and /file) endpoints are
reference-gated (an anonymous player with a UUID must not pull arbitrary tenants'
media), and a plain <img> can't send a Bearer token - so a just-uploaded item 403'd.
- Backend: add an authenticated bypass - a logged-in user who can access the
content's workspace (verified from the Bearer token) may view its file/thumbnail
even when unreferenced. Anonymous players still hit the reference gate.
- Frontend: the content library lazy-fetches thumbnails/previews WITH the token
and swaps in an object URL (IntersectionObserver keeps it under the rate limit;
the URL is revoked after load).
Verified: unreferenced thumbnail now 200 with a bearer token, still 403 anonymous.
The upload input already has 'multiple' and the click handler shares handleFiles()
with drag-drop, so picking multiple files (shift/ctrl-click) already works - it just
wasn't discoverable ('click to upload' read as single-file). Reword to 'click to
select one or more'.
Saving a layout grew its zone count on every server restart. Root cause: the
editor saved zones with a per-zone delete-then-POST loop, and POST /zones minted
a NEW uuid for every zone - so each save replaced the seeded ids (z-sh-1, ...)
with fresh uuids. schema.sql re-seeds template zones via INSERT OR IGNORE on every
boot, so the next restart re-added the now-missing canonical zone alongside the
renamed copy -> a 2-zone template became 4, 6, ... (worse for self-hosters who
rebuild often).
Fix:
- PUT /api/layouts/:id now accepts a zones[] and replaces them atomically in one
transaction, REUSING each zone's id when supplied. The editor sends the full
set in a single call, so the layout ends up with exactly those zones and ids
stay stable (also fixes fit_mode not persisting, and stops device->zone
assignments being orphaned by id churn).
- One-time dedupe migration removes positional-duplicate template zones, keeping
the canonical 'z-...' seeded id so the re-seed stays an idempotent no-op.
Verified: 2 atomic saves keep count + ids stable with fit updated; dedupe restores
a polluted 4-zone split template to its 2 canonical zones. Suite 56/56.
The boot summary counted any non-throwing statement, so UPDATE/index migrations
(which always succeed) made a healthy DB report 'applied N new column migration(s)'
every boot. Count only a successful ALTER ... ADD COLUMN (genuinely new), so the
line appears only when a column was actually added.
Self-hosters rebuilding could end up schema-behind-code, failing only at runtime
(a missing users.must_change_password locked out all logins). Two root causes:
1. The migration loop swallowed EVERY error (catch {}), so a real ALTER failure
was indistinguishable from the benign 'duplicate column' on an already-migrated
DB. Now only 'duplicate column'/'already exists' is treated as a no-op; any
other error is logged loudly, and a one-line summary reports how many new
column migrations actually applied this boot.
2. Nothing verified the schema after migrations. Added lib/schema-check.js:
verifyAndRepairSchema() checks the tables + columns the request path REQUIRES,
idempotently repairs missing repairable columns (logging each), and if anything
required is STILL missing, prints a loud FATAL block and exits - failing fast at
boot instead of at the first authed request.
Note: the reported 'audit_log missing' was a misdiagnosis - the code uses
activity_log (0 refs to audit_log), created by schema.sql on every boot.
Tests: healthy (no-op), auto-repair of must_change_password, missing-table report.
Platform admins can now cleanly remove a customer org (account ends) or a stray
workspace from the UI, instead of raw SQL that risks orphaning resources.
The tenant cascade isn't pure DB CASCADE - workspace-scoped tables (devices,
content, playlists, ...) are NO ACTION and must be purged before the workspace.
Extracted that logic out of deleteUserCascade into shared deleteWorkspaceCascade /
deleteOrgCascade helpers (one tested implementation; deleteUserCascade now reuses
the purgeWorkspaces extraction).
Backend (platform-admin only): GET /api/admin/orgs (list + owner + counts +
workspaces), DELETE /api/admin/orgs/:id, DELETE /api/admin/workspaces/:id.
UI: an Organizations section in Admin listing every org/workspace with a
type-the-name confirmation before the irreversible delete.
Tests: org/workspace cascade (real FKs) + endpoint gating/404. Suite 53/53.
MSPs onboarding customers as separate orgs had no way to create one with
AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a
platform-admin 'Create organization' action.
POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default'
workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless;
the creating admin becomes org_owner + workspace_admin (mirrors the signup
bootstrap in routes/auth.js) - which also surfaces the org in their switcher.
Customer users are then added via the existing Add User / manage-memberships flow.
UI: 'Create organization' button + single-field modal in the Admin area (gated).
Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
Multi-zone videos/images were cropped: every template zone inherited fit_mode
'cover' (fill+crop) and the layout editor had no control to change it, so a
landscape video in a tall split zone showed only a center strip. The player
already honors fit_mode (web object-fit, Android scaleType) - the gap was the UI
and the default. Add a per-zone Fit selector (Contain/Cover/Stretch) to the layout
editor, and make 'contain' (show the whole frame) the default for new zones, the
schema column, and the save fallbacks. Existing built-in templates are migrated
separately.
Follow-up to the layout cache. On a cold start with a cached playlist but no cached
layout yet (first run after shipping, or cleared cache), the player still rendered
fullscreen and flashed before the payload arrived. Now gate the optimistic cached
render on the layout being KNOWN (cache key present — null=fullscreen vs object=
zoned, both fine); if unknown, wait ~1s for the payload to drive the first render.
Eliminates the fullscreen flash on the very first pass too.