Compare commits

..

7 commits

Author SHA1 Message Date
Benjamin Nussbaum
799e66675c fix: require trmnl-liquid to install recipes from TRMNL catalog 2025-11-05 14:13:46 +01:00
Benjamin Nussbaum
b8c847196a feat: set preferred_renderer when installing from catalog 2025-11-05 14:13:46 +01:00
Benjamin Nussbaum
17a0504712 feat: add trmnl-liquid renderer option 2025-11-05 14:13:46 +01:00
Benjamin Nussbaum
76687c156d feat: add installation function 2025-11-05 14:13:46 +01:00
Benjamin Nussbaum
c6ee36470d strip tags 2025-11-05 14:13:46 +01:00
Benjamin Nussbaum
d54a7f3ed2 feat: add TRMNL recipe catalog 2025-11-05 14:13:46 +01:00
Benjamin Nussbaum
9367c60343 feat: add TRMNL recipe catalog 2025-11-05 14:13:46 +01:00
10 changed files with 169 additions and 299 deletions

View file

@ -42,7 +42,7 @@ jobs:
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: | tags: |
type=semver,pattern={{version}} type=ref,event=tag
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6

View file

@ -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) [![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. 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 (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, its the most popular community-driven BYOS. 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, its the most popular community-driven BYOS.
![Screenshot](README_byos-screenshot.png) ![Screenshot](README_byos-screenshot.png)
![Screenshot](README_byos-screenshot-dark.png) ![Screenshot](README_byos-screenshot-dark.png)

View file

@ -11,7 +11,6 @@ use App\Liquid\Filters\StandardFilters;
use App\Liquid\Filters\StringMarkup; use App\Liquid\Filters\StringMarkup;
use App\Liquid\Filters\Uniqueness; use App\Liquid\Filters\Uniqueness;
use App\Liquid\Tags\TemplateTag; use App\Liquid\Tags\TemplateTag;
use App\Services\PluginImportService;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@ -373,14 +372,14 @@ class Plugin extends Model
* @param array $context The render context data * @param array $context The render context data
* @return string The rendered HTML * @return string The rendered HTML
* *
* @throws Exception * @throws LiquidException
*/ */
private function renderWithExternalLiquidRenderer(string $template, array $context): string private function renderWithExternalLiquidRenderer(string $template, array $context): string
{ {
$liquidPath = config('services.trmnl.liquid_path'); $liquidPath = config('services.trmnl.liquid_path');
if (empty($liquidPath)) { if (empty($liquidPath)) {
throw new Exception('External liquid renderer path is not configured'); throw new LiquidException('External liquid renderer path is not configured');
} }
// HTML encode the template // HTML encode the template
@ -390,12 +389,9 @@ class Plugin extends Model
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($jsonContext === false) { if ($jsonContext === false) {
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg()); throw new LiquidException('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 // Execute the external renderer
$process = Process::run([ $process = Process::run([
$liquidPath, $liquidPath,

View file

@ -139,13 +139,11 @@ class PluginImportService
* @param string $zipUrl The URL to the ZIP file * @param string $zipUrl The URL to the ZIP file
* @param User $user The user importing the plugin * @param User $user The user importing the plugin
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo * @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 * @return Plugin The created plugin instance
* *
* @throws Exception If the ZIP file is invalid or required files are missing * @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, ?string $iconUrl = null): Plugin public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null): Plugin
{ {
// Download the ZIP file // Download the ZIP file
$response = Http::timeout(60)->get($zipUrl); $response = Http::timeout(60)->get($zipUrl);
@ -235,7 +233,6 @@ class PluginImportService
'configuration_template' => $configurationTemplate, 'configuration_template' => $configurationTemplate,
'data_payload' => json_decode($settings['static_data'] ?? '{}', true), 'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
'preferred_renderer' => $preferredRenderer, 'preferred_renderer' => $preferredRenderer,
'icon_url' => $iconUrl,
]); ]);
if (! $plugin_updated) { if (! $plugin_updated) {
@ -384,58 +381,4 @@ class PluginImportService
'sharedLiquidPath' => $sharedLiquidPath, '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
*
* @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 (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 = 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.');
}
}
/**
* 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(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
// Note: ARG_MAX includes environment variables, so we leave headroom
return $argMax !== null ? min($argMax, 204800) : 204800;
}
} }

261
composer.lock generated
View file

@ -62,16 +62,16 @@
}, },
{ {
"name": "aws/aws-sdk-php", "name": "aws/aws-sdk-php",
"version": "3.359.6", "version": "3.359.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/aws/aws-sdk-php.git", "url": "https://github.com/aws/aws-sdk-php.git",
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5" "reference": "40543e3993fc5094094ac9f9bdc4434bf81cca2d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/8d2ab3687196f15209c316080a431911f2e02bb5", "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/40543e3993fc5094094ac9f9bdc4434bf81cca2d",
"reference": "8d2ab3687196f15209c316080a431911f2e02bb5", "reference": "40543e3993fc5094094ac9f9bdc4434bf81cca2d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -153,9 +153,9 @@
"support": { "support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions", "forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues", "issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.359.6" "source": "https://github.com/aws/aws-sdk-php/tree/3.359.1"
}, },
"time": "2025-11-05T19:08:10+00:00" "time": "2025-10-29T20:13:06+00:00"
}, },
{ {
"name": "bnussbau/laravel-trmnl-blade", "name": "bnussbau/laravel-trmnl-blade",
@ -685,28 +685,29 @@
}, },
{ {
"name": "dragonmantank/cron-expression", "name": "dragonmantank/cron-expression",
"version": "v3.6.0", "version": "v3.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/dragonmantank/cron-expression.git", "url": "https://github.com/dragonmantank/cron-expression.git",
"reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" "reference": "8c784d071debd117328803d86b2097615b457500"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/8c784d071debd117328803d86b2097615b457500",
"reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", "reference": "8c784d071debd117328803d86b2097615b457500",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^8.2|^8.3|^8.4|^8.5" "php": "^7.2|^8.0",
"webmozart/assert": "^1.0"
}, },
"replace": { "replace": {
"mtdowling/cron-expression": "^1.0" "mtdowling/cron-expression": "^1.0"
}, },
"require-dev": { "require-dev": {
"phpstan/extension-installer": "^1.4.3", "phpstan/extension-installer": "^1.0",
"phpstan/phpstan": "^1.12.32|^2.1.31", "phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^8.5.48|^9.0" "phpunit/phpunit": "^7.0|^8.0|^9.0"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@ -737,7 +738,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/dragonmantank/cron-expression/issues", "issues": "https://github.com/dragonmantank/cron-expression/issues",
"source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" "source": "https://github.com/dragonmantank/cron-expression/tree/v3.4.0"
}, },
"funding": [ "funding": [
{ {
@ -745,7 +746,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-10-31T18:51:33+00:00" "time": "2024-10-09T13:47:03+00:00"
}, },
{ {
"name": "egulias/email-validator", "name": "egulias/email-validator",
@ -1617,16 +1618,16 @@
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v12.37.0", "version": "v12.36.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125" "reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/3c3c4ad30f5b528b164a7c09aa4ad03118c4c125", "url": "https://api.github.com/repos/laravel/framework/zipball/cad110d7685fbab990a6bb8184d0cfd847d7c4d8",
"reference": "3c3c4ad30f5b528b164a7c09aa4ad03118c4c125", "reference": "cad110d7685fbab990a6bb8184d0cfd847d7c4d8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1832,7 +1833,7 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2025-11-04T15:39:33+00:00" "time": "2025-10-29T14:20:57+00:00"
}, },
{ {
"name": "laravel/prompts", "name": "laravel/prompts",
@ -2983,16 +2984,16 @@
}, },
{ {
"name": "livewire/volt", "name": "livewire/volt",
"version": "v1.9.0", "version": "v1.8.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/livewire/volt.git", "url": "https://github.com/livewire/volt.git",
"reference": "4b289eef2f15398987a923d9f813cad6a6a19ea4" "reference": "2d9783a340d612d32f4ffd38070780ca7d7e9205"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/livewire/volt/zipball/4b289eef2f15398987a923d9f813cad6a6a19ea4", "url": "https://api.github.com/repos/livewire/volt/zipball/2d9783a340d612d32f4ffd38070780ca7d7e9205",
"reference": "4b289eef2f15398987a923d9f813cad6a6a19ea4", "reference": "2d9783a340d612d32f4ffd38070780ca7d7e9205",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -3051,7 +3052,7 @@
"issues": "https://github.com/livewire/volt/issues", "issues": "https://github.com/livewire/volt/issues",
"source": "https://github.com/livewire/volt" "source": "https://github.com/livewire/volt"
}, },
"time": "2025-10-30T02:46:00+00:00" "time": "2025-10-29T15:52:35+00:00"
}, },
{ {
"name": "maennchen/zipstream-php", "name": "maennchen/zipstream-php",
@ -3407,25 +3408,25 @@
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
"version": "v1.3.3", "version": "v1.3.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nette/schema.git", "url": "https://github.com/nette/schema.git",
"reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004" "reference": "da801d52f0354f70a638673c4a0f04e16529431d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/2befc2f42d7c715fd9d95efc31b1081e5d765004", "url": "https://api.github.com/repos/nette/schema/zipball/da801d52f0354f70a638673c4a0f04e16529431d",
"reference": "2befc2f42d7c715fd9d95efc31b1081e5d765004", "reference": "da801d52f0354f70a638673c4a0f04e16529431d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"nette/utils": "^4.0", "nette/utils": "^4.0",
"php": "8.1 - 8.5" "php": "8.1 - 8.4"
}, },
"require-dev": { "require-dev": {
"nette/tester": "^2.5.2", "nette/tester": "^2.5.2",
"phpstan/phpstan-nette": "^2.0@stable", "phpstan/phpstan-nette": "^1.0",
"tracy/tracy": "^2.8" "tracy/tracy": "^2.8"
}, },
"type": "library", "type": "library",
@ -3435,9 +3436,6 @@
} }
}, },
"autoload": { "autoload": {
"psr-4": {
"Nette\\": "src"
},
"classmap": [ "classmap": [
"src/" "src/"
] ]
@ -3466,9 +3464,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nette/schema/issues", "issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.3" "source": "https://github.com/nette/schema/tree/v1.3.2"
}, },
"time": "2025-10-30T22:57:59+00:00" "time": "2024-10-06T23:10:23+00:00"
}, },
{ {
"name": "nette/utils", "name": "nette/utils",
@ -7734,6 +7732,64 @@
], ],
"time": "2024-11-21T01:49:47+00:00" "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", "name": "wnx/sidecar-browsershot",
"version": "v2.6.1", "version": "v2.6.1",
@ -7824,16 +7880,16 @@
"packages-dev": [ "packages-dev": [
{ {
"name": "brianium/paratest", "name": "brianium/paratest",
"version": "v7.14.2", "version": "v7.14.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/paratestphp/paratest.git", "url": "https://github.com/paratestphp/paratest.git",
"reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45" "reference": "e1a93c38a94f4808faf75552e835666d3a6f8bb2"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/paratestphp/paratest/zipball/de06de1ae1203b11976c6ca01d6a9081c8b33d45", "url": "https://api.github.com/repos/paratestphp/paratest/zipball/e1a93c38a94f4808faf75552e835666d3a6f8bb2",
"reference": "de06de1ae1203b11976c6ca01d6a9081c8b33d45", "reference": "e1a93c38a94f4808faf75552e835666d3a6f8bb2",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -7847,7 +7903,7 @@
"phpunit/php-code-coverage": "^12.4.0", "phpunit/php-code-coverage": "^12.4.0",
"phpunit/php-file-iterator": "^6", "phpunit/php-file-iterator": "^6",
"phpunit/php-timer": "^8", "phpunit/php-timer": "^8",
"phpunit/phpunit": "^12.4.1", "phpunit/phpunit": "^12.4.0",
"sebastian/environment": "^8.0.3", "sebastian/environment": "^8.0.3",
"symfony/console": "^6.4.20 || ^7.3.4", "symfony/console": "^6.4.20 || ^7.3.4",
"symfony/process": "^6.4.20 || ^7.3.4" "symfony/process": "^6.4.20 || ^7.3.4"
@ -7857,7 +7913,7 @@
"ext-pcntl": "*", "ext-pcntl": "*",
"ext-pcov": "*", "ext-pcov": "*",
"ext-posix": "*", "ext-posix": "*",
"phpstan/phpstan": "^2.1.31", "phpstan/phpstan": "^2.1.30",
"phpstan/phpstan-deprecation-rules": "^2.0.3", "phpstan/phpstan-deprecation-rules": "^2.0.3",
"phpstan/phpstan-phpunit": "^2.0.7", "phpstan/phpstan-phpunit": "^2.0.7",
"phpstan/phpstan-strict-rules": "^2.0.7", "phpstan/phpstan-strict-rules": "^2.0.7",
@ -7901,7 +7957,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/paratestphp/paratest/issues", "issues": "https://github.com/paratestphp/paratest/issues",
"source": "https://github.com/paratestphp/paratest/tree/v7.14.2" "source": "https://github.com/paratestphp/paratest/tree/v7.14.1"
}, },
"funding": [ "funding": [
{ {
@ -7913,7 +7969,7 @@
"type": "paypal" "type": "paypal"
} }
], ],
"time": "2025-10-24T07:20:53+00:00" "time": "2025-10-06T08:26:52+00:00"
}, },
{ {
"name": "doctrine/deprecations", "name": "doctrine/deprecations",
@ -8312,16 +8368,16 @@
}, },
{ {
"name": "larastan/larastan", "name": "larastan/larastan",
"version": "v3.8.0", "version": "v3.7.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/larastan/larastan.git", "url": "https://github.com/larastan/larastan.git",
"reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e" "reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", "url": "https://api.github.com/repos/larastan/larastan/zipball/a761859a7487bd7d0cb8b662a7538a234d5bb5ae",
"reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", "reference": "a761859a7487bd7d0cb8b662a7538a234d5bb5ae",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -8335,7 +8391,7 @@
"illuminate/pipeline": "^11.44.2 || ^12.4.1", "illuminate/pipeline": "^11.44.2 || ^12.4.1",
"illuminate/support": "^11.44.2 || ^12.4.1", "illuminate/support": "^11.44.2 || ^12.4.1",
"php": "^8.2", "php": "^8.2",
"phpstan/phpstan": "^2.1.29" "phpstan/phpstan": "^2.1.28"
}, },
"require-dev": { "require-dev": {
"doctrine/coding-standard": "^13", "doctrine/coding-standard": "^13",
@ -8348,8 +8404,7 @@
"phpunit/phpunit": "^10.5.35 || ^11.5.15" "phpunit/phpunit": "^10.5.35 || ^11.5.15"
}, },
"suggest": { "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", "type": "phpstan-extension",
"extra": { "extra": {
@ -8390,7 +8445,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/larastan/larastan/issues", "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.7.2"
}, },
"funding": [ "funding": [
{ {
@ -8398,20 +8453,20 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-10-27T23:09:14+00:00" "time": "2025-09-19T09:03:05+00:00"
}, },
{ {
"name": "laravel/boost", "name": "laravel/boost",
"version": "v1.7.1", "version": "v1.6.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/boost.git", "url": "https://github.com/laravel/boost.git",
"reference": "355f7c27952862aab3f61adec27773fd4d41a582" "reference": "29d1c7c5a816d2b55c39f50bb07bdbca6c595b09"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/boost/zipball/355f7c27952862aab3f61adec27773fd4d41a582", "url": "https://api.github.com/repos/laravel/boost/zipball/29d1c7c5a816d2b55c39f50bb07bdbca6c595b09",
"reference": "355f7c27952862aab3f61adec27773fd4d41a582", "reference": "29d1c7c5a816d2b55c39f50bb07bdbca6c595b09",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -8420,7 +8475,7 @@
"illuminate/contracts": "^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/routing": "^10.49.0|^11.45.3|^12.28.1",
"illuminate/support": "^10.49.0|^11.45.3|^12.28.1", "illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
"laravel/mcp": "^0.3.2", "laravel/mcp": "^0.2.0|^0.3.0",
"laravel/prompts": "0.1.25|^0.3.6", "laravel/prompts": "0.1.25|^0.3.6",
"laravel/roster": "^0.2.9", "laravel/roster": "^0.2.9",
"php": "^8.1" "php": "^8.1"
@ -8464,7 +8519,7 @@
"issues": "https://github.com/laravel/boost/issues", "issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost" "source": "https://github.com/laravel/boost"
}, },
"time": "2025-11-05T21:41:46+00:00" "time": "2025-10-28T17:43:53+00:00"
}, },
{ {
"name": "laravel/mcp", "name": "laravel/mcp",
@ -9052,16 +9107,16 @@
}, },
{ {
"name": "pestphp/pest", "name": "pestphp/pest",
"version": "v4.1.3", "version": "v4.1.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/pestphp/pest.git", "url": "https://github.com/pestphp/pest.git",
"reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027" "reference": "08b09f2e98fc6830050c0237968b233768642d46"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/pestphp/pest/zipball/477d20a54fd9329ddfb0f8d4eb90dca7bc81b027", "url": "https://api.github.com/repos/pestphp/pest/zipball/08b09f2e98fc6830050c0237968b233768642d46",
"reference": "477d20a54fd9329ddfb0f8d4eb90dca7bc81b027", "reference": "08b09f2e98fc6830050c0237968b233768642d46",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -9073,12 +9128,12 @@
"pestphp/pest-plugin-mutate": "^4.0.1", "pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.1.0", "pestphp/pest-plugin-profanity": "^4.1.0",
"php": "^8.3.0", "php": "^8.3.0",
"phpunit/phpunit": "^12.4.1", "phpunit/phpunit": "^12.4.0",
"symfony/process": "^7.3.4" "symfony/process": "^7.3.4"
}, },
"conflict": { "conflict": {
"filp/whoops": "<2.18.3", "filp/whoops": "<2.18.3",
"phpunit/phpunit": ">12.4.1", "phpunit/phpunit": ">12.4.0",
"sebastian/exporter": "<7.0.0", "sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0" "webmozart/assert": "<1.11.0"
}, },
@ -9152,7 +9207,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/pestphp/pest/issues", "issues": "https://github.com/pestphp/pest/issues",
"source": "https://github.com/pestphp/pest/tree/v4.1.3" "source": "https://github.com/pestphp/pest/tree/v4.1.2"
}, },
"funding": [ "funding": [
{ {
@ -9164,7 +9219,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2025-10-29T22:45:27+00:00" "time": "2025-10-05T19:09:49+00:00"
}, },
{ {
"name": "pestphp/pest-plugin", "name": "pestphp/pest-plugin",
@ -10310,16 +10365,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "12.4.1", "version": "12.4.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194" "reference": "f62aab5794e36ccd26860db2d1bbf89ac19028d9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc5413a2e6d240d2f6d9317bdf7f0a24e73de194", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f62aab5794e36ccd26860db2d1bbf89ac19028d9",
"reference": "fc5413a2e6d240d2f6d9317bdf7f0a24e73de194", "reference": "f62aab5794e36ccd26860db2d1bbf89ac19028d9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -10387,7 +10442,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.1" "source": "https://github.com/sebastianbergmann/phpunit/tree/12.4.0"
}, },
"funding": [ "funding": [
{ {
@ -10411,7 +10466,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-10-09T14:08:29+00:00" "time": "2025-10-03T04:28:03+00:00"
}, },
{ {
"name": "rector/rector", "name": "rector/rector",
@ -11530,64 +11585,6 @@
} }
], ],
"time": "2024-03-03T12:36:25+00:00" "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": [], "aliases": [],

View file

@ -83,13 +83,7 @@ new class extends Component {
$this->installingPlugin = $pluginId; $this->installingPlugin = $pluginId;
try { try {
$importedPlugin = $pluginImportService->importFromUrl( $importedPlugin = $pluginImportService->importFromUrl($plugin['zip_url'], auth()->user(), $plugin['zip_entry_path'] ?? null);
$plugin['zip_url'],
auth()->user(),
$plugin['zip_entry_path'] ?? null,
null,
$plugin['logo_url'] ?? null
);
$this->dispatch('plugin-installed'); $this->dispatch('plugin-installed');
Flux::modal('import-from-catalog')->close(); Flux::modal('import-from-catalog')->close();

View file

@ -21,7 +21,7 @@ new class extends Component {
private function loadNewest(): void private function loadNewest(): void
{ {
try { try {
$this->recipes = Cache::remember('trmnl_recipes_newest', 43200, function () { $this->recipes = Cache::remember('trmnl_recipes_newest', 300, function () {
$response = Http::get('https://usetrmnl.com/recipes.json', [ $response = Http::get('https://usetrmnl.com/recipes.json', [
'sort-by' => 'newest', 'sort-by' => 'newest',
]); ]);
@ -92,14 +92,10 @@ new class extends Component {
try { try {
$zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive"; $zipUrl = "https://usetrmnl.com/api/plugin_settings/{$recipeId}/archive";
$recipe = collect($this->recipes)->firstWhere('id', $recipeId);
$plugin = $pluginImportService->importFromUrl( $plugin = $pluginImportService->importFromUrl(
$zipUrl, $zipUrl,
auth()->user(), auth()->user(),
null, preferredRenderer: config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null
config('services.trmnl.liquid_enabled') ? 'trmnl-liquid' : null,
$recipe['icon_url'] ?? null
); );
$this->dispatch('plugin-installed'); $this->dispatch('plugin-installed');

View file

@ -156,7 +156,6 @@ new class extends Component {
<div class="py-12" x-data="{ <div class="py-12" x-data="{
searchTerm: '', searchTerm: '',
showFilters: false,
filterPlugins(plugins) { filterPlugins(plugins) {
if (this.searchTerm.length <= 1) return plugins; if (this.searchTerm.length <= 1) return plugins;
const search = this.searchTerm.toLowerCase(); const search = this.searchTerm.toLowerCase();
@ -166,8 +165,7 @@ new class extends Component {
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-semibold dark:text-gray-100">Plugins &amp; Recipes</h2> <h2 class="text-2xl font-semibold dark:text-gray-100">Plugins &amp; Recipes</h2>
<div class="flex items-center space-x-2">
<flux:button icon="funnel" variant="ghost" @click="showFilters = !showFilters"></flux:button>
<flux:button.group> <flux:button.group>
<flux:modal.trigger name="add-plugin"> <flux:modal.trigger name="add-plugin">
<flux:button icon="plus" variant="primary">Add Recipe</flux:button> <flux:button icon="plus" variant="primary">Add Recipe</flux:button>
@ -176,6 +174,9 @@ new class extends Component {
<flux:dropdown> <flux:dropdown>
<flux:button icon="chevron-down" variant="primary"></flux:button> <flux:button icon="chevron-down" variant="primary"></flux:button>
<flux:menu> <flux:menu>
<flux:modal.trigger name="import-zip">
<flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item>
</flux:modal.trigger>
<flux:modal.trigger name="import-from-catalog"> <flux:modal.trigger name="import-from-catalog">
<flux:menu.item icon="book-open">Import from OSS Catalog</flux:menu.item> <flux:menu.item icon="book-open">Import from OSS Catalog</flux:menu.item>
</flux:modal.trigger> </flux:modal.trigger>
@ -184,19 +185,13 @@ new class extends Component {
<flux:menu.item icon="book-open">Import from TRMNL Catalog</flux:menu.item> <flux:menu.item icon="book-open">Import from TRMNL Catalog</flux:menu.item>
</flux:modal.trigger> </flux:modal.trigger>
@endif @endif
<flux:separator />
<flux:modal.trigger name="import-zip">
<flux:menu.item icon="archive-box">Import Recipe Archive</flux:menu.item>
</flux:modal.trigger>
<flux:separator />
<flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item> <flux:menu.item icon="beaker" wire:click="seedExamplePlugins">Seed Example Recipes</flux:menu.item>
</flux:menu> </flux:menu>
</flux:dropdown> </flux:dropdown>
</flux:button.group> </flux:button.group>
</div> </div>
</div>
<div x-show="showFilters" class="mb-6 flex flex-col sm:flex-row gap-4" style="display: none;"> <div class="mb-6 flex flex-col sm:flex-row gap-4">
<div class="flex-1"> <div class="flex-1">
<flux:input <flux:input
x-model="searchTerm" x-model="searchTerm"
@ -224,7 +219,7 @@ new class extends Component {
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<flux:heading size="lg">Import Recipe <flux:heading size="lg">Import Recipe
<flux:badge color="blue" class="ml-2">Beta</flux:badge> <flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
</flux:heading> </flux:heading>
<flux:subheading>Upload a ZIP archive containing a TRMNL recipe either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading> <flux:subheading>Upload a ZIP archive containing a TRMNL recipe either exported from the cloud service or structured using the <a href="https://github.com/usetrmnl/trmnlp" target="_blank" class="underline">trmnlp</a> project structure.</flux:subheading>
</div> </div>
@ -282,7 +277,7 @@ new class extends Component {
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<flux:heading size="lg">Import from Catalog <flux:heading size="lg">Import from Catalog
<flux:badge color="blue" class="ml-2">Beta</flux:badge> <flux:badge color="yellow" class="ml-2">Alpha</flux:badge>
</flux:heading> </flux:heading>
<flux:subheading>Browse and install Recipes from the community. Add yours <a href="https://github.com/bnussbau/trmnl-recipe-catalog" class="underline" target="_blank">here</a>.</flux:subheading> <flux:subheading>Browse and install Recipes from the community. Add yours <a href="https://github.com/bnussbau/trmnl-recipe-catalog" class="underline" target="_blank">here</a>.</flux:subheading>
</div> </div>
@ -380,14 +375,10 @@ new class extends Component {
x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())" x-show="searchTerm.length <= 1 || pluginName.includes(searchTerm.toLowerCase())"
class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs"> class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
<a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}" <a href="{{ ($plugin['detail_view_route']) ? route($plugin['detail_view_route']) : route('plugins.recipe', ['plugin' => $plugin['id']]) }}"
class="block h-full"> class="block">
<div class="flex items-center space-x-4 px-10 py-8 h-full"> <div class="flex items-center space-x-4 px-10 py-8">
@isset($plugin['icon_url'])
<img src="{{ $plugin['icon_url'] }}" class="h-6"/>
@else
<flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}" <flux:icon name="{{$plugin['flux_icon_name'] ?? 'puzzle-piece'}}"
class="text-4xl text-accent"/> class="text-4xl text-accent"/>
@endif
<h3 class="text-lg font-medium dark:text-zinc-200">{{$plugin['name']}}</h3> <h3 class="text-lg font-medium dark:text-zinc-200">{{$plugin['name']}}</h3>
</div> </div>
</a> </a>

View file

@ -1082,7 +1082,7 @@ HTML;
})" })"
wire:ignore wire:ignore
wire:key="cm-{{ $textareaId }}" wire:key="cm-{{ $textareaId }}"
class="max-w-2xl min-h-[300px] h-[565px] overflow-hidden resize-y" class="max-w-2xl min-h-[300px] h-[500px] overflow-hidden resize-y"
> >
<!-- Loading state --> <!-- Loading state -->
<div x-show="isLoading" class="flex items-center justify-center h-full"> <div x-show="isLoading" class="flex items-center justify-center h-full">

View file

@ -341,53 +341,6 @@ it('imports specific plugin from monorepo zip with zip_entry_path parameter', fu
->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>'); ->and($plugin->render_markup)->toContain('<div class="plugin2-content">Plugin 2 content</div>');
}); });
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 // Helper methods
function createMockZipFile(array $files): string function createMockZipFile(array $files): string
{ {