Compare commits

..

2 commits

Author SHA1 Message Date
Benjamin Nussbaum
10effba08c chore(docker): add instructions on how to generate an APP_KEY
Some checks failed
tests / ci (push) Has been cancelled
2026-03-06 14:35:56 +01:00
Benjamin Nussbaum
00f5c8b01e chore: update dependencies 2026-03-06 14:35:56 +01:00
25 changed files with 198 additions and 434 deletions

View file

@ -6,6 +6,7 @@ on:
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build-and-push: build-and-push:
@ -39,9 +40,7 @@ jobs:
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.REGISTRY }}/usetrmnl/byos_laravel
${{ env.REGISTRY }}/usetrmnl/larapaper
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}

View file

@ -3,8 +3,8 @@
######################## ########################
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:ed705a4060d50143ddc538c1288afff217eaf76ad5791f7556a97943854cf745 AS base FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:ed705a4060d50143ddc538c1288afff217eaf76ad5791f7556a97943854cf745 AS base
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/larapaper LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
LABEL org.opencontainers.image.description="LaraPaper" LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
LABEL org.opencontainers.image.licenses=MIT LABEL org.opencontainers.image.licenses=MIT
ARG APP_VERSION ARG APP_VERSION

View file

@ -1,8 +1,8 @@
## LaraPaper (PHP/Laravel) ## TRMNL BYOS (PHP/Laravel)
[![tests](https://github.com/usetrmnl/larapaper/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/larapaper/actions/workflows/test.yml) [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
LaraPaper is a self-hostable implementation of a TRMNL server (BYOS), built with Laravel. TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (130+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 700+ from the [TRMNL catalog](https://trmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 50k downloads and 200+ stars, its the most popular community-driven BYOS. It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (130+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 700+ from the [TRMNL catalog](https://trmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 50k downloads and 200+ stars, its the most popular community-driven BYOS.
![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot.png)
@ -26,7 +26,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
* Custom ESP32 with TRMNL firmware * Custom ESP32 with TRMNL firmware
* E-Reader Devices * E-Reader Devices
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader)) * KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/larapaper/pull/27)) * Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook)) * Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo)) * Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
* Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android) * Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
@ -61,7 +61,7 @@ Docker Compose file located at: [docker/prod/docker-compose.yml](docker/prod/doc
##### Backup Database ##### Backup Database
```sh ```sh
docker ps #find container id of larapaper container docker ps #find container id of byos_laravel container
docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite
``` ```
@ -73,11 +73,11 @@ docker compose up -d
``` ```
#### VPS #### VPS
If youre using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy LaraPaper using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel). If youre using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
Its a quick way to get started without having to manually manage Docker setup. Its a quick way to get started without having to manually manage Docker setup.
#### PikaPods #### PikaPods
You can vote for LaraPaper to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel) You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
#### Umbrel #### Umbrel
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store). Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
@ -173,13 +173,13 @@ See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://ww
### ☁️ Activate fresh TRMNL Device with Cloud Proxy ### ☁️ Activate fresh TRMNL Device with Cloud Proxy
1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works) 1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works)
2) Setup LaraPaper, create a user and login 2) Setup Laravel BYOS, create a user and login
3) In LaraPaper in the header bar, activate the toggle "Permit Auto-Join" 3) In Laravel BYOS in the header bar, activate the toggle "Permit Auto-Join"
4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash). 4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash).
5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of LaraPaper. 5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of your Laravel BYOS
6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again. 6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again.
7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.) 7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.)
8) As long as no LaraPaper plugin is scheduled, the device will show your cloud plugins. 8) As long as no Laravel BYOS plugin is scheduled, the device will show your cloud plugins.
###### Troubleshooting ###### Troubleshooting

View file

@ -34,13 +34,8 @@ class GenerateScreenJob implements ShouldQueue
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]); Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
if ($this->pluginId) { if ($this->pluginId) {
$plugin = Plugin::find($this->pluginId); // cache current image
$update = ['current_image' => $newImageUuid]; Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
if ($plugin->plugin_type === 'recipe') {
$device = Device::with(['deviceModel', 'deviceModel.palette'])->find($this->deviceId);
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDevice($device);
}
$plugin->update($update);
} }
ImageGenerationService::cleanupFolder(); ImageGenerationService::cleanupFolder();

View file

