Commit graph

308 commits

Author SHA1 Message Date
screentinker 3f429aec85
Merge pull request #53 from screentinker/fix/template-zone-duplication
fix(layouts): atomic zone save — stop template zone duplication
2026-06-09 10:16:06 -05:00
ScreenTinker cb21b8e34a fix(layouts): atomic zone save (stop template zone duplication)
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.
2026-06-09 10:16:01 -05:00
screentinker e2460855d9
Merge pull request #52 from screentinker/fix/migrate-count-addcolumn
fix(db): boot log counts only ADD COLUMN (#37 follow-up)
2026-06-09 10:02:43 -05:00
ScreenTinker bae70e9154 fix(db): count only ADD COLUMN as new migrations in boot log (#37 follow-up)
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.
2026-06-09 10:02:38 -05:00
screentinker 7ef3e2eb93
Merge pull request #51 from screentinker/fix/migration-schema-verify
fix(db): observable migrations + fail-fast schema verification (#37)
2026-06-09 09:31:57 -05:00
ScreenTinker 7ab19adcea fix(db): observable migrations + fail-fast schema verification (#37)
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.
2026-06-09 09:31:52 -05:00
screentinker 9deccf0a2f
Merge pull request #50 from screentinker/feat/admin-delete-org-workspace
feat(admin): Delete Organization + Workspace with cascade (#36)
2026-06-09 09:22:25 -05:00
ScreenTinker 0d14db97a6 feat(admin): Delete Organization + Workspace with cascade (#36)
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.
2026-06-09 09:22:21 -05:00
screentinker 36d1578794
Merge pull request #49 from screentinker/feat/admin-create-org
feat(admin): Create Organization for platform admins (#35)
2026-06-09 09:10:20 -05:00
ScreenTinker ae595a208d feat(admin): Create Organization for platform admins (#35)
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.
2026-06-09 09:10:15 -05:00
screentinker 69b46647c5
Merge pull request #48 from screentinker/feat/zone-fit-mode
feat(layouts): per-zone fit mode (fix cropped multi-zone video)
2026-06-09 08:55:20 -05:00
ScreenTinker 8fd971405e feat(layouts): per-zone fit mode + default to 'contain'
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.
2026-06-09 08:55:15 -05:00
screentinker 7af9f7a057
Merge pull request #47 from screentinker/fix/player-coldstart-layout
fix(player-web): no fullscreen flash on true cold start (unknown layout)
2026-06-09 08:31:03 -05:00
ScreenTinker 397aedf2d8 fix(player-web): don't optimistic-render fullscreen when layout is unknown
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.
2026-06-09 08:30:58 -05:00
screentinker 8de15465ad
Merge pull request #46 from screentinker/fix/player-cache-layout
fix(player-web): cache layout — cold start renders zones on first pass
2026-06-09 08:27:46 -05:00
ScreenTinker 00964e90a8 fix(player-web): cache layout so cold start renders zones on first pass
The player cached only the playlist, not the layout. On cold start it restored the
playlist and rendered immediately with layout=null -> fullscreen, then re-rendered
into zones once the server payload arrived (the 'fullscreen first, then split'
flash). Cache the layout alongside the playlist and restore it before the first
render; cleared on reset.
2026-06-09 08:27:41 -05:00
screentinker ccee032740
Merge pull request #45 from screentinker/fix/zone-widget-content-type
fix(player-web): render widgets in content zones (black-zone bug)
2026-06-09 08:22:10 -05:00
ScreenTinker 4fe8e87416 fix(player-web): render widgets in any zone, not just zone_type=widget
A widget (e.g. directory board) assigned to a 'content' zone rendered as a black
zone: showZoneItem gated the widget branch on zone.zone_type==='widget', so the
widget was skipped and (mime_type null) nothing else matched either. Key off the
assignment's widget_id instead - matching the Android ZoneManager, which is why
the same layout worked on the APK but not the web player.
2026-06-09 08:22:05 -05:00
screentinker 67d2eae2cf
Merge pull request #44 from screentinker/fix/widget-render-nostore
fix(widgets): no-store on widget/kiosk render
2026-06-08 23:46:46 -05:00
ScreenTinker 8e7d599170 fix(widgets): no-store on widget/kiosk render
The render had no Cache-Control. A copy cached before the X-Frame-Options fix keeps
showing blank, and widget data (clock/weather/rss/directory) is dynamic anyway, so
mark the render no-store. Pairs with the X-Frame-Options removal.
2026-06-08 23:46:42 -05:00
screentinker 8dce93d4dc
Merge pull request #43 from screentinker/fix/widget-render-frameable
fix(widgets): make widget/kiosk render frameable (X-Frame-Options)
2026-06-08 23:37:52 -05:00
ScreenTinker 827b1c4c87 fix(widgets): make widget/kiosk render frameable (X-Frame-Options)
The web player embeds widget/kiosk renders in a sandboxed (allow-scripts, no
allow-same-origin) iframe = a null origin. The global helmet X-Frame-Options:
SAMEORIGIN refuses that (null != same-origin), so every widget rendered blank in
the web player (video worked since it isn't an iframe). Drop X-Frame-Options on
just the /render endpoints - the sandbox, not X-Frame-Options, is what isolates
the widget from the dashboard (it still can't read the JWT). Dashboard keeps its
clickjacking protection. Verified: directory board now renders in a sandboxed
iframe with no refusal.
2026-06-08 23:36:53 -05:00
screentinker d13ac58e74
Merge pull request #30 from screentinker/fix/widget-render-xss
fix(security): sanitize public widget render (stored XSS)
2026-06-08 23:20:38 -05:00
screentinker ac1b24fe43
Merge pull request #42 from screentinker/fix/sw-video-passthrough
fix(web): service worker video passthrough + independent per-zone rotation
2026-06-08 23:17:08 -05:00
ScreenTinker 68fb6a985e Merge remote-tracking branch 'origin/main' into fix/sw-video-passthrough
# Conflicts:
#	server/player/index.html
2026-06-08 23:15:32 -05:00
ScreenTinker 546fcdc105 fix(player-web): independent per-zone rotation in multi-zone layouts
Mirror of the Android fix. The web player showed only the FIRST assignment per
zone (playlist.find) and an image zone set the GLOBAL advanceTimer->nextItem, so
the whole layout re-rendered on one global tick instead of each zone cycling its
own content. Now each zone groups its assignments (by zone_id, sorted), renders
the first, and advances on its OWN timer (images/widgets/youtube: duration;
videos: on end; single-item zones loop). Cleared in teardown. Also render zones
before the single-item 'renderable?' bail so an empty current item can't blank
the screen.
2026-06-08 23:12:29 -05:00
ScreenTinker d4f71bbf3a fix(sw): stop the admin service worker from breaking video playback
sw-admin.js (scope '/') intercepted every non-API GET with clone+cache+respond.
Video requests are Range requests -> 206 Partial Content, which can't be cached;
cache.put threw and the handler errored ('ServiceWorker encountered an unexpected
error'), so .mp4s never loaded on any page this SW controls - including the web
player at /player, which then thrashed between items.

Now bypass (network-only) non-GET, Range requests, and /uploads//player/api/
socket.io; only cache same-origin 200s. CACHE bumped to v4 so clients pick up the
new SW + drop the stale bucket.
2026-06-08 23:08:13 -05:00
screentinker 6ef2cb548c
Merge pull request #33 from screentinker/fix/fullscreen-widgets
fix(android): widgets not rendering in fullscreen / single-zone layouts
2026-06-08 22:54:11 -05:00
ScreenTinker 5c0721b77f Merge branch 'main' into fix/fullscreen-widgets 2026-06-08 22:42:59 -05:00
ScreenTinker 3510670ce1 fix(android): YouTube Error 153 + visible web-frame errors
- YouTube: load the embed via loadDataWithBaseURL with a youtube.com base URL so
  the iframe has a valid origin/referer (a bare loadUrl of /embed/ID gives
  'player misconfigured, Error 153'). Applies to zone + fullscreen YouTube.
- Web frames: shared WebViewSupport.configure() enables mixed-content (self-hosted
  http LAN servers) and pipes WebView load/HTTP/JS-console errors to DebugLog, so a
  failing web frame surfaces the real error in the live panel instead of a black
  broken-page view.
2026-06-08 22:42:59 -05:00
ScreenTinker c184b94602 fix(android): log per-zone content switches (live debug)
After stopping the fullscreen controller in multi-zone, the only switch logs went
away - each zone now logs every item it renders (initial + each rotation) so the
live debug panel shows each zone advancing on its own interval.
2026-06-08 22:36:07 -05:00
ScreenTinker c94757fc97 fix(android): per-zone rotation + stop fullscreen controller in multi-zone
From Chris's live debug logs on the L-Bar layout:
- ZoneManager only rendered the FIRST assignment per zone -> the Main zone (3
  images) never rotated ('says it's switching but it's not'). Now each zone
  cycles its own assignments: images/widgets on a duration timer, videos on
  end (single-item zones still loop).
- The fullscreen PlaylistController kept running BEHIND the zones (playItem every
  10s, would leak audio for a zone video) because startIfNeeded() ran after every
  playlist update. Now only start it when not in multi-zone (zoneManager.hasZones).
- renderAssignments still called container.removeAllViews() (the same static-view
  nuke the cleanup() fix addressed) -> now removes only its own zone views.
2026-06-08 22:19:25 -05:00
ScreenTinker 73912d5f58 feat(debug): live per-device debug logging toggle on the device screen
Checkbox on the device-detail page streams the Android player's player/zone logs
live (no adb). Transient (off on reconnect), not persisted.

- Android: DebugLog util (logcat + optional socket emit); 'set_debug' command wires
  the sink + flag; key player/zone decisions (layout mode, playItem, per-zone
  render) emit through it.
- Server: relay device:log -> dashboard workspace room as dashboard:device-log.
- Dashboard: 'Debug logging' checkbox sends set_debug; live log panel streams lines
  (rendered via textContent; capped at 500).
2026-06-08 21:49:03 -05:00
ScreenTinker 50d7dbe222 fix(player): zone reset on multi->single layout switch + don't blank multi-zone
- Server (deviceSocket buildPlaylistPayload): when a device's layout has <2 zones
  (single or none), strip leftover zone_id from assignments. After switching a
  device from multi-zone back to fullscreen, content was stuck bound to a gone
  left/right zone_id and never played; nulling it lets both players fall back to
  the default fullscreen renderer.
- Web player: render multi-zone zones BEFORE the single-item 'renderable?' bail,
  so an empty/placeholder current rotation item can't blank the whole screen.
2026-06-08 21:31:27 -05:00
screentinker c1fbe165e7
Merge pull request #34 from screentinker/fix/sidebar-scroll
fix(ui): sidebar nav unscrollable on short screens
2026-06-08 21:21:00 -05:00
ScreenTinker 2e14de2069 fix(ui): make sidebar nav scrollable on short screens
On a short viewport (e.g. 1366x768) the sidebar nav was taller than the screen
with no scroll, so items below the fold (Settings) were unreachable. Add
overflow-y:auto + min-height:0 to .nav-links (the min-height:0 lets the flex
child shrink and scroll instead of overflowing).
2026-06-08 20:41:15 -05:00
ScreenTinker c7bbc4f815 fix(android): ZoneManager.cleanup must not remove the activity's static views
The black-screen on fullscreen widgets (and any single-zone playback after using
a multi-zone layout) was here: cleanup() called container.removeAllViews(), but
`container` is the activity root that also holds the static playerView/imageView/
youtubeWebView/statusOverlay. Removing them detached the WebView that the
fullscreen widget path reuses -> black. Remove only the zone views we added.
2026-06-08 20:34:30 -05:00
screentinker 171b69233c
Merge pull request #32 from screentinker/fix/android-device-fixes
fix(android): OTA APK signature verification (Critical) + pairing-code visibility
2026-06-08 20:08:58 -05:00
ScreenTinker 911cd07951 fix(android): render widgets in fullscreen / single-zone layouts
Widgets worked in multi-zone layouts (ZoneManager renders them in a WebView) but
were broken in "default fullscreen" (no layout) and the fullscreen template (a
single-zone layout) - both take the single-zone PlaylistController path, which:
  1) called getString("content_id"), throwing on a widget assignment (no
     content_id) - in both the playlist builder AND the pre-download loop, which
     could break the whole fullscreen playlist; and
  2) had no widget render case in playItem (so a widget never displayed).

Fix:
- PlaylistItem gains widgetId/widgetType + isWidget; the builder reads them and
  tolerates a missing content_id.
- playItem renders a widget fullscreen via MediaPlayerManager.showWidget() (loads
  /api/widgets/:id/render in the full-screen WebView, mirroring ZoneManager).
- Widgets auto-advance on their duration like images.
- Pre-download loop skips widget assignments (no file to fetch).

Compile-checked; signed APK builds. Needs on-device check: a widget plays in
default-fullscreen and the fullscreen template, and mixed widget+media playlists
advance correctly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:23 -05:00
ScreenTinker 60cda97b1d fix(android): stop pairing-code glyph clip + remove duplicate instruction
- The code's bottom was still clipped: the autosize TextView used wrap_content
  height, which clips glyph bottoms. Give it a fixed 96dp box (autosize 24-64sp,
  gravity center) so the text is centered inside a bounded box and never clipped.
- The "Enter this code…" line appeared twice (static label + statusText). Clear
  statusText when paired so it shows only once, with the code.
2026-06-08 19:53:44 -05:00
ScreenTinker 86340caf9d fix(android): keep pairing code fully on-screen (was clipped at bottom)
Follow-up to the provisioning layout fix - on a Pixel the code's bottom half was
cut off. Tightened the screen so the whole block fits:
- "RemoteDisplay" title 36sp -> 22sp, smaller subtitle + margins.
- Anchor content to the top (gravity center_horizontal|top) so the code sits
  high instead of being pushed below the fold by vertical centering.
- Pairing code autosize cap 96sp -> 56sp + vertical padding, so tall digits
  aren't clipped and the block stays on screen on short/landscape phones.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:46:30 -05:00
ScreenTinker 06c6c3214b fix(android): make pairing code fit/visible on all screen sizes
Reported on a Pixel 10: the pairing code wasn't visible. The provisioning screen
was a non-scrolling vertical stack, and when the pairing section appeared below
the server-URL + Connect controls, the fixed 64sp code got pushed off-screen on
short/landscape phones (and could clip horizontally on narrow widths).

- Wrap the screen in a ScrollView (fillViewport) so content is always reachable.
- pairingCodeText now auto-sizes (autoSizeTextType=uniform, 24-96sp, single line,
  match_parent width) so it fills the width and never clips - phones, TVs, sticks.
- Hide the server-URL section + Connect button once paired so the code gets the
  full screen.

Compile-checked + signed APK builds. Needs on-device confirmation (Pixel 10 /
onn stick) that the code is now visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:19:55 -05:00
ScreenTinker d41bd1f27d fix(android): verify OTA APK signature before install + disable backup (Critical)
The updater fetched download_url from the server JSON and installed it via
PackageInstaller with NO verification, over cleartext (usesCleartextTraffic,
no pinning). A network MITM or compromised server could return a malicious APK
and have it silently installed (REQUEST_INSTALL_PACKAGES) → full device RCE.

Fix: before install, verify the downloaded APK (a) is our own package and
(b) shares a current signing certificate with the installed app
(GET_SIGNING_CERTIFICATES on P+, GET_SIGNATURES below). An attacker can't forge
our signing key, so this holds even over an untrusted/cleartext transport.
Fail-closed on any parse/verify error; the APK is deleted on mismatch. Gates
both the session-install and intent-fallback paths.

Also set android:allowBackup="false" so adb backup can't exfiltrate the
device token / config.

Compile-checked + signed debug APK builds. NOT verified on-device - needs a
real update cycle on a device (valid update installs; a wrong-signed APK is
rejected) before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:19:55 -05:00
ScreenTinker 401c4b00b5 fix(security): sanitize public widget render to close stored XSS
The public, CSP-exempt widget render (GET /api/widgets/:id/render) inlined
config values straight into <style>/CSS and (for the text widget) raw into the
same-origin document. A workspace editor could store `}</style><script>...` in a
color/background/size field (bypassing the UI pickers via the API) → stored XSS
executing in the app origin for anyone who opens the render URL (JWT theft).

- safeCss(): allow colors/gradients but reject CSS breakout / url() / @import /
  expression / javascript:. Applied to background/color across clock, weather,
  rss, social renders.
- safeNumber(): coerce font_size / scroll_speed / max_items to a finite number
  so they can't smuggle markup.
- Text widget keeps its intentional raw HTML/CSS feature, but it now renders
  inside an <iframe sandbox="allow-scripts"> (NO allow-same-origin) - scripts run
  in a null origin that can't reach the dashboard's localStorage/JWT.

Tests: test/widget-render-xss.test.js (breakout rejected, numbers coerced, text
isolated, legit colors/gradients preserved). Full suite green.
2026-06-08 19:11:14 -05:00
screentinker 50ad1f670b
Merge pull request #28 from screentinker/fix/security-quick-wins
fix(security): quick-win fixes from the codebase security review
2026-06-08 19:04:14 -05:00
ScreenTinker ba3e2cc785 fix(security): patch quick-win findings from the codebase review
Five low-risk, high-value fixes surfaced by the security review:

#3 Branding lockdown — `custom_domain`/`custom_css` (which feed the PUBLIC,
   pre-auth branding resolver and the login-page <style>) are now settable only
   by platform admins; a workspace_admin can no longer hijack the platform login
   page by claiming its domain. The public /api/branding (+ /domain) now return
   only presentational fields via publicBranding() (no id/user_id/workspace_id/
   custom_domain/timestamps leak).

#6 Strip device_token — the device WS auth secret (validated with
   timingSafeEqual) was returned in device list/get/update + pairing responses
   (SELECT d.* / *). New lib/device-sanitize.js strips it everywhere; prevents
   device impersonation by any workspace user.

#7 must_change_password enforced server-side — was a frontend-only redirect, so
   a provisioned temp password worked indefinitely via the API. requireAuth now
   403s every route except GET/PUT /api/auth/me (the password change, which
   clears the flag) and logout while the flag is set.

#8 XSS — escape user data interpolated into innerHTML in teams.js, kiosk.js,
   layout-editor.js (team/page/layout/zone names, member name/email, kiosk
   config fields). scriptSrcAttr 'unsafe-inline' made these exploitable via
   injected event handlers, not just markup.

#9 Thumbnail IDOR — /api/content/:id/thumbnail had no auth/scope gate (any UUID
   served any tenant's thumbnail). Now mirrors the /file route's playlist/widget
   workspace-scoped reference check.

Tests: new test/security-fixes.test.js (device strip, publicBranding field
allowlist, must_change_password gate). Full suite 41/41. Verified live against a
prod-data copy: device_token absent from /api/devices, /api/branding trimmed.

Not addressed here (tracked for follow-up): Android OTA signature verification
(Critical), public widget-render XSS, token revocation/logout, pairing-code
strength, validateRemoteUrl hardening, import quota.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:02:19 -05:00
ScreenTinker 66ef47239f fix(android): Android 14+ MediaProjection / foreground-service compliance (#5)
On Android 14+ (targetSdk 34) the app could fail to run at all on newer devices
(Pixel 10, onn HD stick). Root cause: the always-on WebSocketService called the
2-arg startForeground(), which claims EVERY foreground-service type declared in
the manifest - including mediaProjection. Android 14 rejects starting a
mediaProjection-typed FGS without a MediaProjection consent token, so the core
service threw on launch and the player never came up. Matches the reporter's
"screen recording policy" hunch - via the FGS type, not the capture trigger.

Fixes:
- WebSocketService now claims ONLY mediaPlayback (explicit
  startForeground(..., FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK), API>=29 guarded;
  2-arg on older). Manifest type narrowed to mediaPlayback.
- New MediaProjectionService (manifest type mediaProjection), started only AFTER
  the user grants consent. It enters the foreground with the mediaProjection type
  BEFORE getMediaProjection() (required on 14+), then drives ScreenCaptureService.
  The consent Activity now hands the result to this service instead of calling
  getMediaProjection() directly (an Activity can't hold that FGS type).
- ScreenCaptureService: register the MediaProjection.Callback BEFORE
  createVirtualDisplay() (Android 14 throws IllegalStateException otherwise).

Verified: Kotlin compiles, manifest merges (WebSocketService=mediaPlayback,
MediaProjectionService=mediaProjection), signed debug APK builds. NOT yet
verified on-device - needs a Pixel 10 / onn-stick run + logcat to confirm the
exact crash is resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 17:19:56 -05:00
screentinker d6e85b1745
Merge pull request #26 from screentinker/feat/global-default-branding-15
feat: instance-level default white-label branding (#15)
2026-06-08 17:03:39 -05:00
ScreenTinker eb13f716d0 feat(branding): instance-level default white-label branding (#15)
White-label is stored per-workspace (white_labels.workspace_id); unbranded and
new workspaces - and the login page - fell back to hardcoded ScreenTinker. Add a
single platform default that everything inherits beneath the per-workspace layer.

Resolution (lib/branding.js): workspace row -> custom-domain match -> platform
default -> hardcoded ScreenTinker. Row-level override: a workspace with its own
row keeps it (current behavior); only row-less workspaces inherit the default,
so editing the default propagates instantly (no row-copying at creation).

The platform default is a white_labels row with a FIXED id ('platform-default'),
not a "workspace_id IS NULL" sentinel - legacy pre-multitenancy rows can also
have a null workspace_id, which would be ambiguous.

- routes/admin.js: GET/PUT /api/admin/branding (requirePlatformAdmin) to read/
  upsert the single platform-default row; audit-logged.
- server.js: public GET /api/branding (domain match -> platform default ->
  hardcoded) for pre-login/pre-workspace contexts.
- routes/white-label.js: authed GET now falls back to the platform default
  (was hardcoded) for row-less workspaces.
- Frontend: login page resolves + applies branding (logo, name, colors, favicon,
  custom CSS) pre-auth; Admin page gets a "Default branding" form.

Tests: resolver order incl. legacy null-ws safety; admin GET/PUT (single row,
upsert, platform-admin-only 403). Full suite 37/37. Verified end-to-end:
public + authed + login-page all inherit the platform default; per-workspace
override preserved.

Closes #15.
2026-06-08 16:55:22 -05:00
screentinker 5433a97bc9
Merge pull request #25 from screentinker/fix/single-workspace-settings-19
fix(switcher): workspace settings inaccessible with a single workspace (#19)
2026-06-08 16:40:44 -05:00