From fb9469d9cd235a13089b67a77b016255672da7ad Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 10 Dec 2025 16:43:27 +0100 Subject: [PATCH 01/53] chore: update dependencies --- composer.lock | 299 +++++++++++++++++++++++++------------------------- 1 file changed, 151 insertions(+), 148 deletions(-) diff --git a/composer.lock b/composer.lock index 9d34443..d13a3e8 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.366.1", + "version": "3.366.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "981ae91529b990987bdb182c11322dd34848976a" + "reference": "1861cc8eede21cdaab0732fd44f43f19ddf1effd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/981ae91529b990987bdb182c11322dd34848976a", - "reference": "981ae91529b990987bdb182c11322dd34848976a", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/1861cc8eede21cdaab0732fd44f43f19ddf1effd", + "reference": "1861cc8eede21cdaab0732fd44f43f19ddf1effd", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.366.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.366.4" }, - "time": "2025-12-04T16:55:00+00:00" + "time": "2025-12-09T19:21:22+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -1617,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.41.1", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "3e229b05935fd0300c632fb1f718c73046d664fc" + "reference": "509b33095564c5165366d81bbaa0afaac28abe75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/3e229b05935fd0300c632fb1f718c73046d664fc", - "reference": "3e229b05935fd0300c632fb1f718c73046d664fc", + "url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75", + "reference": "509b33095564c5165366d81bbaa0afaac28abe75", "shasum": "" }, "require": { @@ -1714,6 +1714,7 @@ "illuminate/process": "self.version", "illuminate/queue": "self.version", "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", "illuminate/routing": "self.version", "illuminate/session": "self.version", "illuminate/support": "self.version", @@ -1738,7 +1739,7 @@ "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", "opis/json-schema": "^2.4.1", - "orchestra/testbench-core": "^10.8.0", + "orchestra/testbench-core": "^10.8.1", "pda/pheanstalk": "^5.0.6|^7.0.0", "php-http/discovery": "^1.15", "phpstan/phpstan": "^2.0", @@ -1800,6 +1801,7 @@ "src/Illuminate/Filesystem/functions.php", "src/Illuminate/Foundation/helpers.php", "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", "src/Illuminate/Support/functions.php", "src/Illuminate/Support/helpers.php" ], @@ -1808,7 +1810,8 @@ "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", "src/Illuminate/Collections/", - "src/Illuminate/Conditionable/" + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" ] } }, @@ -1832,7 +1835,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-03T01:02:13+00:00" + "time": "2025-12-09T15:51:23+00:00" }, { "name": "laravel/prompts", @@ -2019,16 +2022,16 @@ }, { "name": "laravel/socialite", - "version": "v5.23.2", + "version": "v5.24.0", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "41e65d53762d33d617bf0253330d672cb95e624b" + "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/41e65d53762d33d617bf0253330d672cb95e624b", - "reference": "41e65d53762d33d617bf0253330d672cb95e624b", + "url": "https://api.github.com/repos/laravel/socialite/zipball/1d19358c28e8951dde6e36603b89d8f09e6cfbfd", + "reference": "1d19358c28e8951dde6e36603b89d8f09e6cfbfd", "shasum": "" }, "require": { @@ -2087,7 +2090,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2025-11-21T14:00:38+00:00" + "time": "2025-12-09T15:37:06+00:00" }, { "name": "laravel/tinker", @@ -2666,20 +2669,20 @@ }, { "name": "league/uri", - "version": "7.6.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "f625804987a0a9112d954f9209d91fec52182344" + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/f625804987a0a9112d954f9209d91fec52182344", - "reference": "f625804987a0a9112d954f9209d91fec52182344", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/8d587cddee53490f9b82bf203d3a9aa7ea4f9807", + "reference": "8d587cddee53490f9b82bf203d3a9aa7ea4f9807", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.6", + "league/uri-interfaces": "^7.7", "php": "^8.1", "psr/http-factory": "^1" }, @@ -2752,7 +2755,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.6.0" + "source": "https://github.com/thephpleague/uri/tree/7.7.0" }, "funding": [ { @@ -2760,20 +2763,20 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2025-12-07T16:02:06+00:00" }, { "name": "league/uri-interfaces", - "version": "7.6.0", + "version": "7.7.0", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368" + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/ccbfb51c0445298e7e0b7f4481b942f589665368", - "reference": "ccbfb51c0445298e7e0b7f4481b942f589665368", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/62ccc1a0435e1c54e10ee6022df28d6c04c2946c", + "reference": "62ccc1a0435e1c54e10ee6022df28d6c04c2946c", "shasum": "" }, "require": { @@ -2836,7 +2839,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.6.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.7.0" }, "funding": [ { @@ -2844,7 +2847,7 @@ "type": "github" } ], - "time": "2025-11-18T12:17:23+00:00" + "time": "2025-12-07T16:03:21+00:00" }, { "name": "livewire/flux", @@ -3061,16 +3064,16 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", - "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", "shasum": "" }, "require": { @@ -3081,7 +3084,7 @@ "require-dev": { "brianium/paratest": "^7.7", "ext-zip": "*", - "friendsofphp/php-cs-fixer": "^3.16", + "friendsofphp/php-cs-fixer": "^3.86", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", @@ -3127,7 +3130,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" }, "funding": [ { @@ -3135,7 +3138,7 @@ "type": "github" } ], - "time": "2025-07-17T11:15:13+00:00" + "time": "2025-12-10T09:58:31+00:00" }, { "name": "monolog/monolog", @@ -3567,16 +3570,16 @@ }, { "name": "nikic/php-parser", - "version": "v5.6.2", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/3a454ca033b9e06b63282ce19562e892747449bb", - "reference": "3a454ca033b9e06b63282ce19562e892747449bb", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { @@ -3619,9 +3622,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.6.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" }, - "time": "2025-10-21T19:32:17+00:00" + "time": "2025-12-06T11:56:16+00:00" }, { "name": "nunomaduro/termwind", @@ -4479,16 +4482,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.15", + "version": "v0.12.16", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c" + "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/38953bc71491c838fcb6ebcbdc41ab7483cd549c", - "reference": "38953bc71491c838fcb6ebcbdc41ab7483cd549c", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", "shasum": "" }, "require": { @@ -4496,8 +4499,8 @@ "ext-tokenizer": "*", "nikic/php-parser": "^5.0 || ^4.0", "php": "^8.0 || ^7.4", - "symfony/console": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", - "symfony/var-dumper": "^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" }, "conflict": { "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" @@ -4552,9 +4555,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.15" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.16" }, - "time": "2025-11-28T00:00:14+00:00" + "time": "2025-12-07T03:39:01+00:00" }, { "name": "ralouphie/getallheaders", @@ -5023,16 +5026,16 @@ }, { "name": "symfony/console", - "version": "v7.4.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8" + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", - "reference": "0bc0f45254b99c58d45a8fbf9fb955d46cbd1bb8", + "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", "shasum": "" }, "require": { @@ -5097,7 +5100,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.0" + "source": "https://github.com/symfony/console/tree/v7.4.1" }, "funding": [ { @@ -5117,7 +5120,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-12-05T15:23:39+00:00" }, { "name": "symfony/css-selector", @@ -5500,16 +5503,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7fc96ae83372620eaba3826874f46e26295768ca" + "reference": "d937d400b980523dc9ee946bb69972b5e619058d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7fc96ae83372620eaba3826874f46e26295768ca", - "reference": "7fc96ae83372620eaba3826874f46e26295768ca", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", + "reference": "d937d400b980523dc9ee946bb69972b5e619058d", "shasum": "" }, "require": { @@ -5546,7 +5549,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.0" + "source": "https://github.com/symfony/filesystem/tree/v8.0.1" }, "funding": [ { @@ -5566,7 +5569,7 @@ "type": "tidelift" } ], - "time": "2025-11-05T14:36:47+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/finder", @@ -5638,16 +5641,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "769c1720b68e964b13b58529c17d4a385c62167b" + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/769c1720b68e964b13b58529c17d4a385c62167b", - "reference": "769c1720b68e964b13b58529c17d4a385c62167b", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", "shasum": "" }, "require": { @@ -5696,7 +5699,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.0" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" }, "funding": [ { @@ -5716,20 +5719,20 @@ "type": "tidelift" } ], - "time": "2025-11-13T08:49:24+00:00" + "time": "2025-12-07T11:13:10+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.0", + "version": "v7.4.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "7348193cd384495a755554382e4526f27c456085" + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7348193cd384495a755554382e4526f27c456085", - "reference": "7348193cd384495a755554382e4526f27c456085", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", "shasum": "" }, "require": { @@ -5815,7 +5818,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.0" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" }, "funding": [ { @@ -5835,7 +5838,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:38:24+00:00" + "time": "2025-12-08T07:43:37+00:00" }, { "name": "symfony/mailer", @@ -7078,16 +7081,16 @@ }, { "name": "symfony/string", - "version": "v8.0.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f929eccf09531078c243df72398560e32fa4cf4f" + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f929eccf09531078c243df72398560e32fa4cf4f", - "reference": "f929eccf09531078c243df72398560e32fa4cf4f", + "url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", + "reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", "shasum": "" }, "require": { @@ -7144,7 +7147,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.0" + "source": "https://github.com/symfony/string/tree/v8.0.1" }, "funding": [ { @@ -7164,20 +7167,20 @@ "type": "tidelift" } ], - "time": "2025-09-11T14:37:55+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/translation", - "version": "v8.0.0", + "version": "v8.0.1", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6" + "reference": "770e3b8b0ba8360958abedcabacd4203467333ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/82ab368a6fca6358d995b6dd5c41590fb42c03e6", - "reference": "82ab368a6fca6358d995b6dd5c41590fb42c03e6", + "url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca", + "reference": "770e3b8b0ba8360958abedcabacd4203467333ca", "shasum": "" }, "require": { @@ -7237,7 +7240,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.0" + "source": "https://github.com/symfony/translation/tree/v8.0.1" }, "funding": [ { @@ -7257,7 +7260,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T08:09:45+00:00" + "time": "2025-12-01T09:13:36+00:00" }, { "name": "symfony/translation-contracts", @@ -7589,16 +7592,16 @@ }, { "name": "symfony/yaml", - "version": "v7.4.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810" + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/6c84a4b55aee4cd02034d1c528e83f69ddf63810", - "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345", + "reference": "24dd4de28d2e3988b311751ac49e684d783e2345", "shasum": "" }, "require": { @@ -7641,7 +7644,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.0" + "source": "https://github.com/symfony/yaml/tree/v7.4.1" }, "funding": [ { @@ -7661,7 +7664,7 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2025-12-04T18:11:45+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -8544,16 +8547,16 @@ }, { "name": "laravel/boost", - "version": "v1.8.4", + "version": "v1.8.5", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "dbdef07edbf101049f6d308654ead2f4324de703" + "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/dbdef07edbf101049f6d308654ead2f4324de703", - "reference": "dbdef07edbf101049f6d308654ead2f4324de703", + "url": "https://api.github.com/repos/laravel/boost/zipball/99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", + "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", "shasum": "" }, "require": { @@ -8568,10 +8571,10 @@ "php": "^8.1" }, "require-dev": { - "laravel/pint": "1.20", + "laravel/pint": "^1.20.0", "mockery/mockery": "^1.6.12", "orchestra/testbench": "^8.36.0|^9.15.0|^10.6", - "pestphp/pest": "^2.36.0|^3.8.4", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", "phpstan/phpstan": "^2.1.27", "rector/rector": "^2.1" }, @@ -8606,33 +8609,33 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-12-05T05:54:57+00:00" + "time": "2025-12-08T21:54:49+00:00" }, { "name": "laravel/mcp", - "version": "v0.4.1", + "version": "v0.4.2", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "27ab10181d25067de7ace427edb562084d0d0aa3" + "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/27ab10181d25067de7ace427edb562084d0d0aa3", - "reference": "27ab10181d25067de7ace427edb562084d0d0aa3", + "url": "https://api.github.com/repos/laravel/mcp/zipball/1c7878be3931a19768f791ddf141af29f43fb4ef", + "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef", "shasum": "" }, "require": { "ext-json": "*", "ext-mbstring": "*", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/container": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/http": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/json-schema": "^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/validation": "^10.49.0|^11.45.3|^12.28.1", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/container": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/http": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/json-schema": "^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1", "php": "^8.1" }, "require-dev": { @@ -8679,7 +8682,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-12-04T17:29:08+00:00" + "time": "2025-12-07T15:49:15+00:00" }, { "name": "laravel/pail", @@ -8890,16 +8893,16 @@ }, { "name": "laravel/sail", - "version": "v1.50.0", + "version": "v1.51.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f" + "reference": "1c74357df034e869250b4365dd445c9f6ba5d068" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/9177d5de1c8247166b92ea6049c2b069d2a1802f", - "reference": "9177d5de1c8247166b92ea6049c2b069d2a1802f", + "url": "https://api.github.com/repos/laravel/sail/zipball/1c74357df034e869250b4365dd445c9f6ba5d068", + "reference": "1c74357df034e869250b4365dd445c9f6ba5d068", "shasum": "" }, "require": { @@ -8949,7 +8952,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2025-12-03T17:16:36+00:00" + "time": "2025-12-09T13:33:49+00:00" }, { "name": "mockery/mockery", @@ -9670,16 +9673,16 @@ }, { "name": "pestphp/pest-plugin-profanity", - "version": "v4.2.0", + "version": "v4.2.1", "source": { "type": "git", "url": "https://github.com/pestphp/pest-plugin-profanity.git", - "reference": "c37e5e2c7136ee4eae12082e7952332bc1c6600a" + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/c37e5e2c7136ee4eae12082e7952332bc1c6600a", - "reference": "c37e5e2c7136ee4eae12082e7952332bc1c6600a", + "url": "https://api.github.com/repos/pestphp/pest-plugin-profanity/zipball/343cfa6f3564b7e35df0ebb77b7fa97039f72b27", + "reference": "343cfa6f3564b7e35df0ebb77b7fa97039f72b27", "shasum": "" }, "require": { @@ -9720,9 +9723,9 @@ "unit" ], "support": { - "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.0" + "source": "https://github.com/pestphp/pest-plugin-profanity/tree/v4.2.1" }, - "time": "2025-10-28T23:14:11+00:00" + "time": "2025-12-08T00:13:17+00:00" }, { "name": "phar-io/manifest", @@ -10119,23 +10122,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.0", + "version": "12.5.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "bca180c050dd3ae15f87c26d25cabb34fe1a0a5a" + "reference": "c467c59a4f6e04b942be422844e7a6352fa01b57" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/bca180c050dd3ae15f87c26d25cabb34fe1a0a5a", - "reference": "bca180c050dd3ae15f87c26d25cabb34fe1a0a5a", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c467c59a4f6e04b942be422844e7a6352fa01b57", + "reference": "c467c59a4f6e04b942be422844e7a6352fa01b57", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^5.6.2", + "nikic/php-parser": "^5.7.0", "php": ">=8.3", "phpunit/php-file-iterator": "^6.0", "phpunit/php-text-template": "^5.0", @@ -10143,10 +10146,10 @@ "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^1.3.1" + "theseer/tokenizer": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^12.4.4" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -10184,7 +10187,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.0" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.1" }, "funding": [ { @@ -10204,7 +10207,7 @@ "type": "tidelift" } ], - "time": "2025-11-29T07:15:54+00:00" + "time": "2025-12-08T07:17:58+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10558,21 +10561,21 @@ }, { "name": "rector/rector", - "version": "2.2.11", + "version": "2.2.14", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "7bd21a40b0332b93d4bfee284093d7400696902d" + "reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/7bd21a40b0332b93d4bfee284093d7400696902d", - "reference": "7bd21a40b0332b93d4bfee284093d7400696902d", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/6d56bb0e94d4df4f57a78610550ac76ab403657d", + "reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.32" + "phpstan/phpstan": "^2.1.33" }, "conflict": { "rector/rector-doctrine": "*", @@ -10606,7 +10609,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.2.11" + "source": "https://github.com/rectorphp/rector/tree/2.2.14" }, "funding": [ { @@ -10614,7 +10617,7 @@ "type": "github" } ], - "time": "2025-12-02T11:23:46+00:00" + "time": "2025-12-09T10:57:55+00:00" }, { "name": "sebastian/cli-parser", @@ -11626,23 +11629,23 @@ }, { "name": "theseer/tokenizer", - "version": "1.3.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", - "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -11664,7 +11667,7 @@ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { "issues": "https://github.com/theseer/tokenizer/issues", - "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" }, "funding": [ { @@ -11672,7 +11675,7 @@ "type": "github" } ], - "time": "2025-11-17T20:03:58+00:00" + "time": "2025-12-08T11:19:18+00:00" }, { "name": "webmozart/assert", From b1467204f89456d7ced83d70415ad916b83a1816 Mon Sep 17 00:00:00 2001 From: andrzejskowron Date: Wed, 26 Nov 2025 13:13:33 +0100 Subject: [PATCH 02/53] add preview import list --- .../views/livewire/catalog/index.blade.php | 137 +++++++++++++++ .../views/livewire/catalog/trmnl.blade.php | 157 ++++++++++++++++++ 2 files changed, 294 insertions(+) diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 201ee7e..83a34fc 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -14,6 +14,8 @@ new class extends Component { public array $catalogPlugins = []; public string $installingPlugin = ''; + public string $previewingPlugin = ''; + public array $previewData = []; public function mount(): void { @@ -117,6 +119,31 @@ class extends Component { $this->installingPlugin = ''; } } + + public function previewPlugin(string $pluginId): void + { + $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); + + if (!$plugin) { + $this->addError('preview', 'Plugin not found.'); + return; + } + + $this->previewingPlugin = $pluginId; + $this->previewData = $plugin; + + // Store scroll position for restoration later + $this->dispatch('store-scroll-position'); + } + + public function closePreview(): void + { + $this->previewingPlugin = ''; + $this->previewData = []; + + // Restore scroll position when returning to catalog + $this->dispatch('restore-scroll-position'); + } }; ?>
@@ -174,6 +201,17 @@ class extends Component { Install + + + Preview + + + + + @if($plugin['learn_more_url']) @endif + + + + @if($previewingPlugin && !empty($previewData)) +
+ Preview {{ $previewData['name'] ?? 'Plugin' }} +
+ +
+ @if($previewData['screenshot_url']) +
+ Preview of {{ $previewData['name'] }} +
+ @elseif($previewData['logo_url']) +
+ {{ $previewData['name'] }} logo +

No preview image available

+
+ @else +
+ +

No preview available

+
+ @endif + + @if($previewData['description']) +
+

Description

+

{{ $previewData['description'] }}

+
+ @endif + +
+ + + Install Plugin + + +
+
+ @endif +
+ +@script + +@endscript diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 8e9c7af..8a2d72c 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -14,6 +14,8 @@ class extends Component { public array $recipes = []; public string $search = ''; public bool $isSearching = false; + public string $previewingRecipe = ''; + public array $previewData = []; public function mount(): void { @@ -125,6 +127,31 @@ class extends Component { } } + public function previewRecipe(string $recipeId): void + { + $recipe = collect($this->recipes)->firstWhere('id', $recipeId); + + if (!$recipe) { + $this->addError('preview', 'Recipe not found.'); + return; + } + + $this->previewingRecipe = $recipeId; + $this->previewData = $recipe; + + // Store scroll position for restoration later + $this->dispatch('store-scroll-position'); + } + + public function closePreview(): void + { + $this->previewingRecipe = ''; + $this->previewData = []; + + // Restore scroll position when returning to catalog + $this->dispatch('restore-scroll-position'); + } + /** * @param array> $items * @return array> @@ -218,6 +245,19 @@ class extends Component { @endif + @if($recipe['id']) + + + Preview + + + @endif + + + @if($recipe['detail_url']) @endif + + + + @if($previewingRecipe && !empty($previewData)) +
+ Preview {{ $previewData['name'] ?? 'Recipe' }} +
+ +
+ @if($previewData['screenshot_url']) +
+ Preview of {{ $previewData['name'] }} +
+ @elseif($previewData['icon_url']) +
+ {{ $previewData['name'] }} icon +

No preview image available

+
+ @else +
+ +

No preview available

+
+ @endif + + @if($previewData['author_bio']) +
+

Description

+

{{ $previewData['author_bio'] }}

+
+ @endif + + @if(data_get($previewData, 'stats.installs')) +
+

Statistics

+

+ Installs: {{ data_get($previewData, 'stats.installs') }} · + Forks: {{ data_get($previewData, 'stats.forks') }} +

+
+ @endif + +
+ @if($previewData['detail_url']) + + View on TRMNL + + @endif + + + Install Recipe + + +
+
+ @endif +
+ +@script + +@endscript From a7963947f8111cfc476bf898f065f4815fbc5174 Mon Sep 17 00:00:00 2001 From: andrzejskowron Date: Sun, 30 Nov 2025 07:12:16 +0100 Subject: [PATCH 03/53] use flux design --- .../views/livewire/catalog/trmnl.blade.php | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 8a2d72c..0460337 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,7 +204,7 @@ class extends Component { @else
@foreach($recipes as $recipe) -
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @if($thumb) @@ -218,9 +218,9 @@ class extends Component {
-

{{ $recipe['name'] }}

+ {{ $recipe['name'] }} @if(data_get($recipe, 'stats.installs')) -

Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }}

+ Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} @endif
@@ -233,7 +233,7 @@ class extends Component {
@if($recipe['author_bio']) -

{{ $recipe['author_bio'] }}

+ {{ $recipe['author_bio'] }} @endif
@@ -269,7 +269,7 @@ class extends Component {
-
+
@endforeach
@endif @@ -293,30 +293,30 @@ class extends Component { {{ $previewData['name'] }} icon -

No preview image available

+ No preview image available
@else
-

No preview available

+ No preview available
@endif @if($previewData['author_bio']) -
-

Description

-

{{ $previewData['author_bio'] }}

-
+ + Description + {{ $previewData['author_bio'] }} + @endif @if(data_get($previewData, 'stats.installs')) -
-

Statistics

-

+ + Statistics + Installs: {{ data_get($previewData, 'stats.installs') }} · Forks: {{ data_get($previewData, 'stats.forks') }} -

-
+ + @endif
From f3538048d4071957deb0761ac84173f9cb79f4bc Mon Sep 17 00:00:00 2001 From: andrzejskowron Date: Sun, 30 Nov 2025 08:08:06 +0100 Subject: [PATCH 04/53] use flux design --- .../views/livewire/catalog/trmnl.blade.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 0460337..96a92c0 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,7 +204,7 @@ class extends Component { @else
@foreach($recipes as $recipe) - +
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @if($thumb) @@ -220,7 +220,7 @@ class extends Component {
{{ $recipe['name'] }} @if(data_get($recipe, 'stats.installs')) - Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} + Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} @endif
@@ -269,7 +269,7 @@ class extends Component {
-
+
@endforeach
@endif @@ -293,30 +293,30 @@ class extends Component { {{ $previewData['name'] }} icon - No preview image available + No preview image available @else
- No preview available + No preview available
@endif @if($previewData['author_bio']) - +
Description {{ $previewData['author_bio'] }} - +
@endif @if(data_get($previewData, 'stats.installs')) - +
Statistics Installs: {{ data_get($previewData, 'stats.installs') }} · Forks: {{ data_get($previewData, 'stats.forks') }} - +
@endif
From be2bb637c9a4d728206df11d1eb78924d4c8da95 Mon Sep 17 00:00:00 2001 From: andrzejskowron Date: Sun, 30 Nov 2025 08:46:51 +0100 Subject: [PATCH 05/53] styling in line with project standards --- .../views/livewire/catalog/trmnl.blade.php | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 96a92c0..e32ae58 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,8 +204,9 @@ class extends Component { @else
@foreach($recipes as $recipe) -
-
+
+
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @if($thumb) {{ $recipe['name'] }} @@ -269,6 +270,7 @@ class extends Component {
+
@endforeach
@@ -303,19 +305,23 @@ class extends Component { @endif @if($previewData['author_bio']) -
- Description - {{ $previewData['author_bio'] }} +
+
+ Description + {{ $previewData['author_bio'] }} +
@endif @if(data_get($previewData, 'stats.installs')) -
- Statistics - - Installs: {{ data_get($previewData, 'stats.installs') }} · - Forks: {{ data_get($previewData, 'stats.forks') }} - +
+
+ Statistics + + Installs: {{ data_get($previewData, 'stats.installs') }} · + Forks: {{ data_get($previewData, 'stats.forks') }} + +
@endif From d49a2d4f6c78c0fdfa8fa65e0dea95e78f7c998f Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sat, 13 Dec 2025 13:05:35 +0100 Subject: [PATCH 06/53] fix: styling in line with project standards --- .../views/livewire/catalog/trmnl.blade.php | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index e32ae58..1b5dd50 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -204,7 +204,7 @@ class extends Component { @else
@foreach($recipes as $recipe) -
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) @@ -256,17 +256,6 @@ class extends Component { @endif - - - - @if($recipe['detail_url']) - - View on TRMNL - - @endif
@@ -305,7 +294,7 @@ class extends Component { @endif @if($previewData['author_bio']) -
+
Description {{ $previewData['author_bio'] }} @@ -314,7 +303,7 @@ class extends Component { @endif @if(data_get($previewData, 'stats.installs')) -
+
Statistics From f1a9103f0dccee996e213e8a5d05f09d1837410d Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 17 Dec 2025 22:53:29 +0100 Subject: [PATCH 07/53] chore: update dependencies --- composer.lock | 290 +++++++++++++++++++++++++------------------------- 1 file changed, 147 insertions(+), 143 deletions(-) diff --git a/composer.lock b/composer.lock index d13a3e8..a2e83af 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.366.4", + "version": "3.369.1", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "1861cc8eede21cdaab0732fd44f43f19ddf1effd" + "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/1861cc8eede21cdaab0732fd44f43f19ddf1effd", - "reference": "1861cc8eede21cdaab0732fd44f43f19ddf1effd", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", + "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.366.4" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.1" }, - "time": "2025-12-09T19:21:22+00:00" + "time": "2025-12-22T19:13:21+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -1617,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.42.0", + "version": "v12.43.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75" + "reference": "195b893593a9298edee177c0844132ebaa02102f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75", - "reference": "509b33095564c5165366d81bbaa0afaac28abe75", + "url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f", + "reference": "195b893593a9298edee177c0844132ebaa02102f", "shasum": "" }, "require": { @@ -1835,7 +1835,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-09T15:51:23+00:00" + "time": "2025-12-16T18:53:08+00:00" }, { "name": "laravel/prompts", @@ -2851,16 +2851,16 @@ }, { "name": "livewire/flux", - "version": "v2.9.2", + "version": "v2.10.2", "source": { "type": "git", "url": "https://github.com/livewire/flux.git", - "reference": "6572847f70a18e7cf136bb31201d4064f5c8ade1" + "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/flux/zipball/6572847f70a18e7cf136bb31201d4064f5c8ade1", - "reference": "6572847f70a18e7cf136bb31201d4064f5c8ade1", + "url": "https://api.github.com/repos/livewire/flux/zipball/e7a93989788429bb6c0a908a056d22ea3a6c7975", + "reference": "e7a93989788429bb6c0a908a056d22ea3a6c7975", "shasum": "" }, "require": { @@ -2868,12 +2868,12 @@ "illuminate/support": "^10.0|^11.0|^12.0", "illuminate/view": "^10.0|^11.0|^12.0", "laravel/prompts": "^0.1|^0.2|^0.3", - "livewire/livewire": "^3.5.19|^4.0", + "livewire/livewire": "^3.7.3|^4.0", "php": "^8.1", "symfony/console": "^6.0|^7.0" }, "conflict": { - "livewire/blaze": "<0.1.0" + "livewire/blaze": "<1.0.0" }, "type": "library", "extra": { @@ -2911,22 +2911,22 @@ ], "support": { "issues": "https://github.com/livewire/flux/issues", - "source": "https://github.com/livewire/flux/tree/v2.9.2" + "source": "https://github.com/livewire/flux/tree/v2.10.2" }, - "time": "2025-12-04T17:09:39+00:00" + "time": "2025-12-19T02:11:45+00:00" }, { "name": "livewire/livewire", - "version": "v3.7.1", + "version": "v3.7.3", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "214da8f3a1199a88b56ab2fe901d4a607f784805" + "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/214da8f3a1199a88b56ab2fe901d4a607f784805", - "reference": "214da8f3a1199a88b56ab2fe901d4a607f784805", + "url": "https://api.github.com/repos/livewire/livewire/zipball/a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", + "reference": "a5384df9fbd3eaf02e053bc49aabc8ace293fc1c", "shasum": "" }, "require": { @@ -2981,7 +2981,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.7.1" + "source": "https://github.com/livewire/livewire/tree/v3.7.3" }, "funding": [ { @@ -2989,7 +2989,7 @@ "type": "github" } ], - "time": "2025-12-03T22:41:13+00:00" + "time": "2025-12-19T02:00:29+00:00" }, { "name": "livewire/volt", @@ -3481,16 +3481,16 @@ }, { "name": "nette/utils", - "version": "v4.1.0", + "version": "v4.1.1", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0" + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", - "reference": "fa1f0b8261ed150447979eb22e373b7b7ad5a8e0", + "url": "https://api.github.com/repos/nette/utils/zipball/c99059c0315591f1a0db7ad6002000288ab8dc72", + "reference": "c99059c0315591f1a0db7ad6002000288ab8dc72", "shasum": "" }, "require": { @@ -3564,9 +3564,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.0" + "source": "https://github.com/nette/utils/tree/v4.1.1" }, - "time": "2025-12-01T17:49:23+00:00" + "time": "2025-12-22T12:14:32+00:00" }, { "name": "nikic/php-parser", @@ -3715,23 +3715,23 @@ }, { "name": "om/icalparser", - "version": "v3.2.0", + "version": "v3.2.1", "source": { "type": "git", "url": "https://github.com/OzzyCzech/icalparser.git", - "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b" + "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/3aa0716aa9e729f08fba20390773d6dcd685169b", - "reference": "3aa0716aa9e729f08fba20390773d6dcd685169b", + "url": "https://api.github.com/repos/OzzyCzech/icalparser/zipball/bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", + "reference": "bc7a82b12455ae9b62ce8e7f2d0273e86c931ecc", "shasum": "" }, "require": { "php": ">=8.1.0" }, "require-dev": { - "nette/tester": "^2.5.6" + "nette/tester": "^2.5.7" }, "suggest": { "ext-dom": "for timezone tool" @@ -3760,9 +3760,9 @@ ], "support": { "issues": "https://github.com/OzzyCzech/icalparser/issues", - "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.0" + "source": "https://github.com/OzzyCzech/icalparser/tree/v3.2.1" }, - "time": "2025-09-08T07:04:53+00:00" + "time": "2025-12-15T06:25:09+00:00" }, { "name": "paragonie/constant_time_encoding", @@ -3960,16 +3960,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.47", + "version": "3.0.48", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d" + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/9d6ca36a6c2dd434765b1071b2644a1c683b385d", - "reference": "9d6ca36a6c2dd434765b1071b2644a1c683b385d", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89", + "reference": "64065a5679c50acb886e82c07aa139b0f757bb89", "shasum": "" }, "require": { @@ -4050,7 +4050,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.47" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48" }, "funding": [ { @@ -4066,7 +4066,7 @@ "type": "tidelift" } ], - "time": "2025-10-06T01:07:24+00:00" + "time": "2025-12-15T11:51:42+00:00" }, { "name": "psr/clock", @@ -4482,16 +4482,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.16", + "version": "v0.12.18", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67" + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", - "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", + "reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "shasum": "" }, "require": { @@ -4555,9 +4555,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.16" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.18" }, - "time": "2025-12-07T03:39:01+00:00" + "time": "2025-12-17T14:35:46+00:00" }, { "name": "ralouphie/getallheaders", @@ -4681,20 +4681,20 @@ }, { "name": "ramsey/uuid", - "version": "4.9.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440" + "reference": "8429c78ca35a09f27565311b98101e2826affde0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440", - "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0", + "reference": "8429c78ca35a09f27565311b98101e2826affde0", "shasum": "" }, "require": { - "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", + "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14", "php": "^8.0", "ramsey/collection": "^1.2 || ^2.0" }, @@ -4753,22 +4753,22 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.9.1" + "source": "https://github.com/ramsey/uuid/tree/4.9.2" }, - "time": "2025-09-04T20:59:21+00:00" + "time": "2025-12-14T04:43:48+00:00" }, { "name": "spatie/browsershot", - "version": "5.1.1", + "version": "5.2.0", "source": { "type": "git", "url": "https://github.com/spatie/browsershot.git", - "reference": "127c20da43d0d711ebbc64f85053f50bc147c515" + "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/browsershot/zipball/127c20da43d0d711ebbc64f85053f50bc147c515", - "reference": "127c20da43d0d711ebbc64f85053f50bc147c515", + "url": "https://api.github.com/repos/spatie/browsershot/zipball/9bc6b8d67175810d7a399b2588c3401efe2d02a8", + "reference": "9bc6b8d67175810d7a399b2588c3401efe2d02a8", "shasum": "" }, "require": { @@ -4815,7 +4815,7 @@ "webpage" ], "support": { - "source": "https://github.com/spatie/browsershot/tree/5.1.1" + "source": "https://github.com/spatie/browsershot/tree/5.2.0" }, "funding": [ { @@ -4823,7 +4823,7 @@ "type": "github" } ], - "time": "2025-11-26T09:49:20+00:00" + "time": "2025-12-22T10:02:16+00:00" }, { "name": "spatie/laravel-package-tools", @@ -5124,20 +5124,20 @@ }, { "name": "symfony/css-selector", - "version": "v7.4.0", + "version": "v8.0.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135" + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/ab862f478513e7ca2fe9ec117a6f01a8da6e1135", - "reference": "ab862f478513e7ca2fe9ec117a6f01a8da6e1135", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/6225bd458c53ecdee056214cb4a2ffaf58bd592b", + "reference": "6225bd458c53ecdee056214cb4a2ffaf58bd592b", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.4" }, "type": "library", "autoload": { @@ -5169,7 +5169,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.0" + "source": "https://github.com/symfony/css-selector/tree/v8.0.0" }, "funding": [ { @@ -5189,7 +5189,7 @@ "type": "tidelift" } ], - "time": "2025-10-30T13:39:42+00:00" + "time": "2025-10-30T14:17:19+00:00" }, { "name": "symfony/deprecation-contracts", @@ -7668,23 +7668,23 @@ }, { "name": "tijsverkoyen/css-to-inline-styles", - "version": "v2.3.0", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d" + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d", - "reference": "0d72ac1c00084279c1816675284073c5a337c20d", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "php": "^7.4 || ^8.0", - "symfony/css-selector": "^5.4 || ^6.0 || ^7.0" + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^2.0", @@ -7717,9 +7717,9 @@ "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", "support": { "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", - "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0" + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" }, - "time": "2024-12-21T16:25:41+00:00" + "time": "2025-12-02T11:56:42+00:00" }, { "name": "vlucas/phpdotenv", @@ -7969,16 +7969,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.15.0", + "version": "v7.16.0", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "272ff9d59b2ed0bd97c86c3cfe97c9784dabf786" + "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/272ff9d59b2ed0bd97c86c3cfe97c9784dabf786", - "reference": "272ff9d59b2ed0bd97c86c3cfe97c9784dabf786", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/a10878ed0fe0bbc2f57c980f7a08065338b970b6", + "reference": "a10878ed0fe0bbc2f57c980f7a08065338b970b6", "shasum": "" }, "require": { @@ -7989,10 +7989,10 @@ "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", "php": "~8.3.0 || ~8.4.0 || ~8.5.0", - "phpunit/php-code-coverage": "^12.5.0", + "phpunit/php-code-coverage": "^12.5.1", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.4.4", + "phpunit/phpunit": "^12.5.2", "sebastian/environment": "^8.0.3", "symfony/console": "^7.3.4 || ^8.0.0", "symfony/process": "^7.3.4 || ^8.0.0" @@ -8002,9 +8002,9 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.32", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-phpunit": "^2.0.10", "phpstan/phpstan-strict-rules": "^2.0.7", "symfony/filesystem": "^7.3.2 || ^8.0.0" }, @@ -8046,7 +8046,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.15.0" + "source": "https://github.com/paratestphp/paratest/tree/v7.16.0" }, "funding": [ { @@ -8058,7 +8058,7 @@ "type": "paypal" } ], - "time": "2025-11-30T08:08:11+00:00" + "time": "2025-12-09T20:03:26+00:00" }, { "name": "doctrine/deprecations", @@ -8457,16 +8457,16 @@ }, { "name": "larastan/larastan", - "version": "v3.8.0", + "version": "v3.8.1", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e" + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", - "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", + "url": "https://api.github.com/repos/larastan/larastan/zipball/ff3725291bc4c7e6032b5a54776e3e5104c86db9", + "reference": "ff3725291bc4c7e6032b5a54776e3e5104c86db9", "shasum": "" }, "require": { @@ -8480,7 +8480,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1", "illuminate/support": "^11.44.2 || ^12.4.1", "php": "^8.2", - "phpstan/phpstan": "^2.1.29" + "phpstan/phpstan": "^2.1.32" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -8535,7 +8535,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.8.0" + "source": "https://github.com/larastan/larastan/tree/v3.8.1" }, "funding": [ { @@ -8543,29 +8543,29 @@ "type": "github" } ], - "time": "2025-10-27T23:09:14+00:00" + "time": "2025-12-11T16:37:35+00:00" }, { "name": "laravel/boost", - "version": "v1.8.5", + "version": "v1.8.7", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e" + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", - "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e", + "url": "https://api.github.com/repos/laravel/boost/zipball/7a5709a8134ed59d3e7f34fccbd74689830e296c", + "reference": "7a5709a8134ed59d3e7f34fccbd74689830e296c", "shasum": "" }, "require": { "guzzlehttp/guzzle": "^7.9", - "illuminate/console": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1", - "illuminate/support": "^10.49.0|^11.45.3|^12.28.1", - "laravel/mcp": "^0.4.1", + "illuminate/console": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1", + "illuminate/support": "^10.49.0|^11.45.3|^12.41.1", + "laravel/mcp": "^0.5.1", "laravel/prompts": "0.1.25|^0.3.6", "laravel/roster": "^0.2.9", "php": "^8.1" @@ -8609,20 +8609,20 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-12-08T21:54:49+00:00" + "time": "2025-12-19T15:04:12+00:00" }, { "name": "laravel/mcp", - "version": "v0.4.2", + "version": "v0.5.1", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef" + "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/1c7878be3931a19768f791ddf141af29f43fb4ef", - "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef", + "url": "https://api.github.com/repos/laravel/mcp/zipball/10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", + "reference": "10dedea054fa4eeaa9ef2ccbfdad6c3e1dbd17a4", "shasum": "" }, "require": { @@ -8682,7 +8682,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-12-07T15:49:15+00:00" + "time": "2025-12-17T06:14:23+00:00" }, { "name": "laravel/pail", @@ -9198,33 +9198,33 @@ }, { "name": "pestphp/pest", - "version": "v4.1.6", + "version": "v4.2.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "ae419afd363299c29ad5b17e8b70d118b1068bb4" + "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/ae419afd363299c29ad5b17e8b70d118b1068bb4", - "reference": "ae419afd363299c29ad5b17e8b70d118b1068bb4", + "url": "https://api.github.com/repos/pestphp/pest/zipball/7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", + "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", "shasum": "" }, "require": { - "brianium/paratest": "^7.14.2", + "brianium/paratest": "^7.16.0", "nunomaduro/collision": "^8.8.3", "nunomaduro/termwind": "^2.3.3", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.0", "pestphp/pest-plugin-mutate": "^4.0.1", - "pestphp/pest-plugin-profanity": "^4.2.0", + "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.4.4", + "phpunit/phpunit": "^12.5.3", "symfony/process": "^7.4.0|^8.0.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.4.4", + "phpunit/phpunit": ">12.5.3", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -9232,7 +9232,7 @@ "pestphp/pest-dev-tools": "^4.0.0", "pestphp/pest-plugin-browser": "^4.1.1", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.15" + "psy/psysh": "^0.12.17" }, "bin": [ "bin/pest" @@ -9298,7 +9298,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.1.6" + "source": "https://github.com/pestphp/pest/tree/v4.2.0" }, "funding": [ { @@ -9310,7 +9310,7 @@ "type": "github" } ], - "time": "2025-11-28T12:04:48+00:00" + "time": "2025-12-15T11:49:28+00:00" }, { "name": "pestphp/pest-plugin", @@ -9900,16 +9900,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.5", + "version": "5.6.6", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761" + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/90614c73d3800e187615e2dd236ad0e2a01bf761", - "reference": "90614c73d3800e187615e2dd236ad0e2a01bf761", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/5cee1d3dfc2d2aa6599834520911d246f656bcb8", + "reference": "5cee1d3dfc2d2aa6599834520911d246f656bcb8", "shasum": "" }, "require": { @@ -9919,7 +9919,7 @@ "phpdocumentor/reflection-common": "^2.2", "phpdocumentor/type-resolver": "^1.7", "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1" + "webmozart/assert": "^1.9.1 || ^2" }, "require-dev": { "mockery/mockery": "~1.3.5 || ~1.6.0", @@ -9958,9 +9958,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.5" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.6" }, - "time": "2025-11-27T19:50:05+00:00" + "time": "2025-12-22T21:13:58+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -10456,16 +10456,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.4.4", + "version": "12.5.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "9253ec75a672e39fcc9d85bdb61448215b8162c7" + "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9253ec75a672e39fcc9d85bdb61448215b8162c7", - "reference": "9253ec75a672e39fcc9d85bdb61448215b8162c7", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6dc2e076d09960efbb0c1272aa9bc156fc80955e", + "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e", "shasum": "" }, "require": { @@ -10479,7 +10479,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.3", - "phpunit/php-code-coverage": "^12.4.0", + "phpunit/php-code-coverage": "^12.5.1", "phpunit/php-file-iterator": "^6.0.0", "phpunit/php-invoker": "^6.0.0", "phpunit/php-text-template": "^5.0.0", @@ -10501,7 +10501,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "12.4-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -10533,7 +10533,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.4" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.3" }, "funding": [ { @@ -10557,7 +10557,7 @@ "type": "tidelift" } ], - "time": "2025-11-21T07:39:11+00:00" + "time": "2025-12-11T08:52:59+00:00" }, { "name": "rector/rector", @@ -11679,23 +11679,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/1b34b004e35a164bc5bb6ebd33c844b2d8069a54", + "reference": "1b34b004e35a164bc5bb6ebd33c844b2d8069a54", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -11705,7 +11705,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -11721,6 +11721,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -11731,9 +11735,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.0.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2025-12-16T21:36:00+00:00" } ], "aliases": [], From 0b2b5bf25fdb578ed40c2e5dbeb0c81f87b4d631 Mon Sep 17 00:00:00 2001 From: dowjames Date: Sat, 27 Dec 2025 16:24:18 -0500 Subject: [PATCH 08/53] Update holidays-ical.blade.php *Past events are removed. *Events that started earlier but are still ongoing today remain visible. *Anything from today onward displays. --- .../views/recipes/holidays-ical.blade.php | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/resources/views/recipes/holidays-ical.blade.php b/resources/views/recipes/holidays-ical.blade.php index f5f5403..454709d 100644 --- a/resources/views/recipes/holidays-ical.blade.php +++ b/resources/views/recipes/holidays-ical.blade.php @@ -2,36 +2,46 @@ @php use Carbon\Carbon; + $today = Carbon::today(config('app.timezone')); + $events = collect($data['ical'] ?? []) ->map(function (array $event): array { - $start = null; - $end = null; - try { - $start = isset($event['DTSTART']) ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) : null; + $start = isset($event['DTSTART']) + ? Carbon::parse($event['DTSTART'])->setTimezone(config('app.timezone')) + : null; } catch (Exception $e) { $start = null; } try { - $end = isset($event['DTEND']) ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) : null; + $end = isset($event['DTEND']) + ? Carbon::parse($event['DTEND'])->setTimezone(config('app.timezone')) + : null; } catch (Exception $e) { $end = null; } return [ - 'summary' => $event['SUMMARY'] ?? 'Untitled event', - 'location' => $event['LOCATION'] ?? null, - 'start' => $start, - 'end' => $end, + 'summary' => $event['SUMMARY'] ?? 'Untitled event', + 'location' => $event['LOCATION'] ?? '—', + 'start' => $start, + 'end' => $end, ]; }) - ->filter(fn ($event) => $event['start']) + ->filter(fn ($event) => + $event['start'] && + ( + $event['start']->greaterThanOrEqualTo($today) || + ($event['end'] && $event['end']->greaterThanOrEqualTo($today)) + ) + ) ->sortBy('start') ->take($size === 'quadrant' ? 5 : 8) ->values(); @endphp + From d81c1b99f1611ac05ab4be63fd36acec8c56ff5e Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 11:39:21 +0100 Subject: [PATCH 09/53] Update download and star counts in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 20bae5d..34f5c3d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml) 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** (100+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), ~500 from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 30k downloads and 130+ stars, it’s the most popular community-driven BYOS. +It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (100+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), ~500 from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 35k downloads and 150+ stars, it’s the most popular community-driven BYOS. ![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot-dark.png) From d4b5cf99d578421c1459c90a68fccd52902599b8 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 22:05:20 +0100 Subject: [PATCH 10/53] chore: update dependencies --- composer.lock | 100 +++++++++++++++++++++++++------------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/composer.lock b/composer.lock index a2e83af..1b578bf 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.1", + "version": "3.369.4", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3" + "reference": "2aa1ef195e90140d733382e4341732ce113024f5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", - "reference": "b2d04cf1184a96839a8ab62ec6e3cf2d62a278e3", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2aa1ef195e90140d733382e4341732ce113024f5", + "reference": "2aa1ef195e90140d733382e4341732ce113024f5", "shasum": "" }, "require": { @@ -85,7 +85,7 @@ "mtdowling/jmespath.php": "^2.8.0", "php": ">=8.1", "psr/http-message": "^1.0 || ^2.0", - "symfony/filesystem": "^v6.4.3 || ^v7.1.0 || ^v8.0.0" + "symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0" }, "require-dev": { "andrewsville/php-token-reflection": "^1.4", @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.369.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.4" }, - "time": "2025-12-22T19:13:21+00:00" + "time": "2025-12-29T19:07:47+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -950,24 +950,24 @@ }, { "name": "graham-campbell/result-type", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/GrahamCampbell/Result-Type.git", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945" + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/3ba905c11371512af9d9bdd27d99b782216b6945", - "reference": "3ba905c11371512af9d9bdd27d99b782216b6945", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", "shasum": "" }, "require": { "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3" + "phpoption/phpoption": "^1.9.5" }, "require-dev": { - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" }, "type": "library", "autoload": { @@ -996,7 +996,7 @@ ], "support": { "issues": "https://github.com/GrahamCampbell/Result-Type/issues", - "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.3" + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" }, "funding": [ { @@ -1008,7 +1008,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:45:45+00:00" + "time": "2025-12-27T19:43:20+00:00" }, { "name": "guzzlehttp/guzzle", @@ -1617,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.43.1", + "version": "v12.44.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "195b893593a9298edee177c0844132ebaa02102f" + "reference": "592bbf1c036042958332eb98e3e8131b29102f33" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/195b893593a9298edee177c0844132ebaa02102f", - "reference": "195b893593a9298edee177c0844132ebaa02102f", + "url": "https://api.github.com/repos/laravel/framework/zipball/592bbf1c036042958332eb98e3e8131b29102f33", + "reference": "592bbf1c036042958332eb98e3e8131b29102f33", "shasum": "" }, "require": { @@ -1835,7 +1835,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-12-16T18:53:08+00:00" + "time": "2025-12-23T15:29:43+00:00" }, { "name": "laravel/prompts", @@ -3885,16 +3885,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.4", + "version": "1.9.5", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", - "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", "shasum": "" }, "require": { @@ -3944,7 +3944,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" }, "funding": [ { @@ -3956,7 +3956,7 @@ "type": "tidelift" } ], - "time": "2025-08-21T11:53:16+00:00" + "time": "2025-12-27T19:41:33+00:00" }, { "name": "phpseclib/phpseclib", @@ -7723,26 +7723,26 @@ }, { "name": "vlucas/phpdotenv", - "version": "v5.6.2", + "version": "v5.6.3", "source": { "type": "git", "url": "https://github.com/vlucas/phpdotenv.git", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af" + "reference": "955e7815d677a3eaa7075231212f2110983adecc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/24ac4c74f91ee2c193fa1aaa5c249cb0822809af", - "reference": "24ac4c74f91ee2c193fa1aaa5c249cb0822809af", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", "shasum": "" }, "require": { "ext-pcre": "*", - "graham-campbell/result-type": "^1.1.3", + "graham-campbell/result-type": "^1.1.4", "php": "^7.2.5 || ^8.0", - "phpoption/phpoption": "^1.9.3", - "symfony/polyfill-ctype": "^1.24", - "symfony/polyfill-mbstring": "^1.24", - "symfony/polyfill-php80": "^1.24" + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", @@ -7791,7 +7791,7 @@ ], "support": { "issues": "https://github.com/vlucas/phpdotenv/issues", - "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.2" + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" }, "funding": [ { @@ -7803,7 +7803,7 @@ "type": "tidelift" } ], - "time": "2025-04-30T23:37:27+00:00" + "time": "2025-12-27T19:49:13+00:00" }, { "name": "voku/portable-ascii", @@ -10122,16 +10122,16 @@ }, { "name": "phpunit/php-code-coverage", - "version": "12.5.1", + "version": "12.5.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "c467c59a4f6e04b942be422844e7a6352fa01b57" + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/c467c59a4f6e04b942be422844e7a6352fa01b57", - "reference": "c467c59a4f6e04b942be422844e7a6352fa01b57", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/4a9739b51cbcb355f6e95659612f92e282a7077b", + "reference": "4a9739b51cbcb355f6e95659612f92e282a7077b", "shasum": "" }, "require": { @@ -10146,7 +10146,7 @@ "sebastian/environment": "^8.0.3", "sebastian/lines-of-code": "^4.0", "sebastian/version": "^6.0", - "theseer/tokenizer": "^2.0" + "theseer/tokenizer": "^2.0.1" }, "require-dev": { "phpunit/phpunit": "^12.5.1" @@ -10187,7 +10187,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.1" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.2" }, "funding": [ { @@ -10207,7 +10207,7 @@ "type": "tidelift" } ], - "time": "2025-12-08T07:17:58+00:00" + "time": "2025-12-24T07:03:04+00:00" }, { "name": "phpunit/php-file-iterator", @@ -10561,16 +10561,16 @@ }, { "name": "rector/rector", - "version": "2.2.14", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d" + "reference": "f7166355dcf47482f27be59169b0825995f51c7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/6d56bb0e94d4df4f57a78610550ac76ab403657d", - "reference": "6d56bb0e94d4df4f57a78610550ac76ab403657d", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/f7166355dcf47482f27be59169b0825995f51c7d", + "reference": "f7166355dcf47482f27be59169b0825995f51c7d", "shasum": "" }, "require": { @@ -10609,7 +10609,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.2.14" + "source": "https://github.com/rectorphp/rector/tree/2.3.0" }, "funding": [ { @@ -10617,7 +10617,7 @@ "type": "github" } ], - "time": "2025-12-09T10:57:55+00:00" + "time": "2025-12-25T22:00:18+00:00" }, { "name": "sebastian/cli-parser", From e6d66af2984c0750a7f76e62c2529fb9bb091f20 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 22:16:29 +0100 Subject: [PATCH 11/53] fix(#135): use user configured timezone in Playlists --- app/Models/Playlist.php | 24 +++++++++---- tests/Feature/PlaylistSchedulingTest.php | 45 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 7b55a73..68fbddb 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; class Playlist extends Model { @@ -37,21 +38,32 @@ class Playlist extends Model return false; } - // Check weekday - if ($this->weekdays !== null && ! in_array(now()->dayOfWeek, $this->weekdays)) { + // Get user's timezone or fall back to app timezone + $timezone = $this->device->user->timezone ?? config('app.timezone'); + $now = now($timezone); + + // Check weekday (using timezone-aware time) + if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) { return false; } if ($this->active_from !== null && $this->active_until !== null) { - $now = now(); + // Create timezone-aware datetime objects for active_from and active_until + $activeFrom = $now->copy() + ->setTimeFrom($this->active_from) + ->timezone($timezone); + + $activeUntil = $now->copy() + ->setTimeFrom($this->active_until) + ->timezone($timezone); // Handle time ranges that span across midnight - if ($this->active_from > $this->active_until) { + if ($activeFrom > $activeUntil) { // Time range spans midnight (e.g., 09:01 to 03:58) - if ($now >= $this->active_from || $now <= $this->active_until) { + if ($now >= $activeFrom || $now <= $activeUntil) { return true; } - } elseif ($now >= $this->active_from && $now <= $this->active_until) { + } elseif ($now >= $activeFrom && $now <= $activeUntil) { return true; } diff --git a/tests/Feature/PlaylistSchedulingTest.php b/tests/Feature/PlaylistSchedulingTest.php index aea4923..18d0032 100644 --- a/tests/Feature/PlaylistSchedulingTest.php +++ b/tests/Feature/PlaylistSchedulingTest.php @@ -130,3 +130,48 @@ test('playlist isActiveNow handles normal time ranges correctly', function (): v Carbon::setTestNow(Carbon::create(2024, 1, 1, 20, 0, 0)); expect($playlist->isActiveNow())->toBeFalse(); }); + +test('playlist scheduling respects user timezone preference', function (): void { + // Create a user with a timezone that's UTC+1 (e.g., Europe/Berlin) + // This simulates the bug where setting 00:15 doesn't work until one hour later + $user = User::factory()->create([ + 'timezone' => 'Europe/Berlin', // UTC+1 in winter, UTC+2 in summer + ]); + + $device = Device::factory()->create(['user_id' => $user->id]); + + // Create a playlist that should be active from 00:15 to 01:00 in the user's timezone + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'is_active' => true, + 'active_from' => '00:15', + 'active_until' => '01:00', + 'weekdays' => null, + ]); + + // Set test time to 00:15 in the user's timezone (Europe/Berlin) + // In January, Europe/Berlin is UTC+1, so 00:15 Berlin time = 23:15 UTC the previous day + // But Carbon::setTestNow uses UTC by default, so we need to set it to the UTC equivalent + // For January 1, 2024 at 00:15 Berlin time (UTC+1), that's December 31, 2023 at 23:15 UTC + $berlinTime = Carbon::create(2024, 1, 1, 0, 15, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + + // The playlist should be active at 00:15 in the user's timezone + // This test should pass after the fix, but will fail with the current bug + expect($playlist->isActiveNow())->toBeTrue(); + + // Test at 00:30 in user's timezone - should still be active + $berlinTime = Carbon::create(2024, 1, 1, 0, 30, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + expect($playlist->isActiveNow())->toBeTrue(); + + // Test at 01:15 in user's timezone - should NOT be active (past the end time) + $berlinTime = Carbon::create(2024, 1, 1, 1, 15, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + expect($playlist->isActiveNow())->toBeFalse(); + + // Test at 00:10 in user's timezone - should NOT be active (before start time) + $berlinTime = Carbon::create(2024, 1, 1, 0, 10, 0, 'Europe/Berlin'); + Carbon::setTestNow($berlinTime->utc()); + expect($playlist->isActiveNow())->toBeFalse(); +}); From a5cb38421ebafa4e4c34916a84102fe3c9fa3c52 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 22:24:32 +0100 Subject: [PATCH 12/53] fix(#131): invalidate cache when updating recipe markup --- app/Models/Plugin.php | 7 ++++++ tests/Feature/ImageGenerationServiceTest.php | 24 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 2915247..9132d6c 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -55,6 +55,13 @@ class Plugin extends Model $model->uuid = Str::uuid(); } }); + + static::updating(function ($model): void { + // Reset image cache when markup changes + if ($model->isDirty('render_markup')) { + $model->current_image = null; + } + }); } public function user() diff --git a/tests/Feature/ImageGenerationServiceTest.php b/tests/Feature/ImageGenerationServiceTest.php index 603205e..07bb6a6 100644 --- a/tests/Feature/ImageGenerationServiceTest.php +++ b/tests/Feature/ImageGenerationServiceTest.php @@ -324,6 +324,30 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void expect($plugin->current_image)->toBe('test-uuid'); }); +it('cache is reset when plugin markup changes', function (): void { + // Create a plugin with cached image + $plugin = App\Models\Plugin::factory()->create([ + 'current_image' => 'cached-uuid', + 'render_markup' => '
Original markup
', + ]); + + // 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' => '
Updated markup
', + ]); + + // Assert cache was reset when markup changed + $plugin->refresh(); + expect($plugin->current_image)->toBeNull(); +}); + it('determines correct image format from device model', function (): void { // Test BMP format detection $bmpModel = DeviceModel::factory()->create([ From 1298814521b1ee27e955867364d9d9fdd597e9a2 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 23:07:21 +0100 Subject: [PATCH 13/53] fix(#136): mac address matching is case senstive --- app/Models/Device.php | 8 +++ routes/api.php | 10 ++-- tests/Feature/Api/DeviceEndpointsTest.php | 69 +++++++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/app/Models/Device.php b/app/Models/Device.php index 2eeb25b..3583f48 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -20,6 +20,14 @@ class Device extends Model protected $guarded = ['id']; + /** + * Set the MAC address attribute, normalizing to uppercase. + */ + public function setMacAddressAttribute(?string $value): void + { + $this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null; + } + protected $casts = [ 'battery_notification_sent' => 'boolean', 'proxy_cloud' => 'boolean', diff --git a/routes/api.php b/routes/api.php index 9721a0f..d1dbcac 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,7 +18,7 @@ use Illuminate\Support\Str; Route::get('/display', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', $mac_address) + $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) ->where('api_key', $access_token) ->first(); @@ -29,7 +29,7 @@ Route::get('/display', function (Request $request) { if ($auto_assign_user) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => $mac_address, + 'mac_address' => mb_strtoupper($mac_address ?? ''), 'api_key' => $access_token, 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -204,7 +204,7 @@ Route::get('/setup', function (Request $request) { ], 404); } - $device = Device::where('mac_address', $mac_address)->first(); + $device = Device::where('mac_address', mb_strtoupper($mac_address))->first(); if (! $device) { // Check if there's a user with assign_new_devices enabled @@ -219,7 +219,7 @@ Route::get('/setup', function (Request $request) { // Create a new device and assign it to this user $device = Device::create([ - 'mac_address' => $mac_address, + 'mac_address' => mb_strtoupper($mac_address), 'api_key' => Str::random(22), 'user_id' => $auto_assign_user->id, 'name' => "{$auto_assign_user->name}'s TRMNL", @@ -345,7 +345,7 @@ Route::post('/display/update', function (Request $request) { Route::post('/screens', function (Request $request) { $mac_address = $request->header('id'); $access_token = $request->header('access-token'); - $device = Device::where('mac_address', $mac_address) + $device = Device::where('mac_address', mb_strtoupper($mac_address ?? '')) ->where('api_key', $access_token) ->first(); diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index 726f313..aff6758 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -954,3 +954,72 @@ test('setup endpoint handles non-existent device model gracefully', function (): expect($device)->not->toBeNull() ->and($device->device_model_id)->toBeNull(); }); + +test('setup endpoint matches MAC address case-insensitively', function (): void { + // Create device with lowercase MAC address + $device = Device::factory()->create([ + 'mac_address' => 'a1:b2:c3:d4:e5:f6', + 'api_key' => 'test-api-key', + 'friendly_id' => 'test-device', + ]); + + // Request with uppercase MAC address should still match + $response = $this->withHeaders([ + 'id' => 'A1:B2:C3:D4:E5:F6', + ])->get('/api/setup'); + + $response->assertOk() + ->assertJson([ + 'status' => 200, + 'api_key' => 'test-api-key', + 'friendly_id' => 'test-device', + 'message' => 'Welcome to TRMNL BYOS', + ]); +}); + +test('display endpoint matches MAC address case-insensitively', function (): void { + // Create device with lowercase MAC address + $device = Device::factory()->create([ + 'mac_address' => 'a1:b2:c3:d4:e5:f6', + 'api_key' => 'test-api-key', + 'current_screen_image' => 'test-image', + ]); + + // Request with uppercase MAC address should still match + $response = $this->withHeaders([ + 'id' => 'A1:B2:C3:D4:E5:F6', + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk() + ->assertJson([ + 'status' => '0', + 'filename' => 'test-image.bmp', + ]); +}); + +test('screens endpoint matches MAC address case-insensitively', function (): void { + Queue::fake(); + + // Create device with uppercase MAC address + $device = Device::factory()->create([ + 'mac_address' => 'A1:B2:C3:D4:E5:F6', + 'api_key' => 'test-api-key', + ]); + + // Request with lowercase MAC address should still match + $response = $this->withHeaders([ + 'id' => 'a1:b2:c3:d4:e5:f6', + 'access-token' => $device->api_key, + ])->post('/api/screens', [ + 'image' => [ + 'content' => '
Test content
', + ], + ]); + + $response->assertOk(); + Queue::assertPushed(GenerateScreenJob::class); +}); From 3cdc2678093b6efb260ae434260dbb699396b99e Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 29 Dec 2025 23:08:52 +0100 Subject: [PATCH 14/53] chore: pint --- app/Models/Playlist.php | 1 - app/Services/Plugin/Parsers/IcalResponseParser.php | 2 +- database/factories/DevicePaletteFactory.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 68fbddb..b4daf5e 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Support\Carbon; class Playlist extends Model { diff --git a/app/Services/Plugin/Parsers/IcalResponseParser.php b/app/Services/Plugin/Parsers/IcalResponseParser.php index f87e71c..c8f2b58 100644 --- a/app/Services/Plugin/Parsers/IcalResponseParser.php +++ b/app/Services/Plugin/Parsers/IcalResponseParser.php @@ -34,7 +34,7 @@ class IcalResponseParser implements ResponseParser $filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool { $startDate = $this->asCarbon($event['DTSTART'] ?? null); - if (!$startDate instanceof \Carbon\Carbon) { + if (! $startDate instanceof Carbon) { return false; } diff --git a/database/factories/DevicePaletteFactory.php b/database/factories/DevicePaletteFactory.php index a672873..1d7ed2d 100644 --- a/database/factories/DevicePaletteFactory.php +++ b/database/factories/DevicePaletteFactory.php @@ -20,7 +20,7 @@ class DevicePaletteFactory extends Factory public function definition(): array { return [ - 'id' => 'test-' . $this->faker->unique()->slug(), + 'id' => 'test-'.$this->faker->unique()->slug(), 'name' => $this->faker->words(3, true), 'grays' => $this->faker->randomElement([2, 4, 16, 256]), 'colors' => $this->faker->optional()->passthrough([ From 50853728bcb785244bdf1748647e63c66141e6d4 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 09:43:05 +0100 Subject: [PATCH 15/53] refactor(#120): remove unnecessary js, improve cache handling --- .../views/livewire/catalog/index.blade.php | 150 +++------ .../views/livewire/catalog/trmnl.blade.php | 303 ++++++++---------- tests/Feature/Livewire/Catalog/IndexTest.php | 83 +++++ tests/Feature/Volt/CatalogTrmnlTest.php | 62 ++++ 4 files changed, 324 insertions(+), 274 deletions(-) diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 83a34fc..3a24b7e 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -1,20 +1,24 @@ filter(function ($plugin) use ($currentVersion) { // Check if Laravel compatibility is true - if (!Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { + if (! Arr::get($plugin, 'byos.byos_laravel.compatibility', false)) { return false; } @@ -81,8 +85,9 @@ class extends Component { }) ->sortBy('name') ->toArray(); - } catch (\Exception $e) { - Log::error('Failed to load catalog from URL: ' . $e->getMessage()); + } catch (Exception $e) { + Log::error('Failed to load catalog from URL: '.$e->getMessage()); + return []; } }); @@ -94,8 +99,9 @@ class extends Component { $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - if (!$plugin || !$plugin['zip_url']) { + if (! $plugin || ! $plugin['zip_url']) { $this->addError('installation', 'Plugin not found or no download URL available.'); + return; } @@ -113,8 +119,8 @@ class extends Component { $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); - } catch (\Exception $e) { - $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); + } catch (Exception $e) { + $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); } finally { $this->installingPlugin = ''; } @@ -124,32 +130,27 @@ class extends Component { { $plugin = collect($this->catalogPlugins)->firstWhere('id', $pluginId); - if (!$plugin) { + if (! $plugin) { $this->addError('preview', 'Plugin not found.'); + return; } $this->previewingPlugin = $pluginId; $this->previewData = $plugin; - - // Store scroll position for restoration later - $this->dispatch('store-scroll-position'); } public function closePreview(): void { $this->previewingPlugin = ''; $this->previewData = []; - - // Restore scroll position when returning to catalog - $this->dispatch('restore-scroll-position'); } }; ?>
@if(empty($catalogPlugins))
- + No plugins available Catalog is empty
@@ -165,25 +166,25 @@ class extends Component { @if($plugin['logo_url']) {{ $plugin['name'] }} @else -
- +
+
@endif
-

{{ $plugin['name'] }}

+ {{ $plugin['name'] }} @if ($plugin['github']) -

by {{ $plugin['github'] }}

+ by {{ $plugin['github'] }} @endif
@if($plugin['license']) - {{ $plugin['license'] }} + {{ $plugin['license'] }} @endif @if($plugin['repo_url']) - + @endif @@ -191,7 +192,7 @@ class extends Component {
@if($plugin['description']) -

{{ $plugin['description'] }}

+ {{ $plugin['description'] }} @endif
@@ -201,14 +202,16 @@ class extends Component { Install - - - Preview - - + @if($plugin['screenshot_url']) + + + Preview + + + @endif @@ -236,34 +239,20 @@ class extends Component {
- @if($previewData['screenshot_url']) -
- Preview of {{ $previewData['name'] }} -
- @elseif($previewData['logo_url']) -
- {{ $previewData['name'] }} logo -

No preview image available

-
- @else -
- -

No preview available

-
- @endif +
+ Preview of {{ $previewData['name'] }} +
@if($previewData['description']) -
-

Description

-

{{ $previewData['description'] }}

+
+ Description + {{ $previewData['description'] }}
@endif -
+
- -@script - -@endscript diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 1b5dd50..dd97e0e 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -1,20 +1,24 @@ 'newest', ]); - if (!$response->successful()) { - throw new \RuntimeException('Failed to fetch TRMNL recipes'); + if (! $response->successful()) { + throw new RuntimeException('Failed to fetch TRMNL recipes'); } $json = $response->json(); $data = $json['data'] ?? []; + return $this->mapRecipes($data); }); - } catch (\Throwable $e) { - Log::error('TRMNL catalog load error: ' . $e->getMessage()); + } catch (Throwable $e) { + Log::error('TRMNL catalog load error: '.$e->getMessage()); $this->recipes = []; } } @@ -62,23 +67,24 @@ class extends Component { { $this->isSearching = true; try { - $cacheKey = 'trmnl_recipes_search_' . md5($term); + $cacheKey = 'trmnl_recipes_search_'.md5($term); $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { $response = Http::get('https://usetrmnl.com/recipes.json', [ 'search' => $term, 'sort-by' => 'newest', ]); - if (!$response->successful()) { - throw new \RuntimeException('Failed to search TRMNL recipes'); + if (! $response->successful()) { + throw new RuntimeException('Failed to search TRMNL recipes'); } $json = $response->json(); $data = $json['data'] ?? []; + return $this->mapRecipes($data); }); - } catch (\Throwable $e) { - Log::error('TRMNL catalog search error: ' . $e->getMessage()); + } catch (Throwable $e) { + Log::error('TRMNL catalog search error: '.$e->getMessage()); $this->recipes = []; } finally { $this->isSearching = false; @@ -87,13 +93,14 @@ class extends Component { public function updatedSearch(): void { - $term = trim($this->search); + $term = mb_trim($this->search); if ($term === '') { $this->loadNewest(); + return; } - if (strlen($term) < 2) { + if (mb_strlen($term) < 2) { // Require at least 2 chars to avoid noisy calls return; } @@ -121,62 +128,78 @@ class extends Component { $this->dispatch('plugin-installed'); Flux::modal('import-from-trmnl-catalog')->close(); - } catch (\Exception $e) { - Log::error('Plugin installation failed: ' . $e->getMessage()); - $this->addError('installation', 'Error installing plugin: ' . $e->getMessage()); + } catch (Exception $e) { + Log::error('Plugin installation failed: '.$e->getMessage()); + $this->addError('installation', 'Error installing plugin: '.$e->getMessage()); } } public function previewRecipe(string $recipeId): void { - $recipe = collect($this->recipes)->firstWhere('id', $recipeId); - - if (!$recipe) { - $this->addError('preview', 'Recipe not found.'); - return; - } - $this->previewingRecipe = $recipeId; - $this->previewData = $recipe; - - // Store scroll position for restoration later - $this->dispatch('store-scroll-position'); - } - - public function closePreview(): void - { - $this->previewingRecipe = ''; $this->previewData = []; - // Restore scroll position when returning to catalog - $this->dispatch('restore-scroll-position'); + try { + $response = Http::timeout(10)->get("https://usetrmnl.com/recipes/{$recipeId}.json"); + + if ($response->successful()) { + $item = $response->json()['data'] ?? []; + $this->previewData = $this->mapRecipe($item); + } else { + // Fallback to searching for the specific recipe if single endpoint doesn't exist + $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ + 'search' => $recipeId, + ]); + + if ($response->successful()) { + $data = $response->json()['data'] ?? []; + $item = collect($data)->firstWhere('id', $recipeId); + if ($item) { + $this->previewData = $this->mapRecipe($item); + } + } + } + } catch (Throwable $e) { + Log::error('TRMNL catalog preview fetch error: '.$e->getMessage()); + } + + if (empty($this->previewData)) { + $this->previewData = collect($this->recipes)->firstWhere('id', $recipeId) ?? []; + } } /** - * @param array> $items + * @param array> $items * @return array> */ private function mapRecipes(array $items): array { return collect($items) - ->map(function (array $item) { - return [ - 'id' => $item['id'] ?? null, - 'name' => $item['name'] ?? 'Untitled', - 'icon_url' => $item['icon_url'] ?? null, - 'screenshot_url' => $item['screenshot_url'] ?? null, - 'author_bio' => is_array($item['author_bio'] ?? null) - ? strip_tags($item['author_bio']['description'] ?? null) - : null, - 'stats' => [ - 'installs' => data_get($item, 'stats.installs'), - 'forks' => data_get($item, 'stats.forks'), - ], - 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/' . $item['id']) : null, - ]; - }) + ->map(fn (array $item) => $this->mapRecipe($item)) ->toArray(); } + + /** + * @param array $item + * @return array + */ + private function mapRecipe(array $item): array + { + return [ + 'id' => $item['id'] ?? null, + 'name' => $item['name'] ?? 'Untitled', + 'icon_url' => $item['icon_url'] ?? null, + 'screenshot_url' => $item['screenshot_url'] ?? null, + 'author_bio' => is_array($item['author_bio'] ?? null) + ? strip_tags($item['author_bio']['description'] ?? null) + : null, + 'stats' => [ + 'installs' => data_get($item, 'stats.installs'), + 'forks' => data_get($item, 'stats.forks'), + ], + 'detail_url' => isset($item['id']) ? ('https://usetrmnl.com/recipes/'.$item['id']) : null, + ]; + } }; ?>
@@ -188,7 +211,7 @@ class extends Component { icon="magnifying-glass" />
- Newest + Newest
@error('installation') @@ -197,7 +220,7 @@ class extends Component { @if(empty($recipes))
- + No recipes found Try a different search term
@@ -211,8 +234,8 @@ class extends Component { @if($thumb) {{ $recipe['name'] }} @else -
- +
+
@endif @@ -221,12 +244,12 @@ class extends Component {
{{ $recipe['name'] }} @if(data_get($recipe, 'stats.installs')) - Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} + Installs: {{ data_get($recipe, 'stats.installs') }} · Forks: {{ data_get($recipe, 'stats.forks') }} @endif
@if($recipe['detail_url']) - + @endif @@ -246,7 +269,7 @@ class extends Component { @endif - @if($recipe['id']) + @if($recipe['id'] && ($recipe['screenshot_url'] ?? null)) - @if($previewingRecipe && !empty($previewData)) -
- Preview {{ $previewData['name'] ?? 'Recipe' }} +
+
+ + Fetching recipe details...
+
-
- @if($previewData['screenshot_url']) +
+ @if($previewingRecipe && !empty($previewData)) +
+ Preview {{ $previewData['name'] ?? 'Recipe' }} +
+ +
Preview of {{ $previewData['name'] }}
- @elseif($previewData['icon_url']) -
- {{ $previewData['name'] }} icon - No preview image available -
- @else -
- - No preview available -
- @endif - @if($previewData['author_bio']) -
-
- Description - {{ $previewData['author_bio'] }} + @if($previewData['author_bio']) +
+
+ Description + {{ $previewData['author_bio'] }} +
-
- @endif - - @if(data_get($previewData, 'stats.installs')) -
-
- Statistics - - Installs: {{ data_get($previewData, 'stats.installs') }} · - Forks: {{ data_get($previewData, 'stats.forks') }} - -
-
- @endif - -
- @if($previewData['detail_url']) - - View on TRMNL - @endif - - - Install Recipe - - + + @if(data_get($previewData, 'stats.installs')) +
+
+ Statistics + + Installs: {{ data_get($previewData, 'stats.installs') }} · + Forks: {{ data_get($previewData, 'stats.forks') }} + +
+
+ @endif + +
+ @if($previewData['detail_url']) + + View on TRMNL + + @endif + + + Install Recipe + + +
-
- @endif + @endif +
- -@script - -@endscript diff --git a/tests/Feature/Livewire/Catalog/IndexTest.php b/tests/Feature/Livewire/Catalog/IndexTest.php index 22ab4b6..1b2efba 100644 --- a/tests/Feature/Livewire/Catalog/IndexTest.php +++ b/tests/Feature/Livewire/Catalog/IndexTest.php @@ -65,6 +65,46 @@ it('loads plugins from catalog URL', function (): void { $component->assertSee('testuser'); $component->assertSee('A test plugin'); $component->assertSee('MIT'); + $component->assertSee('Preview'); +}); + +it('hides preview button when screenshot_url is missing', function (): void { + // Clear cache first to ensure fresh data + Cache::forget('catalog_plugins'); + + // Mock the HTTP response for the catalog URL without screenshot_url + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin Without Screenshot', + 'author' => ['name' => 'Test Author', 'github' => 'testuser'], + 'author_bio' => [ + 'description' => 'A test plugin', + ], + 'license' => 'MIT', + 'trmnlp' => [ + 'zip_url' => 'https://example.com/plugin.zip', + ], + 'byos' => [ + 'byos_laravel' => [ + 'compatibility' => true, + ], + ], + 'logo_url' => 'https://example.com/logo.png', + 'screenshot_url' => null, + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.index') + ->assertSee('Test Plugin Without Screenshot') + ->assertDontSeeHtml('variant="subtle" icon="eye"'); }); it('shows error when plugin not found', function (): void { @@ -114,3 +154,46 @@ it('shows error when zip_url is missing', function (): void { $component->assertHasErrors(); }); + +it('can preview a plugin', function (): void { + // Clear cache first to ensure fresh data + Cache::forget('catalog_plugins'); + + // Mock the HTTP response for the catalog URL + $catalogData = [ + 'test-plugin' => [ + 'name' => 'Test Plugin', + 'author' => ['name' => 'Test Author', 'github' => 'testuser'], + 'author_bio' => [ + 'description' => 'A test plugin description', + ], + 'license' => 'MIT', + 'trmnlp' => [ + 'zip_url' => 'https://example.com/plugin.zip', + ], + 'byos' => [ + 'byos_laravel' => [ + 'compatibility' => true, + ], + ], + 'logo_url' => 'https://example.com/logo.png', + 'screenshot_url' => 'https://example.com/screenshot.png', + ], + ]; + + $yamlContent = Yaml::dump($catalogData); + + Http::fake([ + config('app.catalog_url') => Http::response($yamlContent, 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.index') + ->assertSee('Test Plugin') + ->call('previewPlugin', 'test-plugin') + ->assertSet('previewingPlugin', 'test-plugin') + ->assertSet('previewData.name', 'Test Plugin') + ->assertSee('Preview Test Plugin') + ->assertSee('A test plugin description'); +}); diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index ba1b722..4c338df 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -28,9 +28,33 @@ it('loads newest TRMNL recipes on mount', function (): void { Volt::test('catalog.trmnl') ->assertSee('Weather Chum') ->assertSee('Install') + ->assertDontSeeHtml('variant="subtle" icon="eye"') ->assertSee('Installs: 10'); }); +it('shows preview button when screenshot_url is provided', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/screenshot.png', + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->assertSee('Preview'); +}); + it('searches TRMNL recipes when search term is provided', function (): void { Http::fake([ // First call (mount -> newest) @@ -152,3 +176,41 @@ it('shows error when plugin installation fails', function (): void { ->call('installPlugin', '123') ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid }); + +it('previews a recipe with async fetch', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/old.png', + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + 'usetrmnl.com/recipes/123.json' => Http::response([ + 'data' => [ + 'id' => 123, + 'name' => 'Weather Chum Updated', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => 'https://example.com/new.png', + 'author_bio' => ['description' => 'New bio'], + 'stats' => ['installs' => 11, 'forks' => 3], + ], + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->call('previewRecipe', '123') + ->assertSet('previewingRecipe', '123') + ->assertSet('previewData.name', 'Weather Chum Updated') + ->assertSet('previewData.screenshot_url', 'https://example.com/new.png') + ->assertSee('Preview Weather Chum Updated') + ->assertSee('New bio'); +}); From 3250bb0402730e3195d4415b6904b7371199651c Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 10:28:41 +0100 Subject: [PATCH 16/53] fix: install loading spinner not shown after catalog search --- resources/views/livewire/catalog/index.blade.php | 2 +- resources/views/livewire/catalog/trmnl.blade.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 3a24b7e..7257ab0 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -161,7 +161,7 @@ class extends Component @enderror @foreach($catalogPlugins as $plugin) -
+
@if($plugin['logo_url']) {{ $plugin['name'] }} diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index dd97e0e..8efd6b5 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -227,7 +227,7 @@ class extends Component @else
@foreach($recipes as $recipe) -
+
@php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) From 7f97114f6e521355bafce523dd867fc761a2b644 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 10:52:54 +0100 Subject: [PATCH 17/53] feat: add trmnl catalog paginator --- .../views/livewire/catalog/trmnl.blade.php | 77 +++++++++++++++---- tests/Feature/Volt/CatalogTrmnlTest.php | 70 +++++++++++++++++ 2 files changed, 134 insertions(+), 13 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 8efd6b5..9ecad1a 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -13,6 +13,10 @@ class extends Component { public array $recipes = []; + public int $page = 1; + + public bool $hasMore = false; + public string $search = ''; public bool $isSearching = false; @@ -43,23 +47,36 @@ class extends Component private function loadNewest(): void { try { - $this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () { + $cacheKey = 'trmnl_recipes_newest_page_'.$this->page; + $response = Cache::remember($cacheKey, 43200, function () { $response = Http::timeout(10)->get('https://usetrmnl.com/recipes.json', [ 'sort-by' => 'newest', + 'page' => $this->page, ]); if (! $response->successful()) { throw new RuntimeException('Failed to fetch TRMNL recipes'); } - $json = $response->json(); - $data = $json['data'] ?? []; - - return $this->mapRecipes($data); + return $response->json(); }); + + $data = $response['data'] ?? []; + $mapped = $this->mapRecipes($data); + + if ($this->page === 1) { + $this->recipes = $mapped; + } else { + $this->recipes = array_merge($this->recipes, $mapped); + } + + $this->hasMore = ! empty($response['next_page_url']); } catch (Throwable $e) { Log::error('TRMNL catalog load error: '.$e->getMessage()); - $this->recipes = []; + if ($this->page === 1) { + $this->recipes = []; + } + $this->hasMore = false; } } @@ -67,32 +84,57 @@ class extends Component { $this->isSearching = true; try { - $cacheKey = 'trmnl_recipes_search_'.md5($term); - $this->recipes = Cache::remember($cacheKey, 300, function () use ($term) { + $cacheKey = 'trmnl_recipes_search_'.md5($term).'_page_'.$this->page; + $response = Cache::remember($cacheKey, 300, function () use ($term) { $response = Http::get('https://usetrmnl.com/recipes.json', [ 'search' => $term, 'sort-by' => 'newest', + 'page' => $this->page, ]); if (! $response->successful()) { throw new RuntimeException('Failed to search TRMNL recipes'); } - $json = $response->json(); - $data = $json['data'] ?? []; - - return $this->mapRecipes($data); + return $response->json(); }); + + $data = $response['data'] ?? []; + $mapped = $this->mapRecipes($data); + + if ($this->page === 1) { + $this->recipes = $mapped; + } else { + $this->recipes = array_merge($this->recipes, $mapped); + } + + $this->hasMore = ! empty($response['next_page_url']); } catch (Throwable $e) { Log::error('TRMNL catalog search error: '.$e->getMessage()); - $this->recipes = []; + if ($this->page === 1) { + $this->recipes = []; + } + $this->hasMore = false; } finally { $this->isSearching = false; } } + public function loadMore(): void + { + $this->page++; + + $term = mb_trim($this->search); + if ($term === '' || mb_strlen($term) < 2) { + $this->loadNewest(); + } else { + $this->searchRecipes($term); + } + } + public function updatedSearch(): void { + $this->page = 1; $term = mb_trim($this->search); if ($term === '') { $this->loadNewest(); @@ -286,6 +328,15 @@ class extends Component
@endforeach
+ + @if($hasMore) +
+ + Load next page + Loading... + +
+ @endif @endif diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php index 4c338df..a80c63a 100644 --- a/tests/Feature/Volt/CatalogTrmnlTest.php +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -214,3 +214,73 @@ it('previews a recipe with async fetch', function (): void { ->assertSee('Preview Weather Chum Updated') ->assertSee('New bio'); }); + +it('supports pagination and loading more recipes', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::response([ + 'data' => [ + [ + 'id' => 1, + 'name' => 'Recipe Page 1', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 1, 'forks' => 0], + ], + ], + 'next_page_url' => '/recipes.json?page=2', + ], 200), + 'usetrmnl.com/recipes.json?sort-by=newest&page=2' => Http::response([ + 'data' => [ + [ + 'id' => 2, + 'name' => 'Recipe Page 2', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 2, 'forks' => 0], + ], + ], + 'next_page_url' => null, + ], 200), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Recipe Page 1') + ->assertDontSee('Recipe Page 2') + ->assertSee('Load next page') + ->call('loadMore') + ->assertSee('Recipe Page 1') + ->assertSee('Recipe Page 2') + ->assertDontSee('Load next page'); +}); + +it('resets pagination when search term changes', function (): void { + Http::fake([ + 'usetrmnl.com/recipes.json?sort-by=newest&page=1' => Http::sequence() + ->push([ + 'data' => [['id' => 1, 'name' => 'Initial 1']], + 'next_page_url' => '/recipes.json?page=2', + ]) + ->push([ + 'data' => [['id' => 3, 'name' => 'Initial 1 Again']], + 'next_page_url' => null, + ]), + 'usetrmnl.com/recipes.json?search=weather&sort-by=newest&page=1' => Http::response([ + 'data' => [['id' => 2, 'name' => 'Weather Result']], + 'next_page_url' => null, + ]), + ]); + + Livewire::withoutLazyLoading(); + + Volt::test('catalog.trmnl') + ->assertSee('Initial 1') + ->call('loadMore') + ->set('search', 'weather') + ->assertSee('Weather Result') + ->assertDontSee('Initial 1') + ->assertSet('page', 1); +}); From 265972ac24930cfaebb05d1a85344d5e2437ade7 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 30 Dec 2025 14:09:31 +0100 Subject: [PATCH 18/53] fix(#130): server error on faulty recipes --- app/Services/ImageGenerationService.php | 25 ++- .../views/default-screens/error.blade.php | 23 +++ routes/api.php | 24 ++- tests/Feature/Api/DeviceEndpointsTest.php | 161 ++++++++++++++++++ 4 files changed, 221 insertions(+), 12 deletions(-) create mode 100644 resources/views/default-screens/error.blade.php diff --git a/app/Services/ImageGenerationService.php b/app/Services/ImageGenerationService.php index cdfc9d2..fcd5f12 100644 --- a/app/Services/ImageGenerationService.php +++ b/app/Services/ImageGenerationService.php @@ -311,7 +311,7 @@ class ImageGenerationService public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep'])) { + if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { return null; } @@ -345,10 +345,10 @@ class ImageGenerationService /** * Generate a default screen image from Blade template */ - public static function generateDefaultScreenImage(Device $device, string $imageType): string + public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string { // Validate image type - if (! in_array($imageType, ['setup-logo', 'sleep'])) { + if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) { throw new InvalidArgumentException("Invalid image type: {$imageType}"); } @@ -365,7 +365,7 @@ class ImageGenerationService $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); // Generate HTML from Blade template - $html = self::generateDefaultScreenHtml($device, $imageType); + $html = self::generateDefaultScreenHtml($device, $imageType, $pluginName); // Create custom Browsershot instance if using AWS Lambda $browsershotInstance = null; @@ -445,12 +445,13 @@ class ImageGenerationService /** * Generate HTML from Blade template for default screens */ - private static function generateDefaultScreenHtml(Device $device, string $imageType): string + private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string { // Map image type to template name $templateName = match ($imageType) { 'setup-logo' => 'default-screens.setup', 'sleep' => 'default-screens.sleep', + 'error' => 'default-screens.error', default => throw new InvalidArgumentException("Invalid image type: {$imageType}") }; @@ -461,14 +462,22 @@ class ImageGenerationService $scaleLevel = $device->scaleLevel(); $darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode - // Render the Blade template - return view($templateName, [ + // Build view data + $viewData = [ 'noBleed' => false, 'darkMode' => $darkMode, 'deviceVariant' => $deviceVariant, 'deviceOrientation' => $deviceOrientation, 'colorDepth' => $colorDepth, 'scaleLevel' => $scaleLevel, - ])->render(); + ]; + + // Add plugin name for error screens + if ($imageType === 'error' && $pluginName !== null) { + $viewData['pluginName'] = $pluginName; + } + + // Render the Blade template + return view($templateName, $viewData)->render(); } } diff --git a/resources/views/default-screens/error.blade.php b/resources/views/default-screens/error.blade.php new file mode 100644 index 0000000..be8063a --- /dev/null +++ b/resources/views/default-screens/error.blade.php @@ -0,0 +1,23 @@ +@props([ + 'noBleed' => false, + 'darkMode' => false, + 'deviceVariant' => 'og', + 'deviceOrientation' => null, + 'colorDepth' => '1bit', + 'scaleLevel' => null, + 'pluginName' => 'Recipe', +]) + + + + + + Error on {{ $pluginName }} + Unable to render content. Please check server logs. + + + + + diff --git a/routes/api.php b/routes/api.php index d1dbcac..b1d08b4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -95,9 +95,16 @@ Route::get('/display', function (Request $request) { // Check and update stale data if needed if ($plugin->isDataStale() || $plugin->current_image === null) { $plugin->updateDataPayload(); - $markup = $plugin->render(device: $device); + try { + $markup = $plugin->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); + GenerateScreenJob::dispatchSync($device->id, $plugin->id, $markup); + } catch (Exception $e) { + Log::error("Failed to render plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage()); + // Generate error display + $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $plugin->name); + $device->update(['current_screen_image' => $errorImageUuid]); + } } $plugin->refresh(); @@ -120,8 +127,17 @@ Route::get('/display', function (Request $request) { } } - $markup = $playlistItem->render(device: $device); - GenerateScreenJob::dispatchSync($device->id, null, $markup); + try { + $markup = $playlistItem->render(device: $device); + GenerateScreenJob::dispatchSync($device->id, null, $markup); + } catch (Exception $e) { + Log::error("Failed to render mashup playlist item {$playlistItem->id}: ".$e->getMessage()); + // For mashups, show error for the first plugin or a generic error + $firstPlugin = $plugins->first(); + $pluginName = $firstPlugin ? $firstPlugin->name : 'Recipe'; + $errorImageUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', $pluginName); + $device->update(['current_screen_image' => $errorImageUuid]); + } $device->refresh(); diff --git a/tests/Feature/Api/DeviceEndpointsTest.php b/tests/Feature/Api/DeviceEndpointsTest.php index aff6758..2925a5e 100644 --- a/tests/Feature/Api/DeviceEndpointsTest.php +++ b/tests/Feature/Api/DeviceEndpointsTest.php @@ -7,6 +7,7 @@ use App\Models\Playlist; use App\Models\PlaylistItem; use App\Models\Plugin; use App\Models\User; +use App\Services\ImageGenerationService; use Bnussbau\TrmnlPipeline\TrmnlPipeline; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; @@ -1023,3 +1024,163 @@ test('screens endpoint matches MAC address case-insensitively', function (): voi $response->assertOk(); Queue::assertPushed(GenerateScreenJob::class); }); + +test('display endpoint handles plugin rendering errors gracefully', function (): void { + TrmnlPipeline::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create a plugin with Blade markup that will cause an exception when accessing data[0] + // when data is not an array or doesn't have index 0 + $plugin = Plugin::factory()->create([ + 'name' => 'Broken Recipe', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access + 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail if data[0] doesn't exist + 'data_payload' => ['error' => 'Failed to fetch data'], // Not a list, so data[0] will fail + 'data_payload_updated_at' => now()->subMinutes(2), // Make it stale + 'current_image' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'test_playlist', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + PlaylistItem::factory()->create([ + 'playlist_id' => $playlist->id, + 'plugin_id' => $plugin->id, + 'order' => 1, + 'is_active' => true, + 'last_displayed_at' => null, + ]); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify error screen was generated and set on device + $device->refresh(); + expect($device->current_screen_image)->not->toBeNull(); + + // Verify the error image exists + $errorImagePath = Storage::disk('public')->path("images/generated/{$device->current_screen_image}.png"); + // The TrmnlPipeline is faked, so we just verify the UUID was set + expect($device->current_screen_image)->toBeString(); +}); + +test('display endpoint handles mashup rendering errors gracefully', function (): void { + TrmnlPipeline::fake(); + + $device = Device::factory()->create([ + 'mac_address' => '00:11:22:33:44:55', + 'api_key' => 'test-api-key', + 'proxy_cloud' => false, + ]); + + // Create plugins for mashup, one with invalid markup + $plugin1 = Plugin::factory()->create([ + 'name' => 'Working Plugin', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'render_markup_view' => 'trmnl', + 'data_payload_updated_at' => now()->subMinutes(2), + 'current_image' => null, + ]); + + $plugin2 = Plugin::factory()->create([ + 'name' => 'Broken Plugin', + 'data_strategy' => 'polling', + 'polling_url' => null, + 'data_stale_minutes' => 1, + 'markup_language' => 'blade', // Use Blade which will throw exception on invalid array access + 'render_markup' => '
{{ $data[0]["invalid"] }}
', // This will fail + 'data_payload' => ['error' => 'Failed to fetch data'], + 'data_payload_updated_at' => now()->subMinutes(2), + 'current_image' => null, + ]); + + $playlist = Playlist::factory()->create([ + 'device_id' => $device->id, + 'name' => 'test_playlist', + 'is_active' => true, + 'weekdays' => null, + 'active_from' => null, + 'active_until' => null, + ]); + + // Create mashup playlist item + $playlistItem = PlaylistItem::createMashup( + $playlist, + '1Lx1R', + [$plugin1->id, $plugin2->id], + 'Test Mashup', + 1 + ); + + $response = $this->withHeaders([ + 'id' => $device->mac_address, + 'access-token' => $device->api_key, + 'rssi' => -70, + 'battery_voltage' => 3.8, + 'fw-version' => '1.0.0', + ])->get('/api/display'); + + $response->assertOk(); + + // Verify error screen was generated and set on device + $device->refresh(); + expect($device->current_screen_image)->not->toBeNull(); + + // Verify the error image UUID was set + expect($device->current_screen_image)->toBeString(); +}); + +test('generateDefaultScreenImage creates error screen with plugin name', function (): void { + TrmnlPipeline::fake(); + Storage::fake('public'); + Storage::disk('public')->makeDirectory('/images/generated'); + + $device = Device::factory()->create(); + + $errorUuid = ImageGenerationService::generateDefaultScreenImage($device, 'error', 'Test Recipe Name'); + + expect($errorUuid)->not->toBeEmpty(); + + // Verify the error image path would be created + $errorPath = "images/generated/{$errorUuid}.png"; + // Since TrmnlPipeline is faked, we just verify the UUID was generated + expect($errorUuid)->toBeString(); +}); + +test('generateDefaultScreenImage throws exception for invalid error image type', function (): void { + $device = Device::factory()->create(); + + expect(fn (): string => ImageGenerationService::generateDefaultScreenImage($device, 'invalid-error-type')) + ->toThrow(InvalidArgumentException::class); +}); + +test('getDeviceSpecificDefaultImage returns null for error type when no device-specific image exists', function (): void { + $device = new Device(); + $device->deviceModel = null; + + $result = ImageGenerationService::getDeviceSpecificDefaultImage($device, 'error'); + expect($result)->toBeNull(); +}); From 4451361f1547e577e2265323ffb238f338afc43b Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 31 Dec 2025 10:20:03 +0100 Subject: [PATCH 19/53] chore: update dependencies --- composer.lock | 164 +++++++++++++++++++++++++------------------------- 1 file changed, 82 insertions(+), 82 deletions(-) diff --git a/composer.lock b/composer.lock index 1b578bf..199fa86 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.4", + "version": "3.369.5", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "2aa1ef195e90140d733382e4341732ce113024f5" + "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/2aa1ef195e90140d733382e4341732ce113024f5", - "reference": "2aa1ef195e90140d733382e4341732ce113024f5", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", + "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", "shasum": "" }, "require": { @@ -153,9 +153,9 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.369.4" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.5" }, - "time": "2025-12-29T19:07:47+00:00" + "time": "2025-12-30T19:07:16+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -3142,16 +3142,16 @@ }, { "name": "monolog/monolog", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6" + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/10d85740180ecba7896c87e06a166e0c95a0e3b6", - "reference": "10d85740180ecba7896c87e06a166e0c95a0e3b6", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0", + "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0", "shasum": "" }, "require": { @@ -3169,7 +3169,7 @@ "graylog2/gelf-php": "^1.4.2 || ^2.0", "guzzlehttp/guzzle": "^7.4.5", "guzzlehttp/psr7": "^2.2", - "mongodb/mongodb": "^1.8", + "mongodb/mongodb": "^1.8 || ^2.0", "php-amqplib/php-amqplib": "~2.4 || ^3", "php-console/php-console": "^3.1.8", "phpstan/phpstan": "^2", @@ -3229,7 +3229,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/3.9.0" + "source": "https://github.com/Seldaek/monolog/tree/3.10.0" }, "funding": [ { @@ -3241,7 +3241,7 @@ "type": "tidelift" } ], - "time": "2025-03-24T10:02:05+00:00" + "time": "2026-01-02T08:56:05+00:00" }, { "name": "mtdowling/jmespath.php", @@ -5026,16 +5026,16 @@ }, { "name": "symfony/console", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", - "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", + "reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "shasum": "" }, "require": { @@ -5100,7 +5100,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.1" + "source": "https://github.com/symfony/console/tree/v7.4.3" }, "funding": [ { @@ -5120,7 +5120,7 @@ "type": "tidelift" } ], - "time": "2025-12-05T15:23:39+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/css-selector", @@ -5573,16 +5573,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" + "reference": "fffe05569336549b20a1be64250b40516d6e8d06" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", - "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", + "url": "https://api.github.com/repos/symfony/finder/zipball/fffe05569336549b20a1be64250b40516d6e8d06", + "reference": "fffe05569336549b20a1be64250b40516d6e8d06", "shasum": "" }, "require": { @@ -5617,7 +5617,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.0" + "source": "https://github.com/symfony/finder/tree/v7.4.3" }, "funding": [ { @@ -5637,20 +5637,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T05:42:40+00:00" + "time": "2025-12-23T14:50:43+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.1", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", - "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/a70c745d4cea48dbd609f4075e5f5cbce453bd52", + "reference": "a70c745d4cea48dbd609f4075e5f5cbce453bd52", "shasum": "" }, "require": { @@ -5699,7 +5699,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.3" }, "funding": [ { @@ -5719,20 +5719,20 @@ "type": "tidelift" } ], - "time": "2025-12-07T11:13:10+00:00" + "time": "2025-12-23T14:23:49+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.2", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" + "reference": "885211d4bed3f857b8c964011923528a55702aa5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", - "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/885211d4bed3f857b8c964011923528a55702aa5", + "reference": "885211d4bed3f857b8c964011923528a55702aa5", "shasum": "" }, "require": { @@ -5818,7 +5818,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.3" }, "funding": [ { @@ -5838,20 +5838,20 @@ "type": "tidelift" } ], - "time": "2025-12-08T07:43:37+00:00" + "time": "2025-12-31T08:43:57+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd" + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", - "reference": "a3d9eea8cfa467ece41f0f54ba28185d74bd53fd", + "url": "https://api.github.com/repos/symfony/mailer/zipball/e472d35e230108231ccb7f51eb6b2100cac02ee4", + "reference": "e472d35e230108231ccb7f51eb6b2100cac02ee4", "shasum": "" }, "require": { @@ -5902,7 +5902,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.0" + "source": "https://github.com/symfony/mailer/tree/v7.4.3" }, "funding": [ { @@ -5922,7 +5922,7 @@ "type": "tidelift" } ], - "time": "2025-11-21T15:26:00+00:00" + "time": "2025-12-16T08:02:06+00:00" }, { "name": "symfony/mime", @@ -6844,16 +6844,16 @@ }, { "name": "symfony/process", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8" + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", - "reference": "7ca8dc2d0dcf4882658313aba8be5d9fd01026c8", + "url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", + "reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "shasum": "" }, "require": { @@ -6885,7 +6885,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.0" + "source": "https://github.com/symfony/process/tree/v7.4.3" }, "funding": [ { @@ -6905,20 +6905,20 @@ "type": "tidelift" } ], - "time": "2025-10-16T11:21:06+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/routing", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "4720254cb2644a0b876233d258a32bf017330db7" + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/4720254cb2644a0b876233d258a32bf017330db7", - "reference": "4720254cb2644a0b876233d258a32bf017330db7", + "url": "https://api.github.com/repos/symfony/routing/zipball/5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", + "reference": "5d3fd7adf8896c2fdb54e2f0f35b1bcbd9e45090", "shasum": "" }, "require": { @@ -6970,7 +6970,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.0" + "source": "https://github.com/symfony/routing/tree/v7.4.3" }, "funding": [ { @@ -6990,7 +6990,7 @@ "type": "tidelift" } ], - "time": "2025-11-27T13:27:24+00:00" + "time": "2025-12-19T10:00:43+00:00" }, { "name": "symfony/service-contracts", @@ -7171,16 +7171,16 @@ }, { "name": "symfony/translation", - "version": "v8.0.1", + "version": "v8.0.3", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca" + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/770e3b8b0ba8360958abedcabacd4203467333ca", - "reference": "770e3b8b0ba8360958abedcabacd4203467333ca", + "url": "https://api.github.com/repos/symfony/translation/zipball/60a8f11f0e15c48f2cc47c4da53873bb5b62135d", + "reference": "60a8f11f0e15c48f2cc47c4da53873bb5b62135d", "shasum": "" }, "require": { @@ -7240,7 +7240,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.1" + "source": "https://github.com/symfony/translation/tree/v8.0.3" }, "funding": [ { @@ -7260,7 +7260,7 @@ "type": "tidelift" } ], - "time": "2025-12-01T09:13:36+00:00" + "time": "2025-12-21T10:59:45+00:00" }, { "name": "symfony/translation-contracts", @@ -7424,16 +7424,16 @@ }, { "name": "symfony/var-dumper", - "version": "v7.4.0", + "version": "v7.4.3", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" + "reference": "7e99bebcb3f90d8721890f2963463280848cba92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", + "reference": "7e99bebcb3f90d8721890f2963463280848cba92", "shasum": "" }, "require": { @@ -7487,7 +7487,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.3" }, "funding": [ { @@ -7507,7 +7507,7 @@ "type": "tidelift" } ], - "time": "2025-10-27T20:36:44+00:00" + "time": "2025-12-18T07:04:31+00:00" }, { "name": "symfony/var-exporter", @@ -9198,16 +9198,16 @@ }, { "name": "pestphp/pest", - "version": "v4.2.0", + "version": "v4.3.0", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd" + "reference": "e86bec3e68f1874c112ca782fb9db1333f3fe7ab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", - "reference": "7c43c1c5834435ed9f4ad635e9cb1f0064f876bd", + "url": "https://api.github.com/repos/pestphp/pest/zipball/e86bec3e68f1874c112ca782fb9db1333f3fe7ab", + "reference": "e86bec3e68f1874c112ca782fb9db1333f3fe7ab", "shasum": "" }, "require": { @@ -9219,12 +9219,12 @@ "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.2.1", "php": "^8.3.0", - "phpunit/phpunit": "^12.5.3", + "phpunit/phpunit": "^12.5.4", "symfony/process": "^7.4.0|^8.0.0" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.5.3", + "phpunit/phpunit": ">12.5.4", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -9232,7 +9232,7 @@ "pestphp/pest-dev-tools": "^4.0.0", "pestphp/pest-plugin-browser": "^4.1.1", "pestphp/pest-plugin-type-coverage": "^4.0.3", - "psy/psysh": "^0.12.17" + "psy/psysh": "^0.12.18" }, "bin": [ "bin/pest" @@ -9298,7 +9298,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.2.0" + "source": "https://github.com/pestphp/pest/tree/v4.3.0" }, "funding": [ { @@ -9310,7 +9310,7 @@ "type": "github" } ], - "time": "2025-12-15T11:49:28+00:00" + "time": "2025-12-30T19:48:33+00:00" }, { "name": "pestphp/pest-plugin", @@ -10456,16 +10456,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.3", + "version": "12.5.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e" + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6dc2e076d09960efbb0c1272aa9bc156fc80955e", - "reference": "6dc2e076d09960efbb0c1272aa9bc156fc80955e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4ba0e923f9d3fc655de22f9547c01d15a41fc93a", + "reference": "4ba0e923f9d3fc655de22f9547c01d15a41fc93a", "shasum": "" }, "require": { @@ -10533,7 +10533,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.3" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.4" }, "funding": [ { @@ -10557,7 +10557,7 @@ "type": "tidelift" } ], - "time": "2025-12-11T08:52:59+00:00" + "time": "2025-12-15T06:05:34+00:00" }, { "name": "rector/rector", From 838b4fd33b223be1e6991f934a1b5e36dfbd38eb Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 2 Jan 2026 22:20:42 +0100 Subject: [PATCH 20/53] feat: bump to Design Framework 2.1 --- composer.json | 3 ++- composer.lock | 26 +++++++++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 2281415..f801679 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "keywords": [ "trmnl", "trmnl-server", + "trmnl-byos", "laravel" ], "license": "MIT", @@ -14,7 +15,7 @@ "ext-imagick": "*", "ext-simplexml": "*", "ext-zip": "*", - "bnussbau/laravel-trmnl-blade": "2.0.*", + "bnussbau/laravel-trmnl-blade": "2.1.*", "bnussbau/trmnl-pipeline-php": "^0.6.0", "keepsuit/laravel-liquid": "^0.5.2", "laravel/framework": "^12.1", diff --git a/composer.lock b/composer.lock index 199fa86..b9e0495 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "3e4c22c016c04e49512b5fcd20983baa", + "content-hash": "4d958d48655a5ad9e3de6b4a9fb52b0a", "packages": [ { "name": "aws/aws-crt-php", @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.369.5", + "version": "3.369.6", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe" + "reference": "b1e1846a4b6593b6916764d86fc0890a31727370" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", - "reference": "7cb482768899d510e8bcb3e9ef685d2ed0afcbfe", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/b1e1846a4b6593b6916764d86fc0890a31727370", + "reference": "b1e1846a4b6593b6916764d86fc0890a31727370", "shasum": "" }, "require": { @@ -153,22 +153,22 @@ "support": { "forum": "https://github.com/aws/aws-sdk-php/discussions", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.369.5" + "source": "https://github.com/aws/aws-sdk-php/tree/3.369.6" }, - "time": "2025-12-30T19:07:16+00:00" + "time": "2026-01-02T19:09:23+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", - "version": "2.0.1", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/bnussbau/laravel-trmnl-blade.git", - "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e" + "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/59343cfa9c41c7c7f9285b366584cde92bf1294e", - "reference": "59343cfa9c41c7c7f9285b366584cde92bf1294e", + "url": "https://api.github.com/repos/bnussbau/laravel-trmnl-blade/zipball/1e1cabfead00118d7a80c86ac6108aece2989bc7", + "reference": "1e1cabfead00118d7a80c86ac6108aece2989bc7", "shasum": "" }, "require": { @@ -223,7 +223,7 @@ ], "support": { "issues": "https://github.com/bnussbau/laravel-trmnl-blade/issues", - "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.0.1" + "source": "https://github.com/bnussbau/laravel-trmnl-blade/tree/2.1.0" }, "funding": [ { @@ -239,7 +239,7 @@ "type": "github" } ], - "time": "2025-09-22T12:12:00+00:00" + "time": "2026-01-02T20:38:51+00:00" }, { "name": "bnussbau/trmnl-pipeline-php", From 9019561bb3b7057e8bbffd4979255e7e8db4ead7 Mon Sep 17 00:00:00 2001 From: jerremyng Date: Sat, 3 Jan 2026 17:25:37 +0000 Subject: [PATCH 21/53] add zip dependencies to dev-container dockerfiles --- .devcontainer/cli/Dockerfile | 5 +++-- .devcontainer/fpm/Dockerfile | 5 +++-- package-lock.json | 13 ++----------- 3 files changed, 8 insertions(+), 15 deletions(-) diff --git a/.devcontainer/cli/Dockerfile b/.devcontainer/cli/Dockerfile index 0317097..ab13330 100644 --- a/.devcontainer/cli/Dockerfile +++ b/.devcontainer/cli/Dockerfile @@ -9,7 +9,8 @@ RUN apk add --no-cache composer # Add Chromium and Image Magick for puppeteer. RUN apk add --no-cache \ imagemagick-dev \ - chromium + chromium \ + libzip-dev ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -19,7 +20,7 @@ RUN chmod 777 /usr/src/php/ext/imagick RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 # Install PHP extensions -RUN docker-php-ext-install imagick +RUN docker-php-ext-install imagick zip # Composer uses its php binary, but we want it to use the container's one RUN rm -f /usr/bin/php84 diff --git a/.devcontainer/fpm/Dockerfile b/.devcontainer/fpm/Dockerfile index 8c585c8..3e658b6 100644 --- a/.devcontainer/fpm/Dockerfile +++ b/.devcontainer/fpm/Dockerfile @@ -14,7 +14,8 @@ RUN apk add --no-cache \ nodejs \ npm \ imagemagick-dev \ - chromium + chromium \ + libzip-dev ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_DOCKER=1 @@ -24,7 +25,7 @@ RUN chmod 777 /usr/src/php/ext/imagick RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1 # Install PHP extensions -RUN docker-php-ext-install imagick +RUN docker-php-ext-install imagick zip RUN rm -f /usr/bin/php84 RUN ln -s /usr/local/bin/php /usr/bin/php84 diff --git a/package-lock.json b/package-lock.json index 8411d6a..e722432 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "laravel-trmnl-server", + "name": "laravel", "lockfileVersion": 3, "requires": true, "packages": { @@ -156,7 +156,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -193,7 +192,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -215,7 +213,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.8.tgz", "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -718,7 +715,6 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -1614,7 +1610,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1898,8 +1893,7 @@ "version": "0.0.1521046", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -2951,7 +2945,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2978,7 +2971,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3429,7 +3421,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.6.tgz", "integrity": "sha512-tI2l/nFHC5rLh7+5+o7QjKjSR04ivXDF4jcgV0f/bTQ+OJiITy5S6gaynVsEM+7RqzufMnVbIon6Sr5x1SDYaQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", From 46e792bc6d79d3acc2bb62471aef5615153fe5ca Mon Sep 17 00:00:00 2001 From: jerremyng Date: Sun, 4 Jan 2026 08:15:09 +0000 Subject: [PATCH 22/53] add HTML rendering on config modal with tests Models/Plugin will now sanitize "description" and "help text" before loading. This allows HTML from these fields to be rendered safely. Sanitization is done using Purify library for completeness (new dependency). A test suite of simple xss attacks is also added. --- app/Models/Plugin.php | 24 ++ composer.json | 1 + composer.lock | 129 ++++++- resources/css/app.css | 4 + .../views/livewire/plugins/recipe.blade.php | 356 ++++++++++-------- tests/Unit/Models/PluginTest.php | 114 +++++- 6 files changed, 470 insertions(+), 158 deletions(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 9132d6c..6f5d88b 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -62,6 +62,11 @@ class Plugin extends Model $model->current_image = null; } }); + + // Sanitize configuration template on save + static::saving(function ($model): void { + $model->sanitizeTemplate(); + }); } public function user() @@ -69,6 +74,25 @@ class Plugin extends Model return $this->belongsTo(User::class); } + // sanitize configuration template descriptions and help texts (since they allow HTML rendering) + protected function sanitizeTemplate(): void + { + $template = $this->configuration_template; + + if (isset($template['custom_fields']) && is_array($template['custom_fields'])) { + foreach ($template['custom_fields'] as &$field) { + if (isset($field['description'])) { + $field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']); + } + if (isset($field['help_text'])) { + $field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']); + } + } + + $this->configuration_template = $template; + } + } + public function hasMissingRequiredConfigurationFields(): bool { if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) { diff --git a/composer.json b/composer.json index f801679..0ced4da 100644 --- a/composer.json +++ b/composer.json @@ -26,6 +26,7 @@ "livewire/volt": "^1.7", "om/icalparser": "^3.2", "spatie/browsershot": "^5.0", + "stevebauman/purify": "^6.3", "symfony/yaml": "^7.3", "wnx/sidecar-browsershot": "^2.6" }, diff --git a/composer.lock b/composer.lock index b9e0495..9767a0d 100644 --- a/composer.lock +++ b/composer.lock @@ -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": "4d958d48655a5ad9e3de6b4a9fb52b0a", + "content-hash": "25c2a1a4a2f2594adefe25ddb6a072fb", "packages": [ { "name": "aws/aws-crt-php", @@ -814,6 +814,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.11.1", @@ -4947,6 +5008,72 @@ ], "time": "2025-01-13T13:04:43+00:00" }, + { + "name": "stevebauman/purify", + "version": "v6.3.1", + "source": { + "type": "git", + "url": "https://github.com/stevebauman/purify.git", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "reference": "3acb5e77904f420ce8aad8fa1c7f394e82daa500", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.17", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0|^12.0", + "php": ">=7.4" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.5.3" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Purify": "Stevebauman\\Purify\\Facades\\Purify" + }, + "providers": [ + "Stevebauman\\Purify\\PurifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Stevebauman\\Purify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" + } + ], + "description": "An HTML Purifier / Sanitizer for Laravel", + "keywords": [ + "Purifier", + "clean", + "cleaner", + "html", + "laravel", + "purification", + "purify" + ], + "support": { + "issues": "https://github.com/stevebauman/purify/issues", + "source": "https://github.com/stevebauman/purify/tree/v6.3.1" + }, + "time": "2025-05-21T16:53:09+00:00" + }, { "name": "symfony/clock", "version": "v8.0.0", diff --git a/resources/css/app.css b/resources/css/app.css index 46b9ca1..30cb7a1 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -59,6 +59,10 @@ @apply !mb-0 !leading-tight; } +[data-flux-description] a { + @apply text-accent underline hover:opacity-80; +} + input:focus[data-flux-control], textarea:focus[data-flux-control], select:focus[data-flux-control] { diff --git a/resources/views/livewire/plugins/recipe.blade.php b/resources/views/livewire/plugins/recipe.blade.php index 4be96cc..e8ab799 100644 --- a/resources/views/livewire/plugins/recipe.blade.php +++ b/resources/views/livewire/plugins/recipe.blade.php @@ -264,7 +264,7 @@ new class extends Component { $fieldKey = $field['keyname']; if (isset($this->configuration[$fieldKey])) { $value = $this->configuration[$fieldKey]; - + // For code fields, if the value is a JSON string and the original was an array, decode it if ($field['field_type'] === 'code' && is_string($value)) { $decoded = json_decode($value, true); @@ -274,7 +274,7 @@ new class extends Component { $value = $decoded; } } - + $configurationValues[$fieldKey] = $value; } } @@ -639,7 +639,14 @@ HTML; @php $fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name']; $rawValue = $configuration[$fieldKey] ?? ($field['default'] ?? ''); - + # These are sanitized at PluginImportService when imported, safe to render HTML + $safeDescription = $field['description'] ?? ''; + $safeHelp = $field['help_text'] ?? ''; + + //Important: Sanitize with Purify to prevent XSS attacks + // $safeDescription = Stevebauman\Purify\Facades\Purify::clean($field['description'] ?? ''); + // $safeHelp = Stevebauman\Purify\Facades\Purify::clean($field['help_text'] ?? ''); + // For code fields, if the value is an array, JSON encode it if ($field['field_type'] === 'code' && is_array($rawValue)) { $currentValue = json_encode($rawValue, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -657,176 +664,211 @@ HTML; @endif @if($field['field_type'] === 'string' || $field['field_type'] === 'url') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'text') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'code') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'password') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'copyable') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'time_zone') - - - @foreach(timezone_identifiers_list() as $timezone) - - @endforeach - + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @foreach(timezone_identifiers_list() as $timezone) + + @endforeach + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'number') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'boolean') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'date') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'time') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'select') @if(isset($field['multiple']) && $field['multiple'] === true) - - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) - @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach - @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - - @endif - @endforeach - @endif - + + {{ $field['name'] }} + {!! $safeDescription !!} + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + @else + + {{ $field['name'] }} + {!! $safeDescription !!} + + + @if(isset($field['options']) && is_array($field['options'])) + @foreach($field['options'] as $option) + @if(is_array($option)) + @foreach($option as $label => $value) + + @endforeach + @else + @php + $key = mb_strtolower(str_replace(' ', '_', $option)); + @endphp + + @endif + @endforeach + @endif + + {!! $safeHelp !!} + + @endif + + @elseif($field['field_type'] === 'xhrSelect') + + {{ $field['name'] }} + {!! $safeDescription !!} - @if(isset($field['options']) && is_array($field['options'])) - @foreach($field['options'] as $option) + @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) + @foreach($xhrSelectOptions[$fieldKey] as $option) @if(is_array($option)) - @foreach($option as $label => $value) - - @endforeach + @if(isset($option['id']) && isset($option['name'])) + {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} + + @else + {{-- xhrSelect format: { 'Braves' => 123 } --}} + @foreach($option as $label => $value) + + @endforeach + @endif @else - @php - $key = mb_strtolower(str_replace(' ', '_', $option)); - @endphp - + @endif @endforeach @endif - @endif - @elseif($field['field_type'] === 'xhrSelect') - - - @if(isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey])) - @foreach($xhrSelectOptions[$fieldKey] as $option) - @if(is_array($option)) - @if(isset($option['id']) && isset($option['name'])) - {{-- xhrSelectSearch format: { 'id' => 'db-456', 'name' => 'Team Goals' } --}} - - @else - {{-- xhrSelect format: { 'Braves' => 123 } --}} - @foreach($option as $label => $value) - - @endforeach - @endif - @else - - @endif - @endforeach - @endif - + {!! $safeHelp !!} + + @elseif($field['field_type'] === 'xhrSelectSearch')
{{ $field['name'] }} - {{ $field['description'] ?? '' }} + {!! $safeDescription !!} - {{ $field['help_text'] ?? '' }} + {!! $safeHelp !!} @if((isset($xhrSelectOptions[$fieldKey]) && is_array($xhrSelectOptions[$fieldKey]) && count($xhrSelectOptions[$fieldKey]) > 0) || !empty($currentValue)) @elseif($field['field_type'] === 'multi_string') - + + {{ $field['name'] }} + {!! $safeDescription !!} + + {!! $safeHelp !!} + @else Field type "{{ $field['field_type'] }}" not yet supported @endif diff --git a/tests/Unit/Models/PluginTest.php b/tests/Unit/Models/PluginTest.php index cf8ea97..49d3f2e 100644 --- a/tests/Unit/Models/PluginTest.php +++ b/tests/Unit/Models/PluginTest.php @@ -4,8 +4,12 @@ use App\Models\Plugin; use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Facades\Http; +use Livewire\Volt\Volt; +use Illuminate\Foundation\Testing\RefreshDatabase; -uses(Illuminate\Foundation\Testing\RefreshDatabase::class); +use Tests\TestCase; + +uses(TestCase::class,RefreshDatabase::class); test('plugin has required attributes', function (): void { $plugin = Plugin::factory()->create([ @@ -679,3 +683,111 @@ test('plugin render includes utc_offset and time_zone_iana in trmnl.user context ->toContain('America/Chicago') ->and($rendered)->toMatch('/\|-?\d+/'); // Should contain a pipe followed by a number (offset in seconds) }); + + +/** + * Plugin security: XSS Payload Dataset + * [Input, Expected to See, Dangerous parts that must be Missing] + */ +dataset('xss_vectors', [ + 'standard_script' => [ + 'Safe ', + 'Safe', + ['', + 'Unclosed tag', + ['', - 'Safe', - ['', - 'Unclosed tag', - ['', 'Safe ', '', 'Safe ', '', 'Safe ', ' + + + + + + +
+
+ + + +
+ + + \ No newline at end of file diff --git a/public/mirror/manifest.json b/public/mirror/manifest.json new file mode 100644 index 0000000..4d44e44 --- /dev/null +++ b/public/mirror/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "TRMNL BYOS Laravel Mirror", + "short_name": "TRMNL BYOS", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff" +} From ddce3947c61a703c77a695f98835662778a8932a Mon Sep 17 00:00:00 2001 From: Gabriele Lauricella Date: Thu, 8 Jan 2026 19:04:21 +0100 Subject: [PATCH 36/53] feat: enhanced web mirror trmnl client --- public/mirror/index.html | 116 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 114 insertions(+), 2 deletions(-) diff --git a/public/mirror/index.html b/public/mirror/index.html index 2c5fcf6..64746fe 100644 --- a/public/mirror/index.html +++ b/public/mirror/index.html @@ -18,6 +18,7 @@