@ -49,7 +49,6 @@ class Plugin extends Model
'preferred_renderer' => 'string', 'preferred_renderer' => 'string',
'plugin_type' => 'string', 'plugin_type' => 'string',
'alias' => 'boolean', 'alias' => 'boolean',
'current_image_metadata' => 'array',
]; ];
protected static function boot() protected static function boot()
@ -72,7 +71,6 @@ class Plugin extends Model
'render_markup_shared', 'render_markup_shared',
])) { ])) {
$model->current_image = null; $model->current_image = null;
$model->current_image_metadata = null;
} }
}); });
@ -224,7 +222,7 @@ class Plugin extends Model
if ($this->data_strategy !== 'polling' || ! $this->polling_url) { if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
return; return;
} }
$headers = ['User-Agent' => 'usetrmnl/larapaper', 'Accept' => 'application/json']; $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
// resolve headers // resolve headers
if ($this->polling_header) { if ($this->polling_header) {

View file

@ -331,88 +331,36 @@ class ImageGenerationService
} }
} }
/** public static function resetIfNotCacheable(?Plugin $plugin): void
* Ensure plugin image cache is valid for the current context. No-op for image_webhook.
* When deviceOrModel is provided (recipe only), clears cache if stored metadata does not match.
*/
public static function resetIfNotCacheable(?Plugin $plugin, Device|DeviceModel|null $deviceOrModel = null): void
{ {
if (! $plugin?->id || $plugin->plugin_type === 'image_webhook') { if ($plugin?->id) {
return; // Image webhook plugins have finalized images that shouldn't be reset
} if ($plugin->plugin_type === 'image_webhook') {
if ($deviceOrModel === null || $plugin->plugin_type !== 'recipe') { return;
return; }
} // Check if any devices have custom dimensions or use non-standard DeviceModels
if ($plugin->current_image === null) { $hasCustomDimensions = Device::query()
return; ->where(function ($query): void {
} $query->where('width', '!=', 800)
if (self::imageMetadataMatches($plugin->current_image_metadata, $deviceOrModel)) { ->orWhere('height', '!=', 480)
return; ->orWhere('rotate', '!=', 0);
} })
$plugin->update([ ->orWhereHas('deviceModel', function ($query): void {
'current_image' => null, // Only allow caching if all device models have standard dimensions (800x480, rotation=0)
'current_image_metadata' => null, $query->where(function ($subQuery): void {
]); $subQuery->where('width', '!=', 800)
Log::debug("Plugin {$plugin->id}: cleared image cache due to metadata mismatch"); ->orWhere('height', '!=', 480)
} ->orWhere('rotation', '!=', 0);
});
})
->exists();
/** if ($hasCustomDimensions) {
* Build canonical image metadata from a Device for cache comparison. // TODO cache image per device
* $plugin->update(['current_image' => null]);
* @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string} Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist');
*/
public static function buildImageMetadataFromDevice(Device $device): array
{
$device->loadMissing(['deviceModel', 'deviceModel.palette']);
$settings = self::getImageSettings($device);
$paletteId = $device->palette_id ?? $device->deviceModel?->palette_id;
return [
'width' => $settings['width'],
'height' => $settings['height'],
'rotation' => $settings['rotation'] ?? 0,
'palette_id' => $paletteId,
'mime_type' => $settings['mime_type'],
];
}
/**
* Build canonical image metadata from a DeviceModel for cache comparison.
*
* @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string}
*/
public static function buildImageMetadataFromDeviceModel(DeviceModel $model): array
{
return [
'width' => $model->width,
'height' => $model->height,
'rotation' => $model->rotation ?? 0,
'palette_id' => $model->palette_id,
'mime_type' => $model->mime_type,
];
}
/**
* Check if stored metadata matches the current device or device model.
* Returns false if stored is null or empty so cache is regenerated and metadata is stored.
*/
public static function imageMetadataMatches(?array $stored, Device|DeviceModel $deviceOrModel): bool
{
if ($stored === null || $stored === []) {
return false;
}
$current = $deviceOrModel instanceof Device
? self::buildImageMetadataFromDevice($deviceOrModel)
: self::buildImageMetadataFromDeviceModel($deviceOrModel);
foreach (['width', 'height', 'rotation', 'palette_id', 'mime_type'] as $key) {
if (($stored[$key] ?? null) !== ($current[$key] ?? null)) {
return false;
} }
} }
return true;
} }
/** /**

110
composer.lock generated
View file

@ -62,16 +62,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.372.1", "version": "3.372.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "a48ff951eaad7f038eca3e0e89f168048b99082b" "reference": "91ff063599dd5775e8886b9a7ba13cb1f40ca201"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/a48ff951eaad7f038eca3e0e89f168048b99082b", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/91ff063599dd5775e8886b9a7ba13cb1f40ca201",
"reference": "a48ff951eaad7f038eca3e0e89f168048b99082b", "reference": "91ff063599dd5775e8886b9a7ba13cb1f40ca201",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -153,9 +153,9 @@
"support": { "support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions", "forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.372.1" "source": "https://github.com/aws/aws-sdk-php/tree/3.372.0"
}, },
"time": "2026-03-06T21:27:21+00:00" "time": "2026-03-05T19:38:44+00:00"
}, },
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@ -4902,16 +4902,16 @@
}, },
{ {
"name": "psy/psysh", "name": "psy/psysh",
"version": "v0.12.21", "version": "v0.12.20",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/bobthecow/psysh.git", "url": "https://github.com/bobthecow/psysh.git",
"reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97" "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373",
"reference": "4821fab5b7cd8c49a673a9fd5754dc9162bb9e97", "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -4975,9 +4975,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/bobthecow/psysh/issues", "issues": "https://github.com/bobthecow/psysh/issues",
"source": "https://github.com/bobthecow/psysh/tree/v0.12.21" "source": "https://github.com/bobthecow/psysh/tree/v0.12.20"
}, },
"time": "2026-03-06T21:21:28+00:00" "time": "2026-02-11T15:05:28+00:00"
}, },
{ {
"name": "ralouphie/getallheaders", "name": "ralouphie/getallheaders",
@ -5597,16 +5597,16 @@
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v7.4.7", "version": "v7.4.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d" "reference": "6d643a93b47398599124022eb24d97c153c12f27"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d", "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27",
"reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d", "reference": "6d643a93b47398599124022eb24d97c153c12f27",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -5671,7 +5671,7 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v7.4.7" "source": "https://github.com/symfony/console/tree/v7.4.6"
}, },
"funding": [ "funding": [
{ {
@ -5691,7 +5691,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-03-06T14:06:20+00:00" "time": "2026-02-25T17:02:47+00:00"
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
@ -6212,16 +6212,16 @@
}, },
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",
"version": "v7.4.7", "version": "v7.4.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-foundation.git", "url": "https://github.com/symfony/http-foundation.git",
"reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81" "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81", "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065",
"reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81", "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -6270,7 +6270,7 @@
"description": "Defines an object-oriented layer for the HTTP specification", "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.7" "source": "https://github.com/symfony/http-foundation/tree/v7.4.6"
}, },
"funding": [ "funding": [
{ {
@ -6290,20 +6290,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-03-06T13:15:18+00:00" "time": "2026-02-21T16:25:55+00:00"
}, },
{ {
"name": "symfony/http-kernel", "name": "symfony/http-kernel",
"version": "v7.4.7", "version": "v7.4.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-kernel.git", "url": "https://github.com/symfony/http-kernel.git",
"reference": "3b3fcf386c809be990c922e10e4c620d6367cab1" "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1", "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83",
"reference": "3b3fcf386c809be990c922e10e4c620d6367cab1", "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -6389,7 +6389,7 @@
"description": "Provides a structured process for converting a Request into a Response", "description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.7" "source": "https://github.com/symfony/http-kernel/tree/v7.4.6"
}, },
"funding": [ "funding": [
{ {
@ -6409,7 +6409,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-03-06T16:33:18+00:00" "time": "2026-02-26T08:30:57+00:00"
}, },
{ {
"name": "symfony/mailer", "name": "symfony/mailer",
@ -6497,16 +6497,16 @@
}, },
{ {
"name": "symfony/mime", "name": "symfony/mime",
"version": "v7.4.7", "version": "v7.4.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/mime.git", "url": "https://github.com/symfony/mime.git",
"reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1" "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1", "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f",
"reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1", "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -6562,7 +6562,7 @@
"mime-type" "mime-type"
], ],
"support": { "support": {
"source": "https://github.com/symfony/mime/tree/v7.4.7" "source": "https://github.com/symfony/mime/tree/v7.4.6"
}, },
"funding": [ "funding": [
{ {
@ -6582,7 +6582,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-03-05T15:24:09+00:00" "time": "2026-02-05T15:57:06+00:00"
}, },
{ {
"name": "symfony/polyfill-ctype", "name": "symfony/polyfill-ctype",
@ -8451,23 +8451,23 @@
}, },
{ {
"name": "wnx/sidecar-browsershot", "name": "wnx/sidecar-browsershot",
"version": "v2.8.0", "version": "v2.7.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/stefanzweifel/sidecar-browsershot.git", "url": "https://github.com/stefanzweifel/sidecar-browsershot.git",
"reference": "1d2a20a6723b74c139f98f7a020fe5c0f57d05a5" "reference": "e42a996c6fab4357919cd5e3f3fab33f019cdd80"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/stefanzweifel/sidecar-browsershot/zipball/1d2a20a6723b74c139f98f7a020fe5c0f57d05a5", "url": "https://api.github.com/repos/stefanzweifel/sidecar-browsershot/zipball/e42a996c6fab4357919cd5e3f3fab33f019cdd80",
"reference": "1d2a20a6723b74c139f98f7a020fe5c0f57d05a5", "reference": "e42a996c6fab4357919cd5e3f3fab33f019cdd80",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"hammerstone/sidecar": "^0.7.1", "hammerstone/sidecar": "^0.7",
"illuminate/contracts": "^12.0", "illuminate/contracts": "^12.0",
"php": "^8.4", "php": "^8.4",
"spatie/browsershot": "^5.0", "spatie/browsershot": "^4.0 || ^5.0",
"spatie/laravel-package-tools": "^1.9.2" "spatie/laravel-package-tools": "^1.9.2"
}, },
"require-dev": { "require-dev": {
@ -8483,7 +8483,7 @@
"phpstan/phpstan-phpunit": "^1.0|^2.0", "phpstan/phpstan-phpunit": "^1.0|^2.0",
"phpunit/phpunit": "^11.0 | ^12.0", "phpunit/phpunit": "^11.0 | ^12.0",
"spatie/image": "^3.3", "spatie/image": "^3.3",
"spatie/pixelmatch-php": "^1.2" "spatie/pixelmatch-php": "^1.0"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@ -8525,7 +8525,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/stefanzweifel/sidecar-browsershot/issues", "issues": "https://github.com/stefanzweifel/sidecar-browsershot/issues",
"source": "https://github.com/stefanzweifel/sidecar-browsershot/tree/v2.8.0" "source": "https://github.com/stefanzweifel/sidecar-browsershot/tree/v2.7.0"
}, },
"funding": [ "funding": [
{ {
@ -8533,7 +8533,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-03-07T18:24:28+00:00" "time": "2025-11-22T08:49:08+00:00"
} }
], ],
"packages-dev": [ "packages-dev": [
@ -9069,24 +9069,24 @@
}, },
{ {
"name": "laravel/boost", "name": "laravel/boost",
"version": "v2.2.3", "version": "v2.2.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/boost.git", "url": "https://github.com/laravel/boost.git",
"reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889" "reference": "2b0366559e9ff591c65ea0321dfb91fd950c2cbd"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/44ab65a5455c2d6fceb71d6145f8d5d89c02d889", "url": "https://api.github.com/repos/laravel/boost/zipball/2b0366559e9ff591c65ea0321dfb91fd950c2cbd",
"reference": "44ab65a5455c2d6fceb71d6145f8d5d89c02d889", "reference": "2b0366559e9ff591c65ea0321dfb91fd950c2cbd",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"guzzlehttp/guzzle": "^7.9", "guzzlehttp/guzzle": "^7.9",
"illuminate/console": "^11.45.3|^12.41.1|^13.0", "illuminate/console": "^11.45.3|^12.41.1",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0", "illuminate/contracts": "^11.45.3|^12.41.1",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0", "illuminate/routing": "^11.45.3|^12.41.1",
"illuminate/support": "^11.45.3|^12.41.1|^13.0", "illuminate/support": "^11.45.3|^12.41.1",
"laravel/mcp": "^0.5.1|^0.6.0", "laravel/mcp": "^0.5.1|^0.6.0",
"laravel/prompts": "^0.3.10", "laravel/prompts": "^0.3.10",
"laravel/roster": "^0.5.0", "laravel/roster": "^0.5.0",
@ -9095,7 +9095,7 @@
"require-dev": { "require-dev": {
"laravel/pint": "^1.27.0", "laravel/pint": "^1.27.0",
"mockery/mockery": "^1.6.12", "mockery/mockery": "^1.6.12",
"orchestra/testbench": "^9.15.0|^10.6|^11.0", "orchestra/testbench": "^9.15.0|^10.6",
"pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
"phpstan/phpstan": "^2.1.27", "phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.1" "rector/rector": "^2.1"
@ -9131,7 +9131,7 @@
"issues": "https://github.com/laravel/boost/issues", "issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost" "source": "https://github.com/laravel/boost"
}, },
"time": "2026-03-06T20:20:28+00:00" "time": "2026-03-03T14:36:03+00:00"
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",

View file

@ -13,7 +13,7 @@ return [
| |
*/ */
'name' => env('APP_NAME', 'LaraPaper'), 'name' => env('APP_NAME', 'Laravel'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -127,8 +127,6 @@ return [
'enabled' => env('REGISTRATION_ENABLED', true), 'enabled' => env('REGISTRATION_ENABLED', true),
], ],
'pixel_logo_enabled' => env('PIXELLOGO_ENABLED', true),
'force_https' => env('FORCE_HTTPS', false), 'force_https' => env('FORCE_HTTPS', false),
'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
@ -156,5 +154,5 @@ return [
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'), 'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
'github_repo' => env('GITHUB_REPO', 'usetrmnl/larapaper'), 'github_repo' => env('GITHUB_REPO', 'usetrmnl/byos_laravel'),
]; ];

