diff --git a/frontend/js/api.js b/frontend/js/api.js
index a1ebecb..786516b 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -33,6 +33,11 @@ export const api = {
updateDevice: (id, data) => request(`/devices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteDevice: (id) => request(`/devices/${id}`, { method: 'DELETE' }),
+ // #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a
+ // device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT).
+ sendPip: (id, opts) => request('/pip', { method: 'POST', body: JSON.stringify({ device_id: id, ...opts }) }),
+ clearPip: (id, pipId) => request('/pip/clear', { method: 'POST', body: JSON.stringify({ device_id: id, pip_id: pipId || undefined }) }),
+
// Provisioning
pairDevice: (pairing_code, name) => request('/provision/pair', {
method: 'POST',
diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js
index 0cb65df..94570d8 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -410,6 +410,29 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.ctl.shutdown')}
+
+
+
@@ -868,6 +891,25 @@ async function setupActions(device) {
document.getElementById('forceUpdateBtn')?.addEventListener('click', () => {
sendWithFeedback('update', 'Update', 'device.toast.update_triggered');
});
+
+ // #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip).
+ document.getElementById('sendPipBtn')?.addEventListener('click', async () => {
+ const uri = (document.getElementById('pipUri')?.value || '').trim();
+ if (!uri) { showToast('Enter an overlay URL', 'error'); return; }
+ try {
+ const res = await api.sendPip(device.id, {
+ type: document.getElementById('pipType').value,
+ uri,
+ position: document.getElementById('pipPosition').value,
+ duration: Number(document.getElementById('pipDuration').value) || 0,
+ });
+ showToast(`Overlay sent (${res.sent} sent, ${res.offline} offline)`, res.sent ? 'success' : 'warning');
+ } catch (err) { showToast(err.message, 'error'); }
+ });
+ document.getElementById('clearPipBtn')?.addEventListener('click', async () => {
+ try { await api.clearPip(device.id); showToast('Overlay cleared', 'success'); }
+ catch (err) { showToast(err.message, 'error'); }
+ });
}
function setupRemote(device) {
diff --git a/server/config/api-surface.js b/server/config/api-surface.js
index d67485d..dc9a3da 100644
--- a/server/config/api-surface.js
+++ b/server/config/api-surface.js
@@ -36,6 +36,7 @@ const PUBLIC_ROUTERS = [
{ path: '/api/playlists', mod: './routes/playlists' },
{ path: '/api/activity', mod: './routes/activity' },
{ path: '/api/kiosk', mod: './routes/kiosk', renderBypass: true },
+ { path: '/api/pip', mod: './routes/pip' },
];
const JWT_ONLY_ROUTERS = [
diff --git a/server/routes/pip.js b/server/routes/pip.js
new file mode 100644
index 0000000..68a29e5
--- /dev/null
+++ b/server/routes/pip.js
@@ -0,0 +1,159 @@
+const express = require('express');
+const router = express.Router();
+const { v4: uuidv4 } = require('uuid');
+const { db } = require('../db/database');
+// #109 PiP: a real-time floating overlay PUSHED to a device/group. Fleet-affecting,
+// full-trust (a `web` overlay renders an arbitrary page in the player), so — like the
+// group command route — it requires the 'full' token scope. No-op for JWT sessions.
+const { requireScope } = require('../middleware/apiToken');
+
+// Reuse the existing 6-hex color contract (#RRGGBB). Overlay transparency is expressed
+// via the separate `opacity` field, so no alpha channel is accepted here.
+const VALID_COLOR = /^#[0-9A-Fa-f]{6}$/;
+const PIP_TYPES = ['image', 'web'];
+const PIP_POSITIONS = ['top-right', 'top-left', 'bottom-right', 'bottom-left', 'center'];
+
+// Numeric bounds (px / seconds). MVP keeps these conservative; sizes are clamped by
+// validation, not silently coerced.
+const DIM_MIN = 40, DIM_MAX = 3840; // overlay box px
+const DUR_MIN = 0, DUR_MAX = 86400; // seconds; 0 = until explicitly cleared
+const RADIUS_MAX = 512; // border-radius px
+
+function intInRange(v, def, lo, hi) {
+ if (v === undefined || v === null || v === '') return { ok: true, val: def };
+ const n = Number(v);
+ if (!Number.isFinite(n)) return { ok: false };
+ const r = Math.round(n);
+ if (r < lo || r > hi) return { ok: false };
+ return { ok: true, val: r };
+}
+
+function floatInRange(v, def, lo, hi) {
+ if (v === undefined || v === null || v === '') return { ok: true, val: def };
+ const n = Number(v);
+ if (!Number.isFinite(n) || n < lo || n > hi) return { ok: false };
+ return { ok: true, val: n };
+}
+
+// Resolve a target id to its online/offline device list within the CALLER'S workspace.
+// A device first, then a group; null if neither exists in this workspace (the handler
+// 404s). Scoping every query by req.workspaceId is the workspace-isolation guarantee:
+// a token bound to workspace A can never address a device/group in workspace B.
+function resolveTargets(req, id) {
+ const wsId = req.workspaceId;
+ if (!wsId || !id) return null;
+ const device = db.prepare('SELECT id, name, status FROM devices WHERE id = ? AND workspace_id = ?').get(id, wsId);
+ if (device) return { kind: 'device', devices: [device] };
+ const group = db.prepare('SELECT id, name FROM device_groups WHERE id = ? AND workspace_id = ?').get(id, wsId);
+ if (group) {
+ const devices = db.prepare(`
+ SELECT d.id, d.name, d.status FROM devices d
+ JOIN device_group_members dgm ON d.id = dgm.device_id
+ WHERE dgm.group_id = ? AND d.workspace_id = ?
+ `).all(id, wsId);
+ return { kind: 'group', devices };
+ }
+ return null;
+}
+
+// Emit `event` to each online target, mirroring the group command route's room-size
+// online check and {device_id, name, status: sent|offline} result shape. Offline
+// devices are reported, never queued — PiP is ephemeral (a stale flash on reconnect
+// is worse than a miss; see the proposal §6).
+function emitToTargets(req, devices, event, payload) {
+ const deviceNs = req.app.get('io').of('/device');
+ const results = [];
+ for (const device of devices) {
+ const room = deviceNs.adapter.rooms.get(device.id);
+ if (room && room.size > 0) {
+ deviceNs.to(device.id).emit(event, payload);
+ results.push({ device_id: device.id, name: device.name, status: 'sent' });
+ } else {
+ results.push({ device_id: device.id, name: device.name, status: 'offline' });
+ }
+ }
+ return results;
+}
+
+function summarize(results) {
+ const sent = results.filter(r => r.status === 'sent').length;
+ const offline = results.filter(r => r.status === 'offline').length;
+ return { sent, offline, total: results.length, results };
+}
+
+// POST /api/pip — show an overlay on a device or group.
+router.post('/', requireScope('full'), (req, res) => {
+ const b = req.body || {};
+
+ if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
+ if (!PIP_TYPES.includes(b.type)) return res.status(400).json({ error: `invalid type, use one of: ${PIP_TYPES.join(', ')}` });
+
+ // uri must be an absolute http(s) URL — the PLAYER fetches it directly (no server
+ // proxy), same trust model as remote_url content.
+ let parsed;
+ try { parsed = new URL(b.uri); } catch { return res.status(400).json({ error: 'uri must be a valid absolute URL' }); }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return res.status(400).json({ error: 'uri scheme must be http or https' });
+ }
+
+ const position = b.position == null || b.position === '' ? 'top-right' : b.position;
+ if (!PIP_POSITIONS.includes(position)) return res.status(400).json({ error: `invalid position, use one of: ${PIP_POSITIONS.join(', ')}` });
+
+ const width = intInRange(b.width, 480, DIM_MIN, DIM_MAX);
+ if (!width.ok) return res.status(400).json({ error: `width must be ${DIM_MIN}-${DIM_MAX}` });
+ const height = intInRange(b.height, 360, DIM_MIN, DIM_MAX);
+ if (!height.ok) return res.status(400).json({ error: `height must be ${DIM_MIN}-${DIM_MAX}` });
+ const duration = intInRange(b.duration, 0, DUR_MIN, DUR_MAX);
+ if (!duration.ok) return res.status(400).json({ error: `duration must be ${DUR_MIN}-${DUR_MAX} seconds (0 = until cleared)` });
+ const opacity = floatInRange(b.opacity, 1, 0, 1);
+ if (!opacity.ok) return res.status(400).json({ error: 'opacity must be between 0 and 1' });
+ const borderRadius = intInRange(b.border_radius, 0, 0, RADIUS_MAX);
+ if (!borderRadius.ok) return res.status(400).json({ error: `border_radius must be 0-${RADIUS_MAX}` });
+
+ if (b.title_color != null && b.title_color !== '' && !VALID_COLOR.test(b.title_color)) {
+ return res.status(400).json({ error: 'invalid title_color, use #RRGGBB' });
+ }
+ if (b.background_color != null && b.background_color !== '' && !VALID_COLOR.test(b.background_color)) {
+ return res.status(400).json({ error: 'invalid background_color, use #RRGGBB' });
+ }
+
+ const targets = resolveTargets(req, b.device_id);
+ if (!targets) return res.status(404).json({ error: 'device or group not found in this workspace' });
+
+ const pip_id = uuidv4();
+ const payload = {
+ pip_id,
+ type: b.type,
+ uri: b.uri,
+ position,
+ width: width.val,
+ height: height.val,
+ duration: duration.val,
+ opacity: opacity.val,
+ border_radius: borderRadius.val,
+ close_button: b.close_button === true,
+ };
+ if (b.title != null && b.title !== '') payload.title = String(b.title).slice(0, 200);
+ if (b.title_color) payload.title_color = b.title_color;
+ if (b.background_color) payload.background_color = b.background_color;
+
+ const results = emitToTargets(req, targets.devices, 'device:pip-show', payload);
+ res.json({ success: true, pip_id, target: targets.kind, ...summarize(results) });
+});
+
+// Clear an overlay. DELETE /api/pip and POST /api/pip/clear are equivalent; an omitted
+// pip_id clears whatever is showing.
+function handleClear(req, res) {
+ const b = req.body || {};
+ if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
+ const targets = resolveTargets(req, b.device_id);
+ if (!targets) return res.status(404).json({ error: 'device or group not found in this workspace' });
+ const payload = b.pip_id ? { pip_id: String(b.pip_id) } : {};
+ const results = emitToTargets(req, targets.devices, 'device:pip-clear', payload);
+ res.json({ success: true, target: targets.kind, ...summarize(results) });
+}
+
+router.post('/clear', requireScope('full'), handleClear);
+router.delete('/', requireScope('full'), handleClear);
+
+module.exports = router;
diff --git a/server/test/api.test.js b/server/test/api.test.js
index 6ed3221..13d3291 100644
--- a/server/test/api.test.js
+++ b/server/test/api.test.js
@@ -88,6 +88,12 @@ before(async () => {
S.deviceToken = 'devtok_' + crypto.randomBytes(16).toString('hex');
db.prepare("INSERT INTO devices (id,name,user_id,workspace_id,device_token,status,created_at) VALUES (?,?,?,?,?,'offline',strftime('%s','now'))")
.run(S.deviceId, 'WS-dev', S.user1, S.wsA, S.deviceToken);
+ // #109 PiP fixtures: a device in workspace B (cross-tenant isolation) and the wsA
+ // device as a member of the wsA group (group-targeting expansion).
+ S.deviceIdB = crypto.randomUUID();
+ db.prepare("INSERT INTO devices (id,name,user_id,workspace_id,device_token,status,created_at) VALUES (?,?,?,?,?,'offline',strftime('%s','now'))")
+ .run(S.deviceIdB, 'WS-dev-B', S.user1, S.wsB, 'devtok_' + crypto.randomBytes(16).toString('hex'));
+ db.prepare('INSERT INTO device_group_members (group_id, device_id) VALUES (?, ?)').run(S.groupId, S.deviceId);
db.close();
});
@@ -133,7 +139,7 @@ test('partition: the public token surface is exactly the reviewed set (snapshot
const EXPECTED_PUBLIC = [
'/api/devices', '/api/content', '/api/folders', '/api/assignments', '/api/layouts',
'/api/widgets', '/api/schedules', '/api/walls', '/api/reports', '/api/groups',
- '/api/playlists', '/api/activity', '/api/kiosk',
+ '/api/playlists', '/api/activity', '/api/kiosk', '/api/pip',
].sort();
assert.deepEqual(PUBLIC_ROUTERS.map(r => r.path).sort(), EXPECTED_PUBLIC);
});
@@ -312,3 +318,59 @@ test('token-auth: last_used_at is stamped on first use', async () => {
const after = (await jfetch('/api/tokens', auth(S.jwt))).body.find(t => t.id === created.body.id);
assert.ok(after.last_used_at, 'last_used_at is set after first use');
});
+
+// ───────────────────────── TIER 5: #109 PiP OVERLAY (POST /api/pip) ─────────────────────────
+// MVP: image/web overlay pushed to a device/group, full-scope, workspace-isolated.
+const pipBody = (over = {}) => ({ device_id: S.deviceId, type: 'image', uri: 'https://example.com/x.png', position: 'top-right', width: 480, height: 360, duration: 30, ...over });
+
+// authz: requireScope('full')
+test('pip: read/write tokens are rejected (403, needs full)', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.read, pipBody()))).status, 403);
+ assert.equal((await jfetch('/api/pip', post(S.tok.write, pipBody()))).status, 403);
+});
+test('pip: full token is accepted (offline device reported, not queued)', async () => {
+ const res = await jfetch('/api/pip', post(S.tok.full, pipBody()));
+ assert.equal(res.status, 200);
+ assert.equal(res.body.target, 'device');
+ assert.ok(res.body.pip_id, 'server generates a pip_id');
+ assert.equal(res.body.total, 1);
+ assert.equal(res.body.offline, 1, 'the offline device is reported offline');
+ assert.equal(res.body.sent, 0);
+});
+
+// workspace isolation: a wsA token cannot address a wsB device, nor a non-existent id.
+test('pip: workspace isolation — wsA token cannot target a wsB device (404)', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: S.deviceIdB }))).then(r => r.status)), 404);
+});
+test('pip: unknown device/group id is 404', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: crypto.randomUUID() })))).status, 404);
+});
+
+// group targeting: device_id resolves to a group and expands to its members.
+test('pip: group id expands to members (group with 1 member)', async () => {
+ const res = await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: S.groupId })));
+ assert.equal(res.status, 200);
+ assert.equal(res.body.target, 'group');
+ assert.equal(res.body.total, 1, 'the group has one member device');
+});
+
+// payload validation
+test('pip: payload validation (type / uri / position / bounds / color)', async () => {
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ type: 'video' })))).status, 400); // not in allowlist
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ uri: 'ftp://x/y' })))).status, 400); // bad scheme
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ uri: 'not a url' })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ position: 'middle' })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ width: 5 })))).status, 400); // below DIM_MIN
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ duration: -1 })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ opacity: 2 })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ background_color: 'red' })))).status, 400);
+ assert.equal((await jfetch('/api/pip', post(S.tok.full, pipBody({ device_id: '' })))).status, 400);
+});
+
+// clear: POST /api/pip/clear and DELETE /api/pip, both full-scope.
+test('pip clear: full token clears (POST /clear and DELETE), read token rejected', async () => {
+ assert.equal((await jfetch('/api/pip/clear', post(S.tok.full, { device_id: S.deviceId }))).status, 200);
+ assert.equal((await jfetch('/api/pip/clear', post(S.tok.read, { device_id: S.deviceId }))).status, 403);
+ const del = await jfetch('/api/pip', { method: 'DELETE', ...auth(S.tok.full), body: JSON.stringify({ device_id: S.deviceId }) });
+ assert.equal(del.status, 200);
+});
diff --git a/server/test/pip-overlay.test.js b/server/test/pip-overlay.test.js
new file mode 100644
index 0000000..0b78626
--- /dev/null
+++ b/server/test/pip-overlay.test.js
@@ -0,0 +1,135 @@
+'use strict';
+
+// #109 PiP player-layer test. Loads the REAL tizen/js/player.js + tizen/js/pip-overlay.js
+// into a vm context with a minimal DOM shim (the repo has no jsdom; node --test only).
+// Proves the overlay shows and auto-dismisses WITHOUT changing the playlist signature
+// underneath — i.e. PipOverlay writes only to #pip and never to #stage / PlaylistPlayer.
+
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+// --- minimal DOM element shim: only what PlaylistPlayer.renderImage + PipOverlay use ---
+function makeEl() {
+ const el = {
+ tag: '', style: {}, className: '', attrs: {}, children: [], _html: '', _src: '', _text: '',
+ appendChild(c) { this.children.push(c); this._html = ''; return c; },
+ querySelector(sel) { return this.children.find(c => c.tag === sel) || null; },
+ setAttribute(k, v) { this.attrs[k] = v; },
+ removeAttribute(k) { delete this.attrs[k]; },
+ addEventListener() {}, removeEventListener() {},
+ classList: { add() {}, remove() {}, contains() { return false; } },
+ load() {}, pause() {}, play() { return { catch() {} }; },
+ };
+ Object.defineProperty(el, 'innerHTML', { get() { return this._html; }, set(v) { this._html = v; if (v === '') this.children = []; } });
+ Object.defineProperty(el, 'src', { get() { return this._src; }, set(v) { this._src = v; } });
+ Object.defineProperty(el, 'textContent', { get() { return this._text; }, set(v) { this._text = v; } });
+ return el;
+}
+
+function loadPlayerContext() {
+ // Controllable timer so the duration teardown is deterministic (no wall-clock waits).
+ const timers = {};
+ let seq = 0;
+ const sandbox = {
+ console,
+ Date,
+ setTimeout: (fn) => { const id = ++seq; timers[id] = fn; return id; },
+ clearTimeout: (id) => { delete timers[id]; },
+ setInterval: () => 0,
+ clearInterval: () => {},
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
+ navigator: { language: 'en' },
+ };
+ sandbox.document = { createElement: (tag) => { const e = makeEl(); e.tag = tag; return e; } };
+ sandbox.window = sandbox;
+ vm.createContext(sandbox);
+ const read = (p) => fs.readFileSync(path.join(__dirname, '..', '..', 'tizen', 'js', p), 'utf8');
+ vm.runInContext(read('player.js'), sandbox, { filename: 'player.js' });
+ vm.runInContext(read('pip-overlay.js'), sandbox, { filename: 'pip-overlay.js' });
+ return { sandbox, timers };
+}
+
+test('pip: overlay shows in #pip and never touches #stage / the playlist signature', () => {
+ const { sandbox } = loadPlayerContext();
+ const stage = makeEl();
+ const pip = makeEl();
+
+ // A 1-item image playlist; capture the signature the renderer computes.
+ const player = new sandbox.PlaylistPlayer(stage, () => 'http://server');
+ player.load([{ content_id: 'c1', mime_type: 'image/png', sort_order: 0, duration_sec: 10 }]);
+ const sigBefore = player.sig;
+ const stageChildrenBefore = stage.children.length;
+ assert.ok(sigBefore, 'player computed a playlist signature');
+ assert.ok(stageChildrenBefore >= 1, 'playlist rendered into #stage');
+
+ const logs = [];
+ const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document, log: (lvl, msg) => logs.push([lvl, msg]) });
+
+ overlay.show({ pip_id: 'p1', type: 'image', uri: 'http://img/x.png', position: 'top-right', width: 480, height: 360, duration: 30 });
+ assert.equal(pip.children.length, 1, 'overlay box rendered into #pip');
+ assert.equal(player.sig, sigBefore, 'playlist signature unchanged by pip show');
+ assert.equal(stage.children.length, stageChildrenBefore, '#stage untouched by pip show');
+ assert.ok(logs.some(l => l[1].indexOf('pip show') === 0), 'show reported over the log channel');
+});
+
+test('pip: duration timer auto-dismisses without disturbing the playlist', () => {
+ const { sandbox, timers } = loadPlayerContext();
+ const stage = makeEl();
+ const pip = makeEl();
+ const player = new sandbox.PlaylistPlayer(stage, () => 'http://server');
+ player.load([{ content_id: 'c1', mime_type: 'image/png', sort_order: 0, duration_sec: 10 }]);
+ const sigBefore = player.sig;
+
+ const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document });
+ overlay.show({ pip_id: 'p1', type: 'image', uri: 'http://img/x.png', duration: 5 });
+ assert.equal(pip.children.length, 1, 'overlay shown');
+
+ // Fire the scheduled duration timer (deterministic: the sandbox setTimeout captured it).
+ const ids = Object.keys(timers);
+ assert.equal(ids.length, 1, 'a single duration timer was scheduled');
+ timers[ids[0]]();
+
+ assert.equal(pip.children.length, 0, 'overlay auto-dismissed at duration');
+ assert.equal(player.sig, sigBefore, 'playlist signature still unchanged after dismiss');
+});
+
+test('pip: web type renders an iframe; last-show-wins; targeted clear is id-aware', () => {
+ const { sandbox } = loadPlayerContext();
+ const pip = makeEl();
+ const overlay = new sandbox.PipOverlay(pip, { document: sandbox.document });
+
+ overlay.show({ pip_id: 'web1', type: 'web', uri: 'https://example.com', duration: 0 });
+ assert.equal(pip.children.length, 1);
+ const box = pip.children[0];
+ assert.ok(box.children.some(c => c.tag === 'iframe'), 'web overlay uses an