Commit graph

308 commits

Author SHA1 Message Date
ScreenTinker 4d81bb112f fix(branding): inject instance branding into the app shell, no default flash (#76)
A never-visited org had no cached white-label, so brand-prime fell through to the
ScreenTinker default baked into the static index.html and flashed it before
branding.js fetched the org brand. Now the /app route injects the resolved
instance / custom-domain branding into the shell as a <meta name="ssr-brand">
(CSP blocks inline <script>, so a meta carries it), and brand-prime applies that
as the fallback when the per-workspace brand is not cached yet - so the page
paints the configured brand on first load instead of ScreenTinker.

- server.js: /app resolves branding (publicBranding strips internal columns) and
  injects the HTML-escaped JSON as a meta tag; falls back to plain sendFile on
  any error so branding can never break the app shell.
- brand-prime.js: read meta[name=ssr-brand] when there is no rd_branding_<ws>.

Verified: the meta carries the resolved brand (default ScreenTinker and a
platform-default white-label), internal columns do not leak, 66 unit tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:30:23 -05:00
ScreenTinker 53e32d31e2 fix(bump-version): do not rewrite the tizen config.xml XML declaration (#77)
bump-version.sh matched `<?xml version="1.0" ...?>` - the XML format version,
which has a leading space before version= just like the widget attribute - and
rewrote it to the app version, producing invalid XML that breaks the Tizen .wgt
build: 'XML version "X.Y.Z" is not supported, only XML 1.0 is supported'. (CI did
not catch it because the no-Tizen-CLI build path just zips the files without
validating the XML.)

- bump-version.sh: skip the `<?xml` declaration line in the tizen version sed.
- tizen/config.xml: restore the declaration to version="1.0" (prior bumps had
  corrupted it to 1.8.2).

The widget version and tizen:application required_version are still updated /
left alone correctly (verified with a dummy bump + an XML parse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:56:59 -05:00
ScreenTinker 3545830ea6 chore(release): v1.8.2 2026-06-11 08:42:57 -05:00
ScreenTinker c237a6fb27 fix(landing): correct comparison-table claims, mobile image, media-query bug
- Comparison table (landing + the 3 compare pages): correct cells against each
  vendor's current pricing/docs (verified June 2026). Delete the inaccurate
  Platforms, Content Designer, and Hardware Lock-in rows; relabel "Remote
  Control" to "Live screen view + remote key presses" with an Android/permission
  caveat; fix Video Wall, Kiosk, Free tier, White Label and remote cells for
  Yodeck, ScreenCloud and OptiSigns. Add an "as of June 2026 / report errors"
  footnote with a GitHub issues link.
- Compare pages: drop the false "supports more platforms than X" claims; correct
  Yodeck (Windows/ChromeOS, web player, kiosk), OptiSigns (free tier, video wall,
  white label, remote); add the same footnote + caveat.
- Mobile fix: .screenshot img now has max-width:100% / height:auto / display:block
  so the dashboard preview no longer distorts on phones (no desktop effect).
- CSS bug: restore the dropped @media (max-width:768px) wrapper (braces were
  102 { vs 103 }) so the mobile overrides stop leaking to desktop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:38:38 -05:00
ScreenTinker 10884ad87a docs: add RELEASING.md (bump -> push -> finalize ritual + ghcr note)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:22:53 -05:00
ScreenTinker e9c89343d7 chore(release): v1.8.1 2026-06-10 14:12:47 -05:00
ScreenTinker 5530d6cfcd docs: tag-based upgrade flow + upgrade.sh, Tizen install paths
- scripts/upgrade.sh: upgrade a self-hosted instance to a tagged release
  (default latest). Backs up the db (.backup), checks out the tag, npm ci
  --omit=dev, restarts the service (SERVICE_NAME override), reports the version.
- README: replace the git-pull update flow with scripts/upgrade.sh (latest or a
  pinned tag); keep main as the bleeding-edge option. Add a Samsung Tizen entry
  to device setup (URL Launcher -> /player).
- tizen/README: point path A at the server's built-in /player, and explain why
  the released .wgt is unsigned (Samsung distributor certs are DUID-locked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:29 -05:00
ScreenTinker fb17b242ce release: bundle .wgt in the CI tarball + finalize-release.sh for the signed apk
- release.yml: build the Tizen .wgt before the source tarball and bundle it in
  (ScreenTinker.wgt at the tarball root). The signed Android APK is added by the
  local finalize step (the keystore stays off CI).
- scripts/finalize-release.sh: after the release workflow publishes a tag, build
  the signed APK locally, pull the CI-built unsigned .wgt from the release,
  assemble a complete tarball (source + apk + wgt at the root, where /download/apk
  resolves the apk after extraction), and upload the apk + complete tarball.
- .gitignore: ignore *.wgt and *.tar.gz so finalize temp files cannot be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 14:12:29 -05:00
ScreenTinker 4f56199bc7 chore(release): v1.8.0 2026-06-10 13:46:17 -05:00
ScreenTinker 4771f62623 ci: release pipeline (tarball, tizen wgt, multi-arch docker) + Docker packaging
- .github/workflows/release.yml: on a v* tag - verify the tag matches VERSION
  (fail-fast guard), run tests, build a source tarball + the unsigned Tizen .wgt
  and publish a GitHub Release with generated notes, and build+push a multi-arch
  (amd64 + arm64) image to ghcr.io/screentinker/screentinker:<version> + :latest.
  The Release (artifacts) and the docker push are independent jobs, so an
  arm64/QEMU docker failure does not block the GitHub Release and is re-runnable.
  Nothing deploys to prod. APK-build-in-CI left as a TODO (keystore secret).
- Dockerfile + .dockerignore: multi-stage node:20-slim image with server +
  frontend + VERSION + scripts; DATA_DIR=/data volume for db/uploads/jwt-secret.
  Verified to build, boot, serve the dashboard + web player, and persist state.
- docker-compose.example.yml: /data volume, SELF_HOSTED, a node-fetch healthcheck
  against /api/status, and an admin-lockout recovery note (reset-admin.js).
- server.js: resolve the OTA APK from DATA_DIR first (a container can mount one
  at /data/ScreenTinker.apk), fall back to the legacy in-repo path, 404 gracefully.
- ci.yml: bump checkout/setup-node to v6 (clears the Node-20 action deprecation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:44:51 -05:00
ScreenTinker e2cd64054a ci: add CI workflow (unit tests + boot/version smoke)
- test job: node 20, npm ci + npm test in server/ (66 tests).
- smoke job: boot the server against a fresh SQLite db with SELF_HOSTED, then
  assert /api/status is ok and reports exactly the VERSION file (proves the
  single-source-of-truth wiring end to end).
- triggers: push and PR to main, plus manual workflow_dispatch. Concurrency
  cancels superseded in-flight runs per ref.
- upgrade-path job left as a TODO (needs a release tag earlier than HEAD).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:24:46 -05:00
ScreenTinker 52b10408be chore(version): single-source VERSION, env-configurable data paths, bump tooling
- server/version.js: shared version helper that reads the root VERSION file once
  (fallback 0.0.0). Replaces the stale hardcoded 1.2.0 / 1.5.1 / 1.0.0 fallbacks
  in /api/version, /api/update/check, and /api/status.
- config.js: DATA_DIR / DB_PATH / UPLOADS_DIR / CERTS_DIR env overrides for the
  db, uploads, and certs/jwt-secret locations. Unset resolves to exactly the
  legacy in-repo paths, so existing installs (including production) are
  byte-for-byte unchanged. Guarded by test/config-paths.test.js.
- package.json: rename remote-display-server -> screentinker (+ lockfile name).
- scripts/bump-version.sh: one-shot bump across VERSION, package.json (+lock),
  android (versionName and versionCode + 1), and the tizen widget version; makes
  one commit plus an annotated tag; prints the push command, never pushes.
- .gitignore: global *.db / *.db-wal / *.db-shm / *.db.* so no database file
  (including .db.devbak backups, at any path) can be committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:56:03 -05:00
screentinker 26cd29c530
Merge pull request #72 from screentinker/feat/player-orientation
feat(player): software orientation (portrait + flipped), Android + Tizen (1.7.12)
2026-06-09 21:43:12 -05:00
ScreenTinker dfc8a4e358 feat(player): software orientation (portrait + flipped) on both players (1.7.12)
The dashboard exposes landscape / portrait / landscape-flipped / portrait-flipped
and the README promises rotation, but neither player ever read the device's
orientation field - it was hardcoded landscape. Reported by a customer testing
Firestick + Samsung signage.

Rotate the CONTENT in software, not the panel: Fire TV / Android TV / Tizen are
fixed-landscape and ignore setRequestedOrientation (can't physically rotate).
- Android (MainActivity): applyOrientation() resizes rootView to the rotated
  dimensions, recenters, and rotates 0/90/180/270. rootView is the shared
  container for single-zone AND multi-zone, so both are covered. Driven from the
  playlist-update payload.
- Tizen (app.js): CSS transform on the stage (rotate + swapped 100vh/100vw),
  same four values, from the playlist payload.

Verified on an Android 16 emulator: device set to portrait -> 'Applied
orientation: portrait (rotation=90, swap=true)' and the video renders rotated.
2026-06-09 21:43:08 -05:00
screentinker f98bb57ab9
Merge pull request #71 from screentinker/feat/backup-script
feat(ops): nightly backup script with point-in-time content history
2026-06-09 19:53:12 -05:00
ScreenTinker 3ac81a4206 feat(ops): nightly backup script with point-in-time content history
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.
2026-06-09 19:53:09 -05:00
screentinker 2cdf483f59
Merge pull request #70 from screentinker/chore/tizen-signing
chore(tizen): dev-signing setup + support email
2026-06-09 19:10:32 -05:00
ScreenTinker 5396cf9896 chore(tizen): dev-signing setup + support@screentinker.net author email
- 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).
2026-06-09 19:10:28 -05:00
screentinker 6bcd193e45
Merge pull request #69 from screentinker/feat/tizen-player
feat(tizen): Samsung Tizen TV web player (.wgt)
2026-06-09 19:02:04 -05:00
ScreenTinker 0cfa09046c feat(tizen): Samsung Tizen TV web player (.wgt)
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).
2026-06-09 19:01:58 -05:00
screentinker c20b5b9b6f
Merge pull request #68 from screentinker/feat/android-boot-launch-tv
feat(android): reliable boot-launch incl. Android TV (1.7.11)
2026-06-09 17:45:05 -05:00
ScreenTinker d9d7a8ae0f feat(android): reliable boot-launch incl. Android TV (1.7.11)
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.
2026-06-09 17:44:49 -05:00
screentinker acd93377e7
Merge pull request #67 from screentinker/fix/android-ota-install-completion
fix(android): OTA install completion + kiosk auto-confirm (1.7.10)
2026-06-09 16:14:12 -05:00
ScreenTinker 5e3408be9a fix(android): OTA install never completed; auto-confirm for kiosks (1.7.10)
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.
2026-06-09 16:14:08 -05:00
screentinker 91cf7ebee6
Merge pull request #66 from screentinker/release/android-1.7.9
release(android): 1.7.9 — Android 14+ crash + YouTube 152 fixes
2026-06-09 15:41:56 -05:00
ScreenTinker f392292b9e release(android): 1.7.9 — Android 14+ crash + YouTube 152 fixes
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.
2026-06-09 15:41:52 -05:00
screentinker 64975fec88
Merge pull request #65 from screentinker/fix/android-youtube-embed-152
fix(android): YouTube error 152 — embed under a third-party domain (#4)
2026-06-09 15:36:49 -05:00
ScreenTinker 4572963175 fix(android): YouTube error 152 - embed under a third-party domain, not youtube.com
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.)
2026-06-09 15:36:24 -05:00
screentinker b88150c115
Merge pull request #64 from screentinker/docs/help-ai-blurb
docs(help): AI Content Design quick-start in Help (#41)
2026-06-09 13:58:57 -05:00
ScreenTinker 09f543fb8b docs(help): add AI Content Design quick-start to the in-app Help page (#41) 2026-06-09 13:58:53 -05:00
screentinker 4a64053d66
Merge pull request #63 from screentinker/docs/local-ai-setup
docs: local AI setup guide for the Content Designer (#41)
2026-06-09 13:57:06 -05:00
ScreenTinker 1a4397ad24 docs: local AI setup guide for the Content Designer (#41)
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.
2026-06-09 13:57:02 -05:00
screentinker c1aee36326
Merge pull request #62 from screentinker/fix/ai-separate-image-key
feat(ai): separate optional image API key (#41)
2026-06-09 13:47:52 -05:00
ScreenTinker dc6424a3cc feat(ai): separate optional image API key (#41)
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.
2026-06-09 13:47:47 -05:00
screentinker c23e8ca289
Merge pull request #61 from screentinker/feat/ai-images-phase2
feat(ai): background + foreground images for signs (#41 Phase 2)
2026-06-09 13:40:19 -05:00
ScreenTinker 303c83e86a feat(ai): generate background + foreground images for signs (#41 Phase 2)
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.
2026-06-09 13:40:14 -05:00
screentinker df4110d9ca
Merge pull request #60 from screentinker/fix/ai-deoverlap
fix(ai): de-overlap text + layer shapes behind text (#41)
2026-06-09 12:57:45 -05:00
ScreenTinker 734795f20b fix(ai): de-overlap generated text + layer shapes behind text (#41)
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.
2026-06-09 12:57:41 -05:00
screentinker 958f5683e4
Merge pull request #59 from screentinker/fix/ai-canvas-fit
fix(ai): keep generated designs inside the canvas (#41)
2026-06-09 12:51:27 -05:00
ScreenTinker 4cc8ccb67e fix(ai): keep generated designs inside the canvas (#41)
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.
2026-06-09 12:51:23 -05:00
screentinker f7f78a7486
Merge pull request #58 from screentinker/feat/ai-model-dropdown
feat(ai): model dropdown + longer timeout (#41)
2026-06-09 12:36:34 -05:00
ScreenTinker 1420a0d2b7 feat(ai): model dropdown + longer generate timeout (#41)
- 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.
2026-06-09 12:36:29 -05:00
screentinker d117016f2d
Merge pull request #57 from screentinker/feat/ai-content-design
feat(ai): AI content design, BYO endpoint (#41 Phase 1)
2026-06-09 12:24:00 -05:00
ScreenTinker 0ba36949cf feat(ai): AI content design in the Designer, BYO endpoint (#41 Phase 1)
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).
2026-06-09 12:23:55 -05:00
screentinker bcdffd4f56
Merge pull request #56 from screentinker/fix/branding-fouc
fix(branding): no default-brand flash on load/switch (#38)
2026-06-09 11:43:46 -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 97c52408de
Merge pull request #55 from screentinker/fix/content-thumbnail-auth
fix(content): thumbnails for not-yet-assigned uploads (#39)
2026-06-09 11:19:01 -05:00
ScreenTinker 6760f61fb8 fix(content): show thumbnails for not-yet-assigned content (#39)
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.
2026-06-09 11:18:56 -05:00
screentinker 61279e9bea
Merge pull request #54 from screentinker/fix/upload-multiple-hint
ui(content): advertise multi-file upload in drop-zone text (#39)
2026-06-09 10:50:56 -05:00
ScreenTinker 020f0bfea7 ui(content): advertise multi-file upload in the drop-zone text (#39)
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'.
2026-06-09 10:50:51 -05:00