From 36f783ac608316b8fd9e896f03612c258dfe9e61 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 6 Nov 2025 15:36:27 +0100 Subject: [PATCH 01/19] chore: update dependencies --- composer.lock | 261 +++++++++++++++++++++++++------------------------- 1 file changed, 132 insertions(+), 129 deletions(-) diff --git a/composer.lock b/composer.lock index 2f54904..60b450d 100644 --- a/composer.lock +++ b/composer.lock @@ -62,16 +62,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.359.1", + "version": "3.359.6", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "40543e3993fc5094094ac9f9bdc4434bf81cca2d" + "reference": "8d2ab3687196f15209c316080a431911f2e02bb5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/40543e3993fc5094094ac9f9bdc4434bf81cca2d", - "reference": "40543e3993fc5094094ac9f9bdc4434bf81cca2d", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8d2ab3687196f15209c316080a431911f2e02bb5", + "reference": "8d2ab3687196f15209c316080a431911f2e02bb5", "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.359.1" + "source": "https://github.com/aws/aws-sdk-php/tree/3.359.6" }, - "time": "2025-10-29T20:13:06+00:00" + "time": "2025-11-05T19:08:10+00:00" }, { "name": "bnussbau/laravel-trmnl-blade", @@ -685,29 +685,28 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.4.0", + "version": "v3.6.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "8c784d071debd117328803d86b2097615b457500" + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500", - "reference": "8c784d071debd117328803d86b2097615b457500", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "shasum": "" }, "require": { - "php": "^7.2|^8.0", - "webmozart/assert": "^1.0" + "php": "^8.2|^8.3|^8.4|^8.5" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" }, "type": "library", "extra": { @@ -738,7 +737,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" }, "funding": [ { @@ -746,7 +745,7 @@ "type": "github" } ], - "time": "2024-10-09T13:47:03+00:00" + "time": "2025-10-31T18:51:33+00:00" }, { "name": "egulias/email-validator", @@ -1618,16 +1617,16 @@ }, { "name": "laravel/framework", - "version": "v12.36.1", + "version": "v12.37.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8" + "reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/cad110d7685fbab990a6bb8184d0cfd847d7c4d8", - "reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8", + "url": "https://api.github.com/repos/laravel/framework/zipball/3c3c4ad30f5b528b164a7c09aa4ad03118c4c125", + "reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125", "shasum": "" }, "require": { @@ -1833,7 +1832,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-10-29T14:20:57+00:00" + "time": "2025-11-04T15:39:33+00:00" }, { "name": "laravel/prompts", @@ -2984,16 +2983,16 @@ }, { "name": "livewire/volt", - "version": "v1.8.0", + "version": "v1.9.0", "source": { "type": "git", "url": "https://github.com/livewire/volt.git", - "reference": "2d9783a340d612d32f4ffd38070780ca7d7e9205" + "reference": "4b289eef2f15398987a923d9f813cad6a6a19ea4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/volt/zipball/2d9783a340d612d32f4ffd38070780ca7d7e9205", - "reference": "2d9783a340d612d32f4ffd38070780ca7d7e9205", + "url": "https://api.github.com/repos/livewire/volt/zipball/4b289eef2f15398987a923d9f813cad6a6a19ea4", + "reference": "4b289eef2f15398987a923d9f813cad6a6a19ea4", "shasum": "" }, "require": { @@ -3052,7 +3051,7 @@ "issues": "https://github.com/livewire/volt/issues", "source": "https://github.com/livewire/volt" }, - "time": "2025-10-29T15:52:35+00:00" + "time": "2025-10-30T02:46:00+00:00" }, { "name": "maennchen/zipstream-php", @@ -3408,25 +3407,25 @@ }, { "name": "nette/schema", - "version": "v1.3.2", + "version": "v1.3.3", "source": { "type": "git", "url": "https://github.com/nette/schema.git", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d" + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d", - "reference": "da801d52f0354f70a638673c4a0f04e16529431d", + "url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", + "reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", "shasum": "" }, "require": { "nette/utils": "^4.0", - "php": "8.1 - 8.4" + "php": "8.1 - 8.5" }, "require-dev": { "nette/tester": "^2.5.2", - "phpstan/phpstan-nette": "^1.0", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.8" }, "type": "library", @@ -3436,6 +3435,9 @@ } }, "autoload": { + "psr-4": { + "Nette\\": "src" + }, "classmap": [ "src/" ] @@ -3464,9 +3466,9 @@ ], "support": { "issues": "https://github.com/nette/schema/issues", - "source": "https://github.com/nette/schema/tree/v1.3.2" + "source": "https://github.com/nette/schema/tree/v1.3.3" }, - "time": "2024-10-06T23:10:23+00:00" + "time": "2025-10-30T22:57:59+00:00" }, { "name": "nette/utils", @@ -7732,64 +7734,6 @@ ], "time": "2024-11-21T01:49:47+00:00" }, - { - "name": "webmozart/assert", - "version": "1.12.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^7.2 || ^8.0" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.10-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" - }, - "time": "2025-10-29T15:56:20+00:00" - }, { "name": "wnx/sidecar-browsershot", "version": "v2.6.1", @@ -7880,16 +7824,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.14.1", + "version": "v7.14.2", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "e1a93c38a94f4808faf75552e835666d3a6f8bb2" + "reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/e1a93c38a94f4808faf75552e835666d3a6f8bb2", - "reference": "e1a93c38a94f4808faf75552e835666d3a6f8bb2", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/de06de1ae1203b11976c6ca01d6a9081c8b33d45", + "reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45", "shasum": "" }, "require": { @@ -7903,7 +7847,7 @@ "phpunit/php-code-coverage": "^12.4.0", "phpunit/php-file-iterator": "^6", "phpunit/php-timer": "^8", - "phpunit/phpunit": "^12.4.0", + "phpunit/phpunit": "^12.4.1", "sebastian/environment": "^8.0.3", "symfony/console": "^6.4.20 || ^7.3.4", "symfony/process": "^6.4.20 || ^7.3.4" @@ -7913,7 +7857,7 @@ "ext-pcntl": "*", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan": "^2.1.31", "phpstan/phpstan-deprecation-rules": "^2.0.3", "phpstan/phpstan-phpunit": "^2.0.7", "phpstan/phpstan-strict-rules": "^2.0.7", @@ -7957,7 +7901,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.14.1" + "source": "https://github.com/paratestphp/paratest/tree/v7.14.2" }, "funding": [ { @@ -7969,7 +7913,7 @@ "type": "paypal" } ], - "time": "2025-10-06T08:26:52+00:00" + "time": "2025-10-24T07:20:53+00:00" }, { "name": "doctrine/deprecations", @@ -8368,16 +8312,16 @@ }, { "name": "larastan/larastan", - "version": "v3.7.2", + "version": "v3.8.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae" + "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/a761859a7487bd7d0cb8b662a7538a234d5bb5ae", - "reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae", + "url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", + "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", "shasum": "" }, "require": { @@ -8391,7 +8335,7 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1", "illuminate/support": "^11.44.2 || ^12.4.1", "php": "^8.2", - "phpstan/phpstan": "^2.1.28" + "phpstan/phpstan": "^2.1.29" }, "require-dev": { "doctrine/coding-standard": "^13", @@ -8404,7 +8348,8 @@ "phpunit/phpunit": "^10.5.35 || ^11.5.15" }, "suggest": { - "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" }, "type": "phpstan-extension", "extra": { @@ -8445,7 +8390,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.7.2" + "source": "https://github.com/larastan/larastan/tree/v3.8.0" }, "funding": [ { @@ -8453,20 +8398,20 @@ "type": "github" } ], - "time": "2025-09-19T09:03:05+00:00" + "time": "2025-10-27T23:09:14+00:00" }, { "name": "laravel/boost", - "version": "v1.6.0", + "version": "v1.7.1", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "29d1c7c5a816d2b55c39f50bb07bdbca6c595b09" + "reference": "355f7c27952862aab3f61adec27773fd4d41a582" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/29d1c7c5a816d2b55c39f50bb07bdbca6c595b09", - "reference": "29d1c7c5a816d2b55c39f50bb07bdbca6c595b09", + "url": "https://api.github.com/repos/laravel/boost/zipball/355f7c27952862aab3f61adec27773fd4d41a582", + "reference": "355f7c27952862aab3f61adec27773fd4d41a582", "shasum": "" }, "require": { @@ -8475,7 +8420,7 @@ "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.2.0|^0.3.0", + "laravel/mcp": "^0.3.2", "laravel/prompts": "0.1.25|^0.3.6", "laravel/roster": "^0.2.9", "php": "^8.1" @@ -8519,7 +8464,7 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-10-28T17:43:53+00:00" + "time": "2025-11-05T21:41:46+00:00" }, { "name": "laravel/mcp", @@ -9107,16 +9052,16 @@ }, { "name": "pestphp/pest", - "version": "v4.1.2", + "version": "v4.1.3", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "08b09f2e98fc6830050c0237968b233768642d46" + "reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/08b09f2e98fc6830050c0237968b233768642d46", - "reference": "08b09f2e98fc6830050c0237968b233768642d46", + "url": "https://api.github.com/repos/pestphp/pest/zipball/477d20a54fd9329ddfb0f8d4eb90dca7bc81b027", + "reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027", "shasum": "" }, "require": { @@ -9128,12 +9073,12 @@ "pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-profanity": "^4.1.0", "php": "^8.3.0", - "phpunit/phpunit": "^12.4.0", + "phpunit/phpunit": "^12.4.1", "symfony/process": "^7.3.4" }, "conflict": { "filp/whoops": "<2.18.3", - "phpunit/phpunit": ">12.4.0", + "phpunit/phpunit": ">12.4.1", "sebastian/exporter": "<7.0.0", "webmozart/assert": "<1.11.0" }, @@ -9207,7 +9152,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v4.1.2" + "source": "https://github.com/pestphp/pest/tree/v4.1.3" }, "funding": [ { @@ -9219,7 +9164,7 @@ "type": "github" } ], - "time": "2025-10-05T19:09:49+00:00" + "time": "2025-10-29T22:45:27+00:00" }, { "name": "pestphp/pest-plugin", @@ -10365,16 +10310,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.4.0", + "version": "12.4.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "f62aab5794e36ccd26860db2d1bbf89ac19028d9" + "reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f62aab5794e36ccd26860db2d1bbf89ac19028d9", - "reference": "f62aab5794e36ccd26860db2d1bbf89ac19028d9", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc5413a2e6d240d2f6d9317bdf7f0a24e73de194", + "reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194", "shasum": "" }, "require": { @@ -10442,7 +10387,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.0" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.1" }, "funding": [ { @@ -10466,7 +10411,7 @@ "type": "tidelift" } ], - "time": "2025-10-03T04:28:03+00:00" + "time": "2025-10-09T14:08:29+00:00" }, { "name": "rector/rector", @@ -11585,6 +11530,64 @@ } ], "time": "2024-03-03T12:36:25+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", + "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^7.2 || ^8.0" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/1.12.1" + }, + "time": "2025-10-29T15:56:20+00:00" } ], "aliases": [], From 1ccaa8382beda528a39d2992a239cee853324ca6 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 6 Nov 2025 15:38:09 +0100 Subject: [PATCH 02/19] Update recipe count in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a5660fa..f3b5fd5 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, recipes (55+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 20k downloads and 100+ stars, it’s the most popular community-driven BYOS. +It allows you to manage TRMNL devices, generate screens using native plugins, recipes (100+ from the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)), or the API, and can also act as a proxy for the native cloud service (Core). With over 20k downloads and 100+ stars, it’s the most popular community-driven BYOS. ![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot-dark.png) From e53c584eed3c4f44ce662620d5c4ceb35cccfc99 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 6 Nov 2025 21:53:41 +0100 Subject: [PATCH 03/19] ci: metadata-action change to semver tag type --- .github/workflows/docker-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index d0dc9ea..a4ff129 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -42,7 +42,7 @@ jobs: with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | - type=ref,event=tag + type=semver,pattern={{version}} - name: Build and push Docker image uses: docker/build-push-action@v6 From ee3df85c2f29f4580750026cc39c72e8f0ec5b22 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sat, 25 Oct 2025 20:51:06 +0200 Subject: [PATCH 04/19] feat: add TRMNL recipe catalog --- .../views/livewire/plugins/index.blade.php | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index ab42b67..086e402 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -178,7 +178,10 @@ new class extends Component { Import Recipe Archive - Import from Catalog + Import from OSS Catalog + + + Import from TRMNL Catalog Seed Example Recipes @@ -280,6 +283,18 @@ new class extends Component { + +
+
+ Import from TRMNL Recipe Catalog + Alpha + +
+{{-- IMPLEMENT p--}} +{{-- --}} +
+
+
From de0ecab67e1daeca9820a29309e0db8c52b07390 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Sat, 25 Oct 2025 20:57:27 +0200 Subject: [PATCH 05/19] feat: add TRMNL recipe catalog --- .../views/livewire/catalog/trmnl.blade.php | 194 ++++++++++++++++++ .../views/livewire/plugins/index.blade.php | 3 +- tests/Feature/Volt/CatalogTrmnlTest.php | 66 ++++++ 3 files changed, 261 insertions(+), 2 deletions(-) create mode 100644 resources/views/livewire/catalog/trmnl.blade.php create mode 100644 tests/Feature/Volt/CatalogTrmnlTest.php diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php new file mode 100644 index 0000000..7d318a6 --- /dev/null +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -0,0 +1,194 @@ +loadNewest(); + } + + private function loadNewest(): void + { + $this->error = ''; + try { + $this->recipes = Cache::remember('trmnl_recipes_newest', 300, function () { + $response = Http::get('https://usetrmnl.com/recipes.json', [ + 'sort-by' => 'newest', + ]); + + 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()); + $this->recipes = []; + $this->error = 'Failed to load the TRMNL catalog. Please try again later.'; + } + } + + private function searchRecipes(string $term): void + { + $this->error = ''; + $this->isSearching = true; + try { + $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'); + } + + $json = $response->json(); + $data = $json['data'] ?? []; + return $this->mapRecipes($data); + }); + } catch (\Throwable $e) { + Log::error('TRMNL catalog search error: ' . $e->getMessage()); + $this->recipes = []; + $this->error = 'Search failed. Please try again later.'; + } finally { + $this->isSearching = false; + } + } + + public function updatedSearch(): void + { + $term = trim($this->search); + if ($term === '') { + $this->loadNewest(); + return; + } + + if (strlen($term) < 2) { + // Require at least 2 chars to avoid noisy calls + return; + } + + $this->searchRecipes($term); + } + + /** + * @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) + ? ($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, + ]; + }) + ->toArray(); + } +}; ?> + +
+
+
+ +
+ Newest +
+ + @if($error) + + @endif + + @if(empty($recipes)) +
+ + No recipes found + Try a different search term +
+ @else +
+ @foreach($recipes as $recipe) +
+
+ @php($thumb = $recipe['icon_url'] ?? $recipe['screenshot_url']) + @if($thumb) + {{ $recipe['name'] }} + @else +
+ +
+ @endif + +
+
+
+

{{ $recipe['name'] }}

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

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

+ @endif +
+
+ @if($recipe['detail_url']) + + + + @endif +
+
+ + @if($recipe['author_bio']) +

{{ $recipe['author_bio'] }}

+ @endif + +
+ + + Install + + + + @if($recipe['detail_url']) + + View on TRMNL + + @endif +
+
+
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 086e402..dfc2c91 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -290,8 +290,7 @@ new class extends Component { Alpha
-{{-- IMPLEMENT p--}} -{{-- --}} +
diff --git a/tests/Feature/Volt/CatalogTrmnlTest.php b/tests/Feature/Volt/CatalogTrmnlTest.php new file mode 100644 index 0000000..ed708c7 --- /dev/null +++ b/tests/Feature/Volt/CatalogTrmnlTest.php @@ -0,0 +1,66 @@ + Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + ]); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->assertSee('Install') + ->assertSee('Installs: 10'); +}); + +it('searches TRMNL recipes when search term is provided', function () { + Http::fake([ + // First call (mount -> newest) + 'usetrmnl.com/recipes.json?*' => Http::sequence() + ->push([ + 'data' => [ + [ + 'id' => 1, + 'name' => 'Initial Recipe', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 1, 'forks' => 0], + ], + ], + ], 200) + // Second call (search) + ->push([ + 'data' => [ + [ + 'id' => 2, + 'name' => 'Weather Search Result', + 'icon_url' => null, + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 3, 'forks' => 1], + ], + ], + ], 200), + ]); + + Volt::test('catalog.trmnl') + ->assertSee('Initial Recipe') + ->set('search', 'weather') + ->assertSee('Weather Search Result') + ->assertSee('Install'); +}); From 0da18e0e5a9c7d725ad551e0f28614d87e0d25fb Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Mon, 27 Oct 2025 12:14:37 +0100 Subject: [PATCH 06/19] strip tags --- resources/views/livewire/catalog/trmnl.blade.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 7d318a6..81cab20 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -99,7 +99,7 @@ new class extends Component { 'icon_url' => $item['icon_url'] ?? null, 'screenshot_url' => $item['screenshot_url'] ?? null, 'author_bio' => is_array($item['author_bio'] ?? null) - ? ($item['author_bio']['description'] ?? null) + ? strip_tags($item['author_bio']['description'] ?? null) : null, 'stats' => [ 'installs' => data_get($item, 'stats.installs'), From 03c74f9575f8c64279ea506a9434ebd913812387 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 29 Oct 2025 10:47:27 +0100 Subject: [PATCH 07/19] feat: add installation function --- .../views/livewire/catalog/trmnl.blade.php | 56 ++++++++++--- tests/Feature/Volt/CatalogTrmnlTest.php | 79 +++++++++++++++++++ 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 81cab20..6f04ca4 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -4,12 +4,14 @@ use Livewire\Volt\Component; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Cache; +use App\Services\PluginImportService; +use Illuminate\Support\Facades\Auth; new class extends Component { public array $recipes = []; public string $search = ''; - public string $error = ''; public bool $isSearching = false; + public string $installingPlugin = ''; public function mount(): void { @@ -18,7 +20,6 @@ new class extends Component { private function loadNewest(): void { - $this->error = ''; try { $this->recipes = Cache::remember('trmnl_recipes_newest', 300, function () { $response = Http::get('https://usetrmnl.com/recipes.json', [ @@ -36,13 +37,11 @@ new class extends Component { } catch (\Throwable $e) { Log::error('TRMNL catalog load error: ' . $e->getMessage()); $this->recipes = []; - $this->error = 'Failed to load the TRMNL catalog. Please try again later.'; } } private function searchRecipes(string $term): void { - $this->error = ''; $this->isSearching = true; try { $cacheKey = 'trmnl_recipes_search_' . md5($term); @@ -63,7 +62,6 @@ new class extends Component { } catch (\Throwable $e) { Log::error('TRMNL catalog search error: ' . $e->getMessage()); $this->recipes = []; - $this->error = 'Search failed. Please try again later.'; } finally { $this->isSearching = false; } @@ -85,6 +83,27 @@ new class extends Component { $this->searchRecipes($term); } + public function installPlugin(string $recipeId, PluginImportService $pluginImportService): void + { + abort_unless(auth()->user() !== null, 403); + + $this->installingPlugin = $recipeId; + + try { + $zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive"; + $plugin = $pluginImportService->importFromUrl($zipUrl, auth()->user()); + + $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()); + } finally { + $this->installingPlugin = ''; + } + } + /** * @param array> $items * @return array> @@ -124,9 +143,9 @@ new class extends Component { Newest - @if($error) - - @endif + @error('installation') + + @enderror @if(empty($recipes))
@@ -170,11 +189,22 @@ new class extends Component { @endif
- - - Install - - + @if($recipe['id']) + @if($installingPlugin === $recipe['id']) + + + + @else + + Install + + @endif + @endif @if($recipe['detail_url']) assertSee('Weather Search Result') ->assertSee('Install'); }); + +it('installs plugin successfully when user is authenticated', function () { + $user = User::factory()->create(); + + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('fake zip content', 200), + ]); + + $this->actingAs($user); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->call('installPlugin', '123') + ->assertSee('Error installing plugin'); // This will fail because we don't have a real zip file +}); + +it('shows error when user is not authenticated', function () { + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + ]); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->call('installPlugin', '123') + ->assertStatus(403); // This will return 403 because user is not authenticated +}); + +it('shows error when plugin installation fails', function () { + $user = User::factory()->create(); + + Http::fake([ + 'usetrmnl.com/recipes.json*' => Http::response([ + 'data' => [ + [ + 'id' => 123, + 'name' => 'Weather Chum', + 'icon_url' => 'https://example.com/icon.png', + 'screenshot_url' => null, + 'author_bio' => null, + 'stats' => ['installs' => 10, 'forks' => 2], + ], + ], + ], 200), + 'usetrmnl.com/api/plugin_settings/123/archive*' => Http::response('invalid zip content', 200), + ]); + + $this->actingAs($user); + + Volt::test('catalog.trmnl') + ->assertSee('Weather Chum') + ->call('installPlugin', '123') + ->assertSee('Error installing plugin'); // This will fail because the zip content is invalid +}); From 3ae8a610a0b95d52d6235d1528d70a1c6131e14f Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 4 Nov 2025 18:35:08 +0100 Subject: [PATCH 08/19] feat: add trmnl-liquid renderer option --- app/Models/Plugin.php | 156 ++++++++++++------ config/services.php | 2 + ...dd_preferred_renderer_to_plugins_table.php | 28 ++++ 3 files changed, 135 insertions(+), 51 deletions(-) create mode 100644 database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 33a29d5..40569f8 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -19,6 +19,7 @@ use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; use Keepsuit\LaravelLiquid\LaravelLiquidExtension; use Keepsuit\Liquid\Exceptions\LiquidException; @@ -40,6 +41,7 @@ class Plugin extends Model 'configuration_template' => 'json', 'no_bleed' => 'boolean', 'dark_mode' => 'boolean', + 'preferred_renderer' => 'string', ]; protected static function boot() @@ -363,6 +365,50 @@ class Plugin extends Model return $liquidTemplate->render($context); } + /** + * Render template using external Ruby liquid renderer + * + * @param string $template The liquid template string + * @param array $context The render context data + * @return string The rendered HTML + * + * @throws LiquidException + */ + private function renderWithExternalLiquidRenderer(string $template, array $context): string + { + $liquidPath = config('services.trmnl.liquid_path'); + + if (empty($liquidPath)) { + throw new LiquidException('External liquid renderer path is not configured'); + } + + // HTML encode the template + $encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8'); + + // Encode context as JSON + $jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + if ($jsonContext === false) { + throw new LiquidException('Failed to encode render context as JSON: '.json_last_error_msg()); + } + + // Execute the external renderer + $process = Process::run([ + $liquidPath, + '--template', + $encodedTemplate, + '--context', + $jsonContext, + ]); + + if (! $process->successful()) { + $errorOutput = $process->errorOutput() ?: $process->output(); + throw new Exception('External liquid renderer failed: '.$errorOutput); + } + + return $process->output(); + } + /** * Render the plugin's markup * @@ -374,59 +420,67 @@ class Plugin extends Model $renderedContent = ''; if ($this->markup_language === 'liquid') { - // Create a custom environment with inline templates support - $inlineFileSystem = new InlineTemplatesFileSystem(); - $environment = new \Keepsuit\Liquid\Environment( - fileSystem: $inlineFileSystem, - extensions: [new StandardExtension(), new LaravelLiquidExtension()] - ); - - // Register all custom filters - $environment->filterRegistry->register(Data::class); - $environment->filterRegistry->register(Date::class); - $environment->filterRegistry->register(Localization::class); - $environment->filterRegistry->register(Numbers::class); - $environment->filterRegistry->register(StringMarkup::class); - $environment->filterRegistry->register(Uniqueness::class); - - // Register the template tag for inline templates - $environment->tagRegistry->register(TemplateTag::class); - - // Apply Liquid replacements (including 'with' syntax conversion) - $processedMarkup = $this->applyLiquidReplacements($this->render_markup); - - $template = $environment->parseString($processedMarkup); - $context = $environment->newRenderContext( - data: [ - 'size' => $size, - 'data' => $this->data_payload, - 'config' => $this->configuration ?? [], - ...(is_array($this->data_payload) ? $this->data_payload : []), - 'trmnl' => [ - 'system' => [ - 'timestamp_utc' => now()->utc()->timestamp, - ], - 'user' => [ - 'utc_offset' => '0', - 'name' => $this->user->name ?? 'Unknown User', - 'locale' => 'en', - 'time_zone_iana' => config('app.timezone'), - ], - 'plugin_settings' => [ - 'instance_name' => $this->name, - 'strategy' => $this->data_strategy, - 'dark_mode' => $this->dark_mode ? 'yes' : 'no', - 'no_screen_padding' => $this->no_bleed ? 'yes' : 'no', - 'polling_headers' => $this->polling_header, - 'polling_url' => $this->polling_url, - 'custom_fields_values' => [ - ...(is_array($this->configuration) ? $this->configuration : []), - ], + // Build render context + $context = [ + 'size' => $size, + 'data' => $this->data_payload, + 'config' => $this->configuration ?? [], + ...(is_array($this->data_payload) ? $this->data_payload : []), + 'trmnl' => [ + 'system' => [ + 'timestamp_utc' => now()->utc()->timestamp, + ], + 'user' => [ + 'utc_offset' => '0', + 'name' => $this->user->name ?? 'Unknown User', + 'locale' => 'en', + 'time_zone_iana' => config('app.timezone'), + ], + 'plugin_settings' => [ + 'instance_name' => $this->name, + 'strategy' => $this->data_strategy, + 'dark_mode' => $this->dark_mode ? 'yes' : 'no', + 'no_screen_padding' => $this->no_bleed ? 'yes' : 'no', + 'polling_headers' => $this->polling_header, + 'polling_url' => $this->polling_url, + 'custom_fields_values' => [ + ...(is_array($this->configuration) ? $this->configuration : []), ], ], - ] - ); - $renderedContent = $template->render($context); + ], + ]; + + // Check if external renderer should be used + if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) { + // Use external Ruby renderer - pass raw template without preprocessing + $renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context); + } else { + // Use PHP keepsuit/liquid renderer + // Create a custom environment with inline templates support + $inlineFileSystem = new InlineTemplatesFileSystem(); + $environment = new \Keepsuit\Liquid\Environment( + fileSystem: $inlineFileSystem, + extensions: [new StandardExtension(), new LaravelLiquidExtension()] + ); + + // Register all custom filters + $environment->filterRegistry->register(Data::class); + $environment->filterRegistry->register(Date::class); + $environment->filterRegistry->register(Localization::class); + $environment->filterRegistry->register(Numbers::class); + $environment->filterRegistry->register(StringMarkup::class); + $environment->filterRegistry->register(Uniqueness::class); + + // Register the template tag for inline templates + $environment->tagRegistry->register(TemplateTag::class); + + // Apply Liquid replacements (including 'with' syntax conversion) + $processedMarkup = $this->applyLiquidReplacements($this->render_markup); + + $template = $environment->parseString($processedMarkup); + $liquidContext = $environment->newRenderContext(data: $context); + $renderedContent = $template->render($liquidContext); + } } else { $renderedContent = Blade::render($this->render_markup, [ 'size' => $size, diff --git a/config/services.php b/config/services.php index 5cb8a74..d97255a 100644 --- a/config/services.php +++ b/config/services.php @@ -41,6 +41,8 @@ return [ 'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'), 'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false), 'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices + 'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false), + 'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'), ], 'webhook' => [ diff --git a/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php b/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php new file mode 100644 index 0000000..a998420 --- /dev/null +++ b/database/migrations/2025_11_03_213452_add_preferred_renderer_to_plugins_table.php @@ -0,0 +1,28 @@ +string('preferred_renderer')->nullable()->after('markup_language'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plugins', function (Blueprint $table) { + $table->dropColumn('preferred_renderer'); + }); + } +}; From b98cda881e6f848c761825a76dc174c138530e2e Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Tue, 4 Nov 2025 22:53:47 +0100 Subject: [PATCH 09/19] feat: set preferred_renderer when installing from catalog --- Dockerfile | 4 ++++ app/Services/PluginImportService.php | 3 ++- resources/views/livewire/catalog/trmnl.blade.php | 11 ++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 57a919f..4e50553 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,9 @@ ENV APP_VERSION=${APP_VERSION} ENV AUTORUN_ENABLED="true" +# Mark trmnl-liquid-cli as installed +ENV TRMNL_LIQUID_ENABLED=1 + # Switch to the root user so we can do root things USER root @@ -49,5 +52,6 @@ FROM base AS production COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules +COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:latest /usr/local/bin/trmnl-liquid-cli /usr/local/bin/ # Drop back to the www-data user USER www-data diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index a9d93b3..7c77767 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -143,7 +143,7 @@ class PluginImportService * * @throws Exception If the ZIP file is invalid or required files are missing */ - public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -232,6 +232,7 @@ class PluginImportService 'render_markup' => $fullLiquid, 'configuration_template' => $configurationTemplate, 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), + 'preferred_renderer' => $preferredRenderer, ]); if (! $plugin_updated) { diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 6f04ca4..4d1a60f 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -91,11 +91,16 @@ new class extends Component { try { $zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive"; - $plugin = $pluginImportService->importFromUrl($zipUrl, auth()->user()); - + + $plugin = $pluginImportService->importFromUrl( + $zipUrl, + auth()->user(), + preferredRenderer: config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null + ); + $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()); From 02e695fe4d3c07517438c33609b8a8ed6597d1ec Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Wed, 5 Nov 2025 13:36:00 +0100 Subject: [PATCH 10/19] fix: require trmnl-liquid to install recipes from TRMNL catalog --- resources/views/livewire/plugins/index.blade.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index dfc2c91..e5f19ec 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -180,9 +180,11 @@ new class extends Component { Import from OSS Catalog - - Import from TRMNL Catalog - + @if(config('services.trmnl.liquid_enabled')) + + Import from TRMNL Catalog + + @endif Seed Example Recipes From 9c37352c174d237ac851701892d5a89578eb6d3c Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 6 Nov 2025 14:17:36 +0100 Subject: [PATCH 11/19] fix: check arg length (external liquid renderer) --- app/Models/Plugin.php | 10 +++-- app/Services/PluginImportService.php | 55 ++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php index 40569f8..ab83514 100644 --- a/app/Models/Plugin.php +++ b/app/Models/Plugin.php @@ -11,6 +11,7 @@ use App\Liquid\Filters\StandardFilters; use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\Uniqueness; use App\Liquid\Tags\TemplateTag; +use App\Services\PluginImportService; use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -372,14 +373,14 @@ class Plugin extends Model * @param array $context The render context data * @return string The rendered HTML * - * @throws LiquidException + * @throws Exception */ private function renderWithExternalLiquidRenderer(string $template, array $context): string { $liquidPath = config('services.trmnl.liquid_path'); if (empty($liquidPath)) { - throw new LiquidException('External liquid renderer path is not configured'); + throw new Exception('External liquid renderer path is not configured'); } // HTML encode the template @@ -389,9 +390,12 @@ class Plugin extends Model $jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($jsonContext === false) { - throw new LiquidException('Failed to encode render context as JSON: '.json_last_error_msg()); + throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg()); } + // Validate argument sizes + app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath); + // Execute the external renderer $process = Process::run([ $liquidPath, diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index 7c77767..a245f65 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -381,4 +381,59 @@ class PluginImportService 'sharedLiquidPath' => $sharedLiquidPath, ]; } + + /** + * Validate that template and context are within command-line argument limits + * + * @param string $template The liquid template string + * @param string $jsonContext The JSON-encoded context + * @param string $liquidPath The path to the liquid renderer executable + * @return void + * + * @throws Exception If the template or context exceeds argument limits + */ + public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void + { + // MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments + // ARG_MAX is the total size of all arguments (typically 2MB on modern systems) + $maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit + $maxTotalArgLength = $this->getMaxArgumentLength(); + + // Check individual argument sizes (template and context are the largest) + if (strlen($template) > $maxIndividualArgLength || strlen($jsonContext) > $maxIndividualArgLength) { + throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); + } + + // Calculate total size of all arguments (path + flags + template + context) + // Add overhead for path, flags, and separators (conservative estimate: ~200 bytes) + $totalArgSize = strlen($liquidPath) + strlen('--template') + strlen($template) + + strlen('--context') + strlen($jsonContext) + 200; + + if ($totalArgSize > $maxTotalArgLength) { + throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); + } + } + + /** + * Get the maximum argument length for command-line arguments + * + * @return int Maximum argument length in bytes + */ + private function getMaxArgumentLength(): int + { + // Try to get ARG_MAX from system using getconf + $argMax = null; + if (function_exists('shell_exec')) { + $result = @shell_exec('getconf ARG_MAX 2>/dev/null'); + if ($result !== null && is_numeric(trim($result))) { + $argMax = (int) trim($result); + } + } + + // Use conservative fallback if ARG_MAX cannot be determined + // ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB) + // We use 200KB as a conservative limit that works on both systems + // Note: ARG_MAX includes environment variables, so we leave headroom + return $argMax !== null ? min($argMax, 204800) : 204800; + } } From e427932dd00b21e9c138bb6536300ea40a015882 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 6 Nov 2025 20:07:41 +0100 Subject: [PATCH 12/19] feat: show plugin icon from url --- resources/views/livewire/plugins/index.blade.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index e5f19ec..0edd26e 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -377,8 +377,12 @@ new class extends Component {
- + @else + + @endif

{{$plugin['name']}}

From 471340ac167edf774421e7f1a9bb3bfc2db9dac3 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 6 Nov 2025 20:25:55 +0100 Subject: [PATCH 13/19] feat: set icon url on import --- app/Services/PluginImportService.php | 26 +++++----- .../views/livewire/catalog/index.blade.php | 8 +++- .../views/livewire/catalog/trmnl.blade.php | 6 ++- tests/Feature/PluginImportTest.php | 47 +++++++++++++++++++ 4 files changed, 73 insertions(+), 14 deletions(-) diff --git a/app/Services/PluginImportService.php b/app/Services/PluginImportService.php index a245f65..06e6092 100644 --- a/app/Services/PluginImportService.php +++ b/app/Services/PluginImportService.php @@ -139,11 +139,13 @@ class PluginImportService * @param string $zipUrl The URL to the ZIP file * @param User $user The user importing the plugin * @param string|null $zipEntryPath Optional path to specific plugin in monorepo + * @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid') + * @param string|null $iconUrl Optional icon URL to set on the plugin * @return Plugin The created plugin instance * * @throws Exception If the ZIP file is invalid or required files are missing */ - public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null): Plugin + public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null): Plugin { // Download the ZIP file $response = Http::timeout(60)->get($zipUrl); @@ -233,6 +235,7 @@ class PluginImportService 'configuration_template' => $configurationTemplate, 'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'preferred_renderer' => $preferredRenderer, + 'icon_url' => $iconUrl, ]); if (! $plugin_updated) { @@ -388,7 +391,6 @@ class PluginImportService * @param string $template The liquid template string * @param string $jsonContext The JSON-encoded context * @param string $liquidPath The path to the liquid renderer executable - * @return void * * @throws Exception If the template or context exceeds argument limits */ @@ -398,17 +400,17 @@ class PluginImportService // ARG_MAX is the total size of all arguments (typically 2MB on modern systems) $maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit $maxTotalArgLength = $this->getMaxArgumentLength(); - + // Check individual argument sizes (template and context are the largest) - if (strlen($template) > $maxIndividualArgLength || strlen($jsonContext) > $maxIndividualArgLength) { + if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) { throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); } - + // Calculate total size of all arguments (path + flags + template + context) // Add overhead for path, flags, and separators (conservative estimate: ~200 bytes) - $totalArgSize = strlen($liquidPath) + strlen('--template') + strlen($template) - + strlen('--context') + strlen($jsonContext) + 200; - + $totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template) + + mb_strlen('--context') + mb_strlen($jsonContext) + 200; + if ($totalArgSize > $maxTotalArgLength) { throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.'); } @@ -416,7 +418,7 @@ class PluginImportService /** * Get the maximum argument length for command-line arguments - * + * * @return int Maximum argument length in bytes */ private function getMaxArgumentLength(): int @@ -425,11 +427,11 @@ class PluginImportService $argMax = null; if (function_exists('shell_exec')) { $result = @shell_exec('getconf ARG_MAX 2>/dev/null'); - if ($result !== null && is_numeric(trim($result))) { - $argMax = (int) trim($result); + if ($result !== null && is_numeric(mb_trim($result))) { + $argMax = (int) mb_trim($result); } } - + // Use conservative fallback if ARG_MAX cannot be determined // ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB) // We use 200KB as a conservative limit that works on both systems diff --git a/resources/views/livewire/catalog/index.blade.php b/resources/views/livewire/catalog/index.blade.php index 5bdae10..94d0d2a 100644 --- a/resources/views/livewire/catalog/index.blade.php +++ b/resources/views/livewire/catalog/index.blade.php @@ -83,7 +83,13 @@ new class extends Component { $this->installingPlugin = $pluginId; try { - $importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null); + $importedPlugin = $pluginImportService->importFromUrl( + $plugin['zip_url'], + auth()->user(), + $plugin['zip_entry_path'] ?? null, + null, + $plugin['logo_url'] ?? null + ); $this->dispatch('plugin-installed'); Flux::modal('import-from-catalog')->close(); diff --git a/resources/views/livewire/catalog/trmnl.blade.php b/resources/views/livewire/catalog/trmnl.blade.php index 4d1a60f..e5f40cd 100644 --- a/resources/views/livewire/catalog/trmnl.blade.php +++ b/resources/views/livewire/catalog/trmnl.blade.php @@ -92,10 +92,14 @@ new class extends Component { try { $zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive"; + $recipe = collect($this->recipes)->firstWhere('id', $recipeId); + $plugin = $pluginImportService->importFromUrl( $zipUrl, auth()->user(), - preferredRenderer: config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null + null, + config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null, + $recipe['icon_url'] ?? null ); $this->dispatch('plugin-installed'); diff --git a/tests/Feature/PluginImportTest.php b/tests/Feature/PluginImportTest.php index a0f3bc5..4bbea15 100644 --- a/tests/Feature/PluginImportTest.php +++ b/tests/Feature/PluginImportTest.php @@ -341,6 +341,53 @@ it('imports specific plugin from monorepo zip with zip_entry_path parameter', fu ->and($plugin->render_markup)->toContain('
Plugin 2 content
'); }); +it('sets icon_url when importing from URL with iconUrl parameter', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/full.liquid' => getValidFullLiquid(), + ]); + + Http::fake([ + 'https://example.com/plugin.zip' => Http::response($zipContent, 200), + ]); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromUrl( + 'https://example.com/plugin.zip', + $user, + null, + null, + 'https://example.com/icon.png' + ); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->icon_url)->toBe('https://example.com/icon.png'); +}); + +it('does not set icon_url when importing from URL without iconUrl parameter', function (): void { + $user = User::factory()->create(); + + $zipContent = createMockZipFile([ + 'src/settings.yml' => getValidSettingsYaml(), + 'src/full.liquid' => getValidFullLiquid(), + ]); + + Http::fake([ + 'https://example.com/plugin.zip' => Http::response($zipContent, 200), + ]); + + $pluginImportService = new PluginImportService(); + $plugin = $pluginImportService->importFromUrl( + 'https://example.com/plugin.zip', + $user + ); + + expect($plugin)->toBeInstanceOf(Plugin::class) + ->and($plugin->icon_url)->toBeNull(); +}); + // Helper methods function createMockZipFile(array $files): string { From d1776e59e3fdc6822a7d7c9f2941f750efeb117a Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Thu, 6 Nov 2025 20:37:59 +0100 Subject: [PATCH 14/19] feat: add plugin funnel button to reveal search and sort options --- resources/views/livewire/plugins/index.blade.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/views/livewire/plugins/index.blade.php b/resources/views/livewire/plugins/index.blade.php index 0edd26e..e5d3cc5 100644 --- a/resources/views/livewire/plugins/index.blade.php +++ b/resources/views/livewire/plugins/index.blade.php @@ -156,6 +156,7 @@ new class extends Component {

Plugins & Recipes

+ Add Recipe @@ -191,7 +193,7 @@ new class extends Component {
-
+