View file

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->json('current_image_metadata')->nullable()->after('current_image');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn('current_image_metadata');
});
}
};

View file

@ -3,7 +3,6 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\Device; use App\Models\Device;
use App\Models\Playlist;
use App\Models\Plugin; use App\Models\Plugin;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@ -24,19 +23,9 @@ class DatabaseSeeder extends Seeder
'password' => bcrypt('admin@example.com'), 'password' => bcrypt('admin@example.com'),
]); ]);
$device = Device::factory()->create([ Device::factory(1)->create([
'mac_address' => '00:00:00:00:00:00', 'mac_address' => '00:00:00:00:00:00',
'api_key' => 'test-api-key', 'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'Default',
'is_active' => true,
'active_from' => null,
'active_until' => null,
'weekdays' => null
]); ]);
// Device::factory(5)->create(); // Device::factory(5)->create();

View file

@ -1,6 +1,6 @@
services: services:
app: app:
image: ghcr.io/usetrmnl/larapaper:latest image: ghcr.io/usetrmnl/byos_laravel:latest
ports: ports:
- "4567:8080" - "4567:8080"
environment: environment:

View file

@ -9,7 +9,7 @@
#### Clone the repository #### Clone the repository
```bash ```bash
git clone git@github.com:usetrmnl/larapaper.git git clone git@github.com:usetrmnl/byos_laravel.git
``` ```
#### Copy environment file #### Copy environment file

View file

@ -2,9 +2,5 @@
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" /> <x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</div> </div>
<div class="ml-1 grid flex-1 text-left text-sm"> <div class="ml-1 grid flex-1 text-left text-sm">
@if(config('app.pixel_logo_enabled')) <span class="mb-0.5 truncate leading-none font-semibold">TRMNL BYOS Laravel</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 pt-1.5 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
@else
<span class="mb-0.5 truncate leading-none font-semibold">LaraPaper</span>
@endif
</div> </div>

View file

@ -4,10 +4,6 @@
]) ])
<div class="flex w-full flex-col text-center"> <div class="flex w-full flex-col text-center">
@if(config('app.pixel_logo_enabled')) <flux:heading size="xl">{{ $title }}</flux:heading>
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
@else
<flux:heading size="xl">LaraPaper</flux:heading>
@endif
<flux:subheading>{{ $description }}</flux:subheading> <flux:subheading>{{ $description }}</flux:subheading>
</div> </div>

View file

@ -15,10 +15,10 @@
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center"> <x-trmnl::richtext gapSize="large" align="center">
<x-trmnl::title>Welcome to LaraPaper!</x-trmnl::title> <x-trmnl::title>Welcome to BYOS Laravel!</x-trmnl::title>
<x-trmnl::content>Your device is connected.</x-trmnl::content> <x-trmnl::content>Your device is connected.</x-trmnl::content>
</x-trmnl::richtext> </x-trmnl::richtext>
</x-trmnl::layout> </x-trmnl::layout>
<x-trmnl::title-bar title="LaraPaper"/> <x-trmnl::title-bar title="byos_laravel"/>
</x-trmnl::view> </x-trmnl::view>
</x-trmnl::screen> </x-trmnl::screen>

View file

@ -25,6 +25,6 @@
<x-trmnl::title>Sleep Mode</x-trmnl::title> <x-trmnl::title>Sleep Mode</x-trmnl::title>
</x-trmnl::richtext> </x-trmnl::richtext>
</x-trmnl::layout> </x-trmnl::layout>
<x-trmnl::title-bar title="LaraPaper"/> <x-trmnl::title-bar title="byos_laravel"/>
</x-trmnl::view> </x-trmnl::view>
</x-trmnl::screen> </x-trmnl::screen>

View file

@ -264,7 +264,7 @@ new class extends Component
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}} {{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
{{-- </ul>--}} {{-- </ul>--}}
</ul> </ul>
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li> <flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
</div> </div>
<form wire:submit="importZip"> <form wire:submit="importZip">
@ -315,7 +315,7 @@ new class extends Component
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li> <li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li> <li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
</ul> </ul>
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li> <flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
</flux:callout> </flux:callout>
</div> </div>
<livewire:catalog.trmnl /> <livewire:catalog.trmnl />

View file

@ -72,8 +72,8 @@ new class extends Component
<x-trmnl::view> <x-trmnl::view>
<x-trmnl::layout> <x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center"> <x-trmnl::richtext gapSize="large" align="center">
<x-trmnl::title>LaraPaper</x-trmnl::title> <x-trmnl::title>TRMNL BYOS Laravel</x-trmnl::title>
<x-trmnl::content>“This screen was rendered by BYOS LaraPaper</x-trmnl::content> <x-trmnl::content>“This screen was rendered by BYOS Laravel</x-trmnl::content>
<x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label> <x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label>
</x-trmnl::richtext> </x-trmnl::richtext>
</x-trmnl::layout> </x-trmnl::layout>

View file

@ -1,7 +1,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ $title ?? 'LaraPaper' }}</title> <title>{{ $title ?? 'TRMNL BYOS Laravel' }}</title>
<link rel="preconnect" href="https://fonts.bunny.net"> <link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" /> <link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />

View file

@ -1,6 +1,6 @@
<x-layouts::auth.card> <x-layouts::auth.card>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<x-auth-header title="LaraPaper" description="Server is up and running."/> <x-auth-header title="TRMNL BYOS Laravel" description="Server is up and running."/>
</div> </div>
<header class="w-full lg:max-w-4xl max-w-[335px] text-sm mt-6 not-has-[nav]:hidden"> <header class="w-full lg:max-w-4xl max-w-[335px] text-sm mt-6 not-has-[nav]:hidden">
@if (Route::has('login')) @if (Route::has('login'))
@ -33,7 +33,7 @@
</header> </header>
@auth @auth
@if(config('app.version')) @if(config('app.version'))
<flux:text class="text-xs">Version: <a href="https://github.com/{{ config('app.github_repo') }}/releases/tag/{{ config('app.version') }}" <flux:text class="text-xs">Version: <a href="https://github.com/usetrmnl/byos_laravel/releases/tag/{{ config('app.version') }}"
target="_blank">{{ config('app.version') }}</a> target="_blank">{{ config('app.version') }}</a>
</flux:text> </flux:text>
@endif @endif

View file

@ -88,8 +88,8 @@ Route::get('/display', function (Request $request) {
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time; $refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
$plugin = $playlistItem->plugin; $plugin = $playlistItem->plugin;
ImageGenerationService::resetIfNotCacheable($plugin, $device); // Reset cache if Devices with different dimensions exist
$plugin->refresh(); ImageGenerationService::resetIfNotCacheable($plugin);
// Check and update stale data if needed // Check and update stale data if needed
if ($plugin->isDataStale() || $plugin->current_image === null) { if ($plugin->isDataStale() || $plugin->current_image === null) {
@ -699,9 +699,6 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
], 404); ], 404);
} }
ImageGenerationService::resetIfNotCacheable($plugin, $deviceModel);
$plugin->refresh();
// Check if we can use cached image (only for og_png and if data is not stale) // Check if we can use cached image (only for og_png and if data is not stale)
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null; $useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
@ -747,13 +744,9 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
palette: $deviceModel->palette palette: $deviceModel->palette
); );
// Update plugin cache if using og_png (recipes only get metadata for cache comparison) // Update plugin cache if using og_png
if ($deviceModelName === 'og_png') { if ($deviceModelName === 'og_png') {
$update = ['current_image' => $imageUuid]; $plugin->update(['current_image' => $imageUuid]);
if ($plugin->plugin_type === 'recipe') {
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDeviceModel($deviceModel);
}
$plugin->update($update);
} }
// Return the generated image // Return the generated image

View file

@ -2,8 +2,6 @@
use App\Jobs\GenerateScreenJob; use App\Jobs\GenerateScreenJob;
use App\Models\Device; use App\Models\Device;
use App\Models\DeviceModel;
use App\Models\Plugin;
use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -60,26 +58,3 @@ test('it preserves gitignore file during cleanup', function (): void {
Storage::disk('public')->assertExists('/images/generated/.gitignore'); Storage::disk('public')->assertExists('/images/generated/.gitignore');
}); });
test('it saves current_image_metadata for recipe plugins', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$plugin = Plugin::factory()->create(['plugin_type' => 'recipe']);
$job = new GenerateScreenJob($device->id, $plugin->id, '<div>Test</div>');
$job->handle();
$plugin->refresh();
expect($plugin->current_image)->not->toBeNull();
expect($plugin->current_image_metadata)->toBeArray();
expect($plugin->current_image_metadata)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($plugin->current_image_metadata['width'])->toBe(800);
expect($plugin->current_image_metadata['height'])->toBe(480);
expect($plugin->current_image_metadata['mime_type'])->toBe('image/png');
});

View file

@ -8,7 +8,6 @@ use App\Models\DeviceModel;
use App\Services\ImageGenerationService; use App\Services\ImageGenerationService;
use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -23,10 +22,6 @@ afterEach(function (): void {
TrmnlPipeline::restore(); TrmnlPipeline::restore();
}); });
it('plugins table has current_image_metadata column', function (): void {
expect(Schema::hasColumn('plugins', 'current_image_metadata'))->toBeTrue();
});
it('generates image for device without device model', function (): void { it('generates image for device without device model', function (): void {
// Create a device without a DeviceModel (legacy behavior) // Create a device without a DeviceModel (legacy behavior)
$device = Device::factory()->create([ $device = Device::factory()->create([
@ -275,15 +270,39 @@ it('cleanupFolder preserves .gitignore', function (): void {
Storage::disk('public')->assertExists('/images/generated/.gitignore'); Storage::disk('public')->assertExists('/images/generated/.gitignore');
}); });
it('resetIfNotCacheable does not reset recipe cache based on other devices', function (): void { it('resetIfNotCacheable resets when device models exist', function (): void {
// Cache validity is now determined at use-time via current_image_metadata // Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']); $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
Device::factory()->create(['device_model_id' => DeviceModel::factory()->create()->id]); // Create a device with DeviceModel (should trigger cache reset)
Device::factory()->create([
'device_model_id' => DeviceModel::factory()->create()->id,
]);
// Run reset check
ImageGenerationService::resetIfNotCacheable($plugin); ImageGenerationService::resetIfNotCacheable($plugin);
// Assert plugin image was reset
$plugin->refresh(); $plugin->refresh();
expect($plugin->current_image)->toBe('test-uuid'); expect($plugin->current_image)->toBeNull();
});
it('resetIfNotCacheable resets when custom dimensions exist', function (): void {
// Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
// Create a device with custom dimensions (should trigger cache reset)
Device::factory()->create([
'width' => 1024, // Different from default 800
'height' => 768, // Different from default 480
]);
// Run reset check
ImageGenerationService::resetIfNotCacheable($plugin);
// Assert plugin image was reset
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
}); });
it('resetIfNotCacheable preserves image for standard devices', function (): void { it('resetIfNotCacheable preserves image for standard devices', function (): void {
@ -306,122 +325,27 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
}); });
it('cache is reset when plugin markup changes', function (): void { it('cache is reset when plugin markup changes', function (): void {
// Create a plugin with cached image and metadata // Create a plugin with cached image
$plugin = App\Models\Plugin::factory()->create([ $plugin = App\Models\Plugin::factory()->create([
'current_image' => 'cached-uuid', 'current_image' => 'cached-uuid',
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
'render_markup' => '<div>Original markup</div>', 'render_markup' => '<div>Original markup</div>',
]); ]);
$plugin->update(['render_markup' => '<div>Updated markup</div>']); // Create devices with standard dimensions (cacheable)
Device::factory()->count(2)->create([
'width' => 800,
'height' => 480,
'rotate' => 0,
]);
// Update the plugin markup
$plugin->update([
'render_markup' => '<div>Updated markup</div>',
]);
// Assert cache was reset when markup changed
$plugin->refresh(); $plugin->refresh();
expect($plugin->current_image)->toBeNull(); expect($plugin->current_image)->toBeNull();
expect($plugin->current_image_metadata)->toBeNull();
});
it('buildImageMetadataFromDevice returns canonical metadata shape', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($meta['width'])->toBe(800);
expect($meta['height'])->toBe(480);
expect($meta['rotation'])->toBe(0);
expect($meta['mime_type'])->toBe('image/png');
});
it('buildImageMetadataFromDeviceModel returns canonical metadata shape', function (): void {
$model = DeviceModel::factory()->create([
'width' => 1024,
'height' => 768,
'rotation' => 90,
'mime_type' => 'image/bmp',
'palette_id' => null,
]);
$meta = ImageGenerationService::buildImageMetadataFromDeviceModel($model);
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($meta['width'])->toBe(1024);
expect($meta['height'])->toBe(768);
expect($meta['rotation'])->toBe(90);
expect($meta['mime_type'])->toBe('image/bmp');
});
it('imageMetadataMatches returns false when stored is null or empty', function (): void {
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
expect(ImageGenerationService::imageMetadataMatches(null, $device))->toBeFalse();
expect(ImageGenerationService::imageMetadataMatches([], $device))->toBeFalse();
});
it('imageMetadataMatches returns true when metadata matches device', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$stored = ImageGenerationService::buildImageMetadataFromDevice($device);
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeTrue();
});
it('imageMetadataMatches returns false when metadata differs', function (): void {
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
$stored = ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'];
$device->update(['width' => 1024]);
$device->refresh();
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeFalse();
});
it('resetIfNotCacheable clears recipe cache when metadata does not match', function (): void {
$plugin = App\Models\Plugin::factory()->create([
'plugin_type' => 'recipe',
'current_image' => 'cached-uuid',
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
]);
$device = Device::factory()->create(['width' => 1024, 'height' => 768, 'rotate' => 0]);
ImageGenerationService::resetIfNotCacheable($plugin, $device);
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
expect($plugin->current_image_metadata)->toBeNull();
});
it('resetIfNotCacheable preserves cache when metadata matches', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
$plugin = App\Models\Plugin::factory()->create([
'plugin_type' => 'recipe',
'current_image' => 'cached-uuid',
'current_image_metadata' => $meta,
]);
ImageGenerationService::resetIfNotCacheable($plugin, $device);
$plugin->refresh();
expect($plugin->current_image)->toBe('cached-uuid');
}); });
it('determines correct image format from device model', function (): void { it('determines correct image format from device model', function (): void {

View file

@ -1,46 +0,0 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Config;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('auth pages show pixel logo SVG when pixel_logo_enabled is true', function (): void {
Config::set('app.pixel_logo_enabled', true);
$response = $this->get('/login');
$response->assertStatus(200);
$response->assertSee('viewBox="0 0 1000 150"', false);
});
test('auth pages show heading instead of pixel logo when pixel_logo_enabled is false', function (): void {
Config::set('app.pixel_logo_enabled', false);
$response = $this->get('/login');
$response->assertStatus(200);
$response->assertDontSee('viewBox="0 0 1000 150"', false);
$response->assertSee('LaraPaper');
});
test('app logo shows text when pixel_logo_enabled is false', function (): void {
Config::set('app.pixel_logo_enabled', false);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertStatus(200);
$response->assertSee('LaraPaper');
$response->assertDontSee('viewBox="0 0 1000 150"', false);
});
test('app logo shows pixel logo SVG when pixel_logo_enabled is true', function (): void {
Config::set('app.pixel_logo_enabled', true);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertStatus(200);
$response->assertSee('viewBox="0 0 1000 150"', false);
});

View file

@ -176,15 +176,37 @@ it('cleanup_folder identifies active images correctly', function (): void {
expect($activeImageUuids)->not->toContain(null); expect($activeImageUuids)->not->toContain(null);
}); });
it('reset_if_not_cacheable does not reset recipe cache when other devices exist', function (): void { it('reset_if_not_cacheable detects device models', function (): void {
// Cache validity is now determined at use-time via metadata // Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']); $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
Device::factory()->create(['device_model_id' => DeviceModel::factory()->create()->id]);
// Create a device with DeviceModel
Device::factory()->create([
'device_model_id' => DeviceModel::factory()->create()->id,
]);
// Test that the method detects DeviceModels and resets cache
ImageGenerationService::resetIfNotCacheable($plugin); ImageGenerationService::resetIfNotCacheable($plugin);
$plugin->refresh(); $plugin->refresh();
expect($plugin->current_image)->toBe('test-uuid'); expect($plugin->current_image)->toBeNull();
});
it('reset_if_not_cacheable detects custom dimensions', function (): void {
// Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
// Create a device with custom dimensions
Device::factory()->create([
'width' => 1024, // Different from default 800
'height' => 768, // Different from default 480
]);
// Test that the method detects custom dimensions and resets cache
ImageGenerationService::resetIfNotCacheable($plugin);
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
}); });
it('reset_if_not_cacheable preserves cache for standard devices', function (): void { it('reset_if_not_cacheable preserves cache for standard devices', function (): void {
@ -236,21 +258,26 @@ it('reset_if_not_cacheable preserves cache for og_png and og_plus device models'
expect($plugin->current_image)->toBe('test-uuid'); expect($plugin->current_image)->toBe('test-uuid');
}); });
it('reset_if_not_cacheable does not reset cache for non-standard device models', function (): void { it('reset_if_not_cacheable resets cache for non-standard device models', function (): void {
// Cache is now validated at use-time via metadata comparison // Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']); $plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
// Create a non-standard device model (e.g., kindle)
$kindleModel = DeviceModel::factory()->create([ $kindleModel = DeviceModel::factory()->create([
'name' => 'test_amazon_kindle_2024', 'name' => 'test_amazon_kindle_2024',
'width' => 1400, 'width' => 1400,
'height' => 840, 'height' => 840,
'rotation' => 90, 'rotation' => 90,
]); ]);
// Create a device with the non-standard device model
Device::factory()->create(['device_model_id' => $kindleModel->id]); Device::factory()->create(['device_model_id' => $kindleModel->id]);
// Test that the method resets cache for non-standard device models
ImageGenerationService::resetIfNotCacheable($plugin); ImageGenerationService::resetIfNotCacheable($plugin);
$plugin->refresh(); $plugin->refresh();
expect($plugin->current_image)->toBe('test-uuid'); expect($plugin->current_image)->toBeNull();
}); });
it('reset_if_not_cacheable handles null plugin', function (): void { it('reset_if_not_cacheable handles null plugin', function (): void {