feat(home): live updates via Mercure — server pushes device state to the PWA
CI / test (push) Has been cancelled

Subscribe per-device with a Symfony Mercure hub: server publishes a fresh
device payload after every poll (200/304/204), every PATCH, and every
lock/unlock. The frontend opens one EventSource per device topic and
splats inbound JSON straight into the devices store — same shape as
GET /api/devices, so no envelope handling.

Topic: https://pictureframe.edholm.me/devices/{id}

Stack mirrors aqua-iq:
- symfony/mercure-bundle + config/packages/mercure.yaml
- App\Service\MercurePublisher (errors swallowed + logged; a flaky hub
  must not break a poll response)
- App\Service\DeviceSerializer extracted as the single source of truth
  for the wire shape (REST + Mercure share it)
- Frontend useDeviceMercure() composable: opens/closes EventSources to
  match the device list reactively, reconnects on hub-side closes
- SpaController exposes MERCURE_PUBLIC_URL via window.__PF_MERCURE_URL__

Production compose adds a dunglas/mercure container with Traefik labels
for pictureframe.edholm.me/.well-known/mercure (handled separately on
the host since the file isn't in this repo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 16:20:21 -04:00
parent 995445ed9e
commit ba9625d45d
32 changed files with 529 additions and 43 deletions
+9
View File
@@ -52,3 +52,12 @@ MAILER_SENDER=noreply@pictureframe.edholm.me
SHARE_TOKEN_TTL_DAYS=7
HARD_DELETE_TOKEN_TTL_DAYS=30
###< pictureframe ###
###> symfony/mercure-bundle ###
# Internal URL the Symfony app uses to publish — matches the mercure service name.
MERCURE_URL=http://mercure/.well-known/mercure
# Public URL the browser subscribes to. Overridden in production envs.
MERCURE_PUBLIC_URL=http://localhost/.well-known/mercure
# Default secret for dev only; production sets a 32-byte hex value via .env.prod.
MERCURE_JWT_SECRET=changeme
###< symfony/mercure-bundle ###
+1
View File
@@ -23,6 +23,7 @@
"symfony/http-client": "7.4.*",
"symfony/intl": "7.4.*",
"symfony/mailer": "7.4.*",
"symfony/mercure-bundle": "^0.4.2",
"symfony/messenger": "7.4.*",
"symfony/mime": "7.4.*",
"symfony/monolog-bundle": "^3.0|^4.0",
Generated
+240 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "825ff6a99861f07a048495aa7ff53a31",
"content-hash": "b4497c0a9b6d2e48309f07088b6b6e8b",
"packages": [
{
"name": "doctrine/collections",
@@ -1187,6 +1187,79 @@
],
"time": "2025-03-06T22:45:56+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -4082,6 +4155,172 @@
],
"time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/mercure",
"version": "v0.7.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/mercure.git",
"reference": "3ba1d19c9792d6bf66cf6cb4412ea289e9a42565"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mercure/zipball/3ba1d19c9792d6bf66cf6cb4412ea289e9a42565",
"reference": "3ba1d19c9792d6bf66cf6cb4412ea289e9a42565",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/deprecation-contracts": "^2.0|^3.0",
"symfony/http-client": "^6.4|^7.3|^8.0",
"symfony/http-foundation": "^6.4|^7.3|^8.0",
"symfony/web-link": "^6.4|^7.3|^8.0"
},
"require-dev": {
"lcobucci/jwt": "^3.4|^4.0|^5.0",
"symfony/event-dispatcher": "^6.4|^7.3|^8.0",
"symfony/http-kernel": "^6.4|^7.3|^8.0",
"symfony/phpunit-bridge": "^7.3.4|^8.0",
"symfony/stopwatch": "^6.4|^7.3|^8.0",
"twig/twig": "^2.0|^3.0|^4.0"
},
"suggest": {
"symfony/stopwatch": "Integration with the profiler performances"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/dunglas/mercure",
"name": "dunglas/mercure"
},
"branch-alias": {
"dev-main": "0.6.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\Mercure\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "dunglas@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony Mercure Component",
"homepage": "https://symfony.com",
"keywords": [
"mercure",
"push",
"sse",
"updates"
],
"support": {
"issues": "https://github.com/symfony/mercure/issues",
"source": "https://github.com/symfony/mercure/tree/v0.7.2"
},
"funding": [
{
"url": "https://github.com/dunglas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/mercure",
"type": "tidelift"
}
],
"time": "2025-12-15T15:22:09+00:00"
},
{
"name": "symfony/mercure-bundle",
"version": "v0.4.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/mercure-bundle.git",
"reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3",
"reference": "eae8bf5a75b4e1203bd9aa4181c7950a4df4b3e3",
"shasum": ""
},
"require": {
"lcobucci/jwt": "^3.4|^4.0|^5.0",
"php": ">=8.1",
"symfony/config": "^6.4|^7.3|^8.0",
"symfony/dependency-injection": "^6.4|^7.3|^8.0",
"symfony/http-kernel": "^6.4|^7.3|^8.0",
"symfony/mercure": "*",
"symfony/web-link": "^6.4|^7.3|^8.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^7.3.4|^8.0",
"symfony/stopwatch": "^6.4|^7.3|^8.0",
"symfony/ux-turbo": "*",
"symfony/var-dumper": "^6.4|^7.3|^8.0"
},
"suggest": {
"symfony/messenger": "To use the Messenger integration"
},
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-main": "0.3.x-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MercureBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Kévin Dunglas",
"email": "dunglas@gmail.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MercureBundle",
"homepage": "https://symfony.com",
"keywords": [
"mercure",
"push",
"sse",
"updates"
],
"support": {
"issues": "https://github.com/symfony/mercure-bundle/issues",
"source": "https://github.com/symfony/mercure-bundle/tree/v0.4.2"
},
"funding": [
{
"url": "https://github.com/dunglas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle",
"type": "tidelift"
}
],
"time": "2025-11-25T12:51:49+00:00"
},
{
"name": "symfony/messenger",
"version": "v7.4.8",
+1
View File
@@ -13,4 +13,5 @@ return [
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
DAMA\DoctrineTestBundle\DAMADoctrineTestBundle::class => ['test' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
];
+8
View File
@@ -0,0 +1,8 @@
mercure:
hubs:
default:
url: '%env(default::MERCURE_URL)%'
public_url: '%env(default::MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SECRET)%'
publish: '*'
@@ -0,0 +1,113 @@
import { onUnmounted, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useDevicesStore } from '@/stores/devices'
import type { Device } from '@/types'
const TOPIC_PREFIX = 'https://pictureframe.edholm.me/devices/'
declare global {
interface Window {
__PF_MERCURE_URL__?: string
}
}
/**
* Open one EventSource per device topic and merge inbound updates into the
* devices store. Reconnect on close (Mercure usually proxies through Traefik
* which can drop idle connections). Cleans up on unmount.
*
* The subscription set is reactive when the device list changes (a new
* frame is provisioned, or one is removed), connections are added/dropped
* to match. The server publishes the same JSON shape that GET /api/devices
* returns, so updates are a straight splat into the existing store entry.
*/
export function useDeviceMercure() {
const devicesStore = useDevicesStore()
const { devices } = storeToRefs(devicesStore)
// deviceId → EventSource. Tracked outside Vue's reactivity to avoid
// accidentally proxying the native EventSource and breaking it.
const sources = new Map<number, EventSource>()
// deviceId → reconnect timer handle.
const reconnectTimers = new Map<number, number>()
const baseUrl = window.__PF_MERCURE_URL__
if (!baseUrl) {
// No URL configured (dev without a hub, or SSR-render fallback) — quietly
// no-op rather than throwing. Polling-on-visibility-change is still wired
// up in HomeView, so the UI keeps working.
return { connectedCount: () => 0 }
}
function open(deviceId: number) {
if (sources.has(deviceId)) return
try {
const url = new URL(baseUrl!)
url.searchParams.append('topic', TOPIC_PREFIX + deviceId)
const es = new EventSource(url.toString(), { withCredentials: true })
es.onmessage = (event) => {
try {
const updated = JSON.parse(event.data) as Device
const idx = devices.value.findIndex(d => d.id === updated.id)
if (idx !== -1) {
// Splice replacement so Vue's reactivity tracks the swap.
devices.value[idx] = updated
}
} catch (e) {
console.warn('[mercure] parse error', e)
}
}
es.onerror = () => {
// Mercure / Traefik will sometimes close idle connections; reopen
// after a short delay rather than spinning. CLOSED is the only
// terminal state that needs a manual reconnect.
if (es.readyState === EventSource.CLOSED) {
close(deviceId)
const handle = window.setTimeout(() => {
reconnectTimers.delete(deviceId)
open(deviceId)
}, 5000)
reconnectTimers.set(deviceId, handle)
}
}
sources.set(deviceId, es)
} catch (e) {
console.warn('[mercure] open failed for device ' + deviceId, e)
}
}
function close(deviceId: number) {
const es = sources.get(deviceId)
if (es) {
es.close()
sources.delete(deviceId)
}
const timer = reconnectTimers.get(deviceId)
if (timer !== undefined) {
clearTimeout(timer)
reconnectTimers.delete(deviceId)
}
}
// Sync subscriptions to the current device list.
watch(
devices,
(list) => {
const wantIds = new Set(list.map(d => d.id))
// Open new ones.
for (const id of wantIds) if (!sources.has(id)) open(id)
// Close stale ones.
for (const id of [...sources.keys()]) if (!wantIds.has(id)) close(id)
},
{ immediate: true, deep: false },
)
onUnmounted(() => {
for (const id of [...sources.keys()]) close(id)
})
return { connectedCount: () => sources.size }
}
+5
View File
@@ -208,6 +208,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useDevicesStore } from '@/stores/devices'
import { useUploadStore } from '@/stores/upload'
import { useDeviceMercure } from '@/composables/useDeviceMercure'
import type { Device } from '@/types'
// Sync interval for status comparisons. Devices configured with explicit wake
@@ -330,6 +331,10 @@ const router = useRouter()
const devicesStore = useDevicesStore()
const uploadStore = useUploadStore()
// Live updates: server publishes a fresh device payload after every poll
// (and on PATCH/lock/unlock); the composable splats it into the store.
useDeviceMercure()
onMounted(() => {
devicesStore.fetchDevices()
document.addEventListener('visibilitychange', onVisibility)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{_ as e,d as t,f as n,g as r,j as i,k as a,m as o,pt as s,s as c,t as l,u,v as d,z as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,t as m}from"./BaseBottomSheet-CV7IMg-N.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(d({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(l,{emit:d}){let y=l,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=u(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(u,d)=>(a(),n(m,{"model-value":l.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>u.$emit(`update:modelValue`,e)},{default:f(()=>[d[2]||=t(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=t(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),t(`div`,h,[(a(!0),o(c,null,i(l.devices,e=>(a(),o(`label`,{key:e.id,class:`device-picker__row`},[t(`input`,{type:`checkbox`,class:`device-picker__check`,checked:l.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),t(`span`,_,s(e.name),1),t(`span`,v,s(e.orientation),1)]))),128))]),e(p,{variant:`primary`,class:`device-picker__confirm`,disabled:l.selected.length===0||l.uploading,onClick:d[0]||=e=>u.$emit(`confirm`)},{default:f(()=>[r(s(l.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
@@ -1 +0,0 @@
import{A as e,O as t,R as n,_ as r,d as i,ft as a,g as o,h as s,l as c,o as l,p as u,t as d,u as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,t as m}from"./BaseBottomSheet-Y2PW4H1i.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=d(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:d}){let y=r,b=d;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,d)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":d[1]||=e=>c.$emit(`update:modelValue`,e)},{default:n(()=>[d[2]||=f(`h2`,{class:`device-picker__title`},`Add to frames`,-1),d[3]||=f(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),f(`div`,h,[(t(!0),u(l,null,e(r.devices,e=>(t(),u(`label`,{key:e.id,class:`device-picker__row`},[f(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),f(`span`,_,a(e.name),1),f(`span`,v,a(e.orientation),1)]))),128))]),o(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:d[0]||=e=>c.$emit(`confirm`)},{default:n(()=>[s(a(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{H as e,M as t,d as n,dt as r,ft as i,k as a,m as o,t as s,u as c,v as l}from"./_plugin-vue_export-helper-DRLwVS0w.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=s(l({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(s){let l=s,d=e(0),f=e(!1),p=0,m=0,h=!1,g=null,_=c(()=>Math.min(d.value/l.threshold,1)),v=c(()=>f.value?1:Math.min(d.value/l.threshold,1)),y=c(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||l.isAtTop&&!l.isAtTop()||e.touches.length===1&&(p=e.touches[0].clientY,m=e.touches[0].clientX,h=!0,g=null)}function x(e){if(!h)return;let t=e.touches[0].clientX-m,n=e.touches[0].clientY-p;if(g===null){if(Math.abs(t)<6&&Math.abs(n)<6)return;g=Math.abs(t)>Math.abs(n)?`x`:`y`}if(g===`x`||n<=0){h=!1,d.value=0;return}if(l.isAtTop&&!l.isAtTop()){h=!1,d.value=0;return}let r=n<l.maxPull?n*.5:l.maxPull*.5+(n-l.maxPull)*.1;d.value=Math.min(r,l.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=l.threshold){f.value=!0,d.value=l.threshold*.7;try{await l.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(e,s)=>(a(),o(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[n(`div`,{class:r([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:i({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(a(),o(`div`,u)):(a(),o(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:i({transform:`rotate(${_.value*180}deg)`})},[...s[0]||=[n(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),n(`polyline`,{points:`6 13 12 19 18 13`,stroke:`currentColor`,"stroke-width":`2.5`,fill:`none`,"stroke-linecap":`round`,"stroke-linejoin":`round`},null,-1)]],4))],6),n(`div`,{class:`ptr__content`,style:i(y.value)},[t(e.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
@@ -1 +0,0 @@
import{O as e,V as t,_ as n,dt as r,j as i,l as a,p as o,t as s,u as c,ut as l}from"./_plugin-vue_export-helper-CeYnMxKK.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=s(n({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(n){let s=n,d=t(0),f=t(!1),p=0,m=0,h=!1,g=null,_=a(()=>Math.min(d.value/s.threshold,1)),v=a(()=>f.value?1:Math.min(d.value/s.threshold,1)),y=a(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||s.isAtTop&&!s.isAtTop()||e.touches.length===1&&(p=e.touches[0].clientY,m=e.touches[0].clientX,h=!0,g=null)}function x(e){if(!h)return;let t=e.touches[0].clientX-m,n=e.touches[0].clientY-p;if(g===null){if(Math.abs(t)<6&&Math.abs(n)<6)return;g=Math.abs(t)>Math.abs(n)?`x`:`y`}if(g===`x`||n<=0){h=!1,d.value=0;return}if(s.isAtTop&&!s.isAtTop()){h=!1,d.value=0;return}let r=n<s.maxPull?n*.5:s.maxPull*.5+(n-s.maxPull)*.1;d.value=Math.min(r,s.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=s.threshold){f.value=!0,d.value=s.threshold*.7;try{await s.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(t,n)=>(e(),o(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[c(`div`,{class:l([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:r({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(e(),o(`div`,u)):(e(),o(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:r({transform:`rotate(${_.value*180}deg)`})},[...n[0]||=[c(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),c(`polyline`,{points:`6 13 12 19 18 13`,stroke:`currentColor`,"stroke-width":`2.5`,fill:`none`,"stroke-linecap":`round`,"stroke-linejoin":`round`},null,-1)]],4))],6),c(`div`,{class:`ptr__content`,style:r(y.value)},[i(t.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
@@ -0,0 +1 @@
import{K as e,d as t,dt as n,ft as r,j as i,k as a,m as o,p as s,pt as c,s as l,t as u,u as d,v as f}from"./_plugin-vue_export-helper-DRLwVS0w.js";import{n as p,r as m,t as h}from"./index-CJkFKvc-.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(f({__name:`SettingsView`,setup(u){let f=m(),{saveTheme:T}=p(),E=d(()=>f.user?.theme??`warm-craft`);function D(e){T(e)}return(u,d)=>(a(),o(`main`,g,[d[5]||=t(`h1`,{class:`settings__title`},`Settings`,-1),t(`section`,_,[d[1]||=t(`h2`,{class:`settings__section-title`},`Theme`,-1),t(`div`,v,[(a(!0),o(l,null,i(e(h),e=>(a(),o(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:n([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:r({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[d[0]||=t(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[t(`span`,{class:`theme-swatch__bar`}),t(`span`,{class:`theme-swatch__dot`})],-1),t(`span`,b,c(e.label),1),E.value===e.id?(a(),o(`span`,x,``)):s(``,!0)],14,y))),128))])]),t(`section`,S,[d[3]||=t(`h2`,{class:`settings__section-title`},`Account`,-1),t(`div`,C,[d[2]||=t(`span`,{class:`settings__row-label`},`Signed in as`,-1),t(`span`,w,c(e(f).user?.email),1)]),d[4]||=t(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
@@ -1 +0,0 @@
import{A as e,G as t,O as n,_ as r,dt as i,f as a,ft as o,l as s,o as c,p as l,t as u,u as d,ut as f}from"./_plugin-vue_export-helper-CeYnMxKK.js";import{n as p,r as m,t as h}from"./index-DabTPrXi.js";var g={class:`settings`},_={class:`settings__section`},v={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},y=[`aria-checked`,`aria-label`,`onClick`],b={class:`theme-swatch__label`},x={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},S={class:`settings__section`},C={class:`settings__row`},w={class:`settings__row-value`},T=u(r({__name:`SettingsView`,setup(r){let u=m(),{saveTheme:T}=p(),E=s(()=>u.user?.theme??`warm-craft`);function D(e){T(e)}return(r,s)=>(n(),l(`main`,g,[s[5]||=d(`h1`,{class:`settings__title`},`Settings`,-1),d(`section`,_,[s[1]||=d(`h2`,{class:`settings__section-title`},`Theme`,-1),d(`div`,v,[(n(!0),l(c,null,e(t(h),e=>(n(),l(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":E.value===e.id,"aria-label":e.label,class:f([`theme-swatch`,{"theme-swatch--active":E.value===e.id}]),style:i({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>D(e.id)},[s[0]||=d(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[d(`span`,{class:`theme-swatch__bar`}),d(`span`,{class:`theme-swatch__dot`})],-1),d(`span`,b,o(e.label),1),E.value===e.id?(n(),l(`span`,x,``)):a(``,!0)],14,y))),128))])]),d(`section`,S,[s[3]||=d(`h2`,{class:`settings__section-title`},`Account`,-1),d(`div`,C,[s[2]||=d(`span`,{class:`settings__row-label`},`Signed in as`,-1),d(`span`,w,o(t(u).user?.email),1)]),s[4]||=d(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)])]))}}),[[`__scopeId`,`data-v-76ec3881`]]);export{T as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -14,8 +14,8 @@
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
<script type="module" crossorigin src="/build/assets/index-DabTPrXi.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-CeYnMxKK.js">
<script type="module" crossorigin src="/build/assets/index-CJkFKvc-.js"></script>
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-DRLwVS0w.js">
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
</head>
<body>
+15 -23
View File
@@ -10,6 +10,8 @@ use App\Entity\RenderedAsset;
use App\Entity\User;
use App\Enum\Orientation;
use App\Enum\RenderStatus;
use App\Service\DeviceSerializer;
use App\Service\MercurePublisher;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
@@ -39,7 +41,9 @@ class DeviceApiController extends AbstractController
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
private readonly string $projectDir,
private readonly DeviceSerializer $serializer,
private readonly MercurePublisher $mercure,
) {}
#[Route('', name: 'api_devices_list', methods: ['GET'])]
@@ -49,7 +53,7 @@ class DeviceApiController extends AbstractController
$user = $this->getUser();
$devices = $em->getRepository(Device::class)->findBy(['user' => $user]);
return $this->json(array_map($this->serialize(...), $devices));
return $this->json(array_map($this->serializer->serialize(...), $devices));
}
#[Route('/{id}', name: 'api_device_update', methods: ['PATCH'])]
@@ -116,8 +120,10 @@ class DeviceApiController extends AbstractController
}
$em->flush();
$payload = $this->serializer->serialize($device);
$this->mercure->publishDevice((int) $device->getId(), $payload);
return $this->json($this->serialize($device));
return $this->json($payload);
}
#[Route('/{id}/lock', name: 'api_device_lock', methods: ['PUT'])]
@@ -149,8 +155,10 @@ class DeviceApiController extends AbstractController
$device->setLockedImage($image);
$em->flush();
$payload = $this->serializer->serialize($device);
$this->mercure->publishDevice((int) $device->getId(), $payload);
return $this->json($this->serialize($device));
return $this->json($payload);
}
#[Route('/{id}/lock', name: 'api_device_unlock', methods: ['DELETE'])]
@@ -166,28 +174,12 @@ class DeviceApiController extends AbstractController
$device->setLockedImage(null);
$em->flush();
$payload = $this->serializer->serialize($device);
$this->mercure->publishDevice((int) $device->getId(), $payload);
return $this->json($this->serialize($device));
return $this->json($payload);
}
private function serialize(Device $d): array
{
return [
'id' => $d->getId(),
'mac' => $d->getMac(),
'name' => $d->getName(),
'orientation' => $d->getOrientation()->value,
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
'wakeTimes' => $d->getWakeTimes(),
'timezone' => $d->getTimezone(),
'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
'nextPollExpectedAt' => $d->getNextPollExpectedAt()?->format(\DateTimeInterface::ATOM),
'lockedImageId' => $d->getLockedImage()?->getId(),
'currentImageId' => $d->getCurrentImage()?->getId(),
];
}
/**
* Serve a PNG preview of the image **currently shown on the frame**,
+19 -3
View File
@@ -7,6 +7,8 @@ namespace App\Controller;
use App\Entity\Device;
use App\Entity\RenderedAsset;
use App\Enum\RenderStatus;
use App\Service\DeviceSerializer;
use App\Service\MercurePublisher;
use App\Service\RotationService;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
@@ -21,9 +23,11 @@ class DeviceImageController extends AbstractController
{
public function __construct(
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
private readonly RotationService $rotation,
private readonly LoggerInterface $logger,
private readonly string $projectDir,
private readonly RotationService $rotation,
private readonly LoggerInterface $logger,
private readonly DeviceSerializer $serializer,
private readonly MercurePublisher $mercure,
) {}
private function computeIntervalMs(Device $device): int
@@ -72,6 +76,13 @@ class DeviceImageController extends AbstractController
// (they previously didn't flush at all — latent bug for lastSeenAt).
$em->flush();
// Push the new state to any subscribed PWA clients. Done before we
// know which response branch we'll take — lastSeenAt + nextPollExpectedAt
// moved on every successful poll regardless of image change, and the
// PWA cares about both. If the 200 path mutates currentImage below,
// the second flush triggers a second publish to keep it accurate.
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
// Locked image bypasses rotation entirely.
$image = $device->getLockedImage() ?? $this->rotation->advance($device);
@@ -124,6 +135,7 @@ class DeviceImageController extends AbstractController
// though the device has been confirming the new one for cycles.
$device->setCurrentImage($image);
$em->flush();
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
$this->logger->info('device.poll.no_change', [
'device_id' => $device->getId(),
@@ -164,6 +176,10 @@ class DeviceImageController extends AbstractController
$device->setCurrentRenderedAt($renderedAt);
$em->flush();
// Re-publish: currentImageId just changed, so the SPA needs the
// updated thumbnail URL. Cheap; the hub deduplicates per topic.
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
$this->logger->info('device.poll.served', [
'device_id' => $device->getId(),
'mac' => $mac,
+11 -2
View File
@@ -17,6 +17,8 @@ class SpaController extends AbstractController
public function __construct(
#[Autowire('%kernel.project_dir%/public/build')]
private readonly string $buildDir,
#[Autowire('%env(default::MERCURE_PUBLIC_URL)%')]
private readonly string $mercurePublicUrl = '',
) {}
#[Route(
@@ -50,8 +52,15 @@ class SpaController extends AbstractController
// Inject theme on <html> so CSS applies before JS hydrates (no FOUC)
$html = str_replace('<html lang="en">', '<html lang="en" data-theme="' . htmlspecialchars($theme, ENT_QUOTES) . '">', $html);
// Bootstrap current user into window so Pinia auth store needs no initial API call
$html = str_replace('</head>', '<script>window.__PF_USER__=' . $userData . ';</script></head>', $html);
// Bootstrap current user into window so Pinia auth store needs no initial API call.
// Also expose the Mercure public URL so the live-updates composable can subscribe
// without needing its own /api/config round trip.
$mercureJson = json_encode($this->mercurePublicUrl, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT);
$html = str_replace(
'</head>',
'<script>window.__PF_USER__=' . $userData . ';window.__PF_MERCURE_URL__=' . $mercureJson . ';</script></head>',
$html,
);
return new Response($html, headers: ['Content-Type' => 'text/html; charset=utf-8']);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Device;
/**
* Single source of truth for the wire shape of a Device. The REST API uses it
* to render `/api/devices` responses; the Mercure publisher uses it so live
* pushes are byte-identical to a fresh GET, letting the SPA splat the payload
* straight into its store.
*/
final class DeviceSerializer
{
/** @return array<string, mixed> */
public function serialize(Device $d): array
{
return [
'id' => $d->getId(),
'mac' => $d->getMac(),
'name' => $d->getName(),
'orientation' => $d->getOrientation()->value,
'rotationIntervalMinutes' => $d->getRotationIntervalMinutes(),
'wakeTimes' => $d->getWakeTimes(),
'timezone' => $d->getTimezone(),
'uniquenessWindow' => $d->getUniquenessWindow(),
'linkedAt' => $d->getLinkedAt()->format(\DateTimeInterface::ATOM),
'lastSeenAt' => $d->getLastSeenAt()?->format(\DateTimeInterface::ATOM),
'nextPollExpectedAt' => $d->getNextPollExpectedAt()?->format(\DateTimeInterface::ATOM),
'lockedImageId' => $d->getLockedImage()?->getId(),
'currentImageId' => $d->getCurrentImage()?->getId(),
];
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Service;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
/**
* Thin wrapper around the Mercure hub for device-state pushes.
*
* Topic shape: `https://pictureframe.edholm.me/devices/{id}` same convention
* aqua-iq uses. The browser subscribes per device id; the published payload is
* the same JSON the REST API would have returned, so the SPA can splat it
* straight into the device store with no separate envelope handling.
*
* Errors are swallowed and logged a flaky hub must never break a poll
* response or a settings PATCH for the user.
*/
final class MercurePublisher
{
public const TOPIC_PREFIX = 'https://pictureframe.edholm.me/devices/';
public function __construct(
private readonly HubInterface $hub,
private readonly LoggerInterface $logger,
) {}
public function publishDevice(int $deviceId, array $payload): void
{
try {
$this->hub->publish(new Update(
self::TOPIC_PREFIX . $deviceId,
json_encode($payload, JSON_THROW_ON_ERROR),
));
} catch (\Throwable $e) {
$this->logger->warning('mercure.publish_failed', [
'device_id' => $deviceId,
'error' => $e->getMessage(),
]);
}
}
}
+12
View File
@@ -140,6 +140,18 @@
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
}
},
"symfony/mercure-bundle": {
"version": "0.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "0.4",
"ref": "b141b8c8f13bc8c31d718a5488039b712c0d3592"
},
"files": [
"config/packages/mercure.yaml"
]
},
"symfony/messenger": {
"version": "7.4",
"recipe": {