feat(#102): added support for Alias plugin

This commit is contained in:
Benjamin Nussbaum 2026-01-11 19:34:11 +01:00
parent 0d6079db8b
commit 3f98a70ad9
5 changed files with 222 additions and 32 deletions

View file

@ -46,6 +46,7 @@ class Plugin extends Model
'dark_mode' => 'boolean', 'dark_mode' => 'boolean',
'preferred_renderer' => 'string', 'preferred_renderer' => 'string',
'plugin_type' => 'string', 'plugin_type' => 'string',
'alias' => 'boolean',
]; ];
protected static function boot() protected static function boot()
@ -153,7 +154,7 @@ class Plugin extends Model
public function updateDataPayload(): void public function updateDataPayload(): void
{ {
if ($this->data_strategy !== 'polling' || !$this->polling_url) { if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
return; return;
} }
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json']; $headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];

View file

@ -26,11 +26,44 @@ class ImageGenerationService
public static function generateImage(string $markup, $deviceId): string public static function generateImage(string $markup, $deviceId): string
{ {
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId); $device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
$uuid = self::generateImageFromModel(
markup: $markup,
deviceModel: $device->deviceModel,
user: $device->user,
palette: $device->palette ?? $device->deviceModel?->palette,
device: $device
);
$device->update(['current_screen_image' => $uuid]);
Log::info("Device $device->id: updated with new image: $uuid");
return $uuid;
}
/**
* Generate an image from markup using a DeviceModel
*
* @param string $markup The HTML markup to render
* @param DeviceModel|null $deviceModel The device model to use for image generation
* @param \App\Models\User|null $user Optional user for timezone settings
* @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
* @param Device|null $device Optional device for legacy devices without DeviceModel
* @return string The UUID of the generated image
*/
public static function generateImageFromModel(
string $markup,
?DeviceModel $deviceModel = null,
?\App\Models\User $user = null,
?\App\Models\DevicePalette $palette = null,
?Device $device = null
): string {
$uuid = Uuid::uuid4()->toString(); $uuid = Uuid::uuid4()->toString();
try { try {
// Get image generation settings from DeviceModel if available, otherwise use device settings // Get image generation settings from DeviceModel or Device (for legacy devices)
$imageSettings = self::getImageSettings($device); $imageSettings = $deviceModel
? self::getImageSettingsFromModel($deviceModel)
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png'; $fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension); $outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
@ -45,7 +78,7 @@ class ImageGenerationService
$browserStage->html($markup); $browserStage->html($markup);
// Set timezone from user or fall back to app timezone // Set timezone from user or fall back to app timezone
$timezone = $device->user->timezone ?? config('app.timezone'); $timezone = $user?->timezone ?? config('app.timezone');
$browserStage->timezone($timezone); $browserStage->timezone($timezone);
if (config('app.puppeteer_window_size_strategy') === 'v2') { if (config('app.puppeteer_window_size_strategy') === 'v2') {
@ -65,12 +98,12 @@ class ImageGenerationService
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']); $browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
} }
// Get palette from device or fallback to device model's default palette // Get palette from parameter or fallback to device model's default palette
$palette = $device->palette ?? $device->deviceModel?->palette;
$colorPalette = null; $colorPalette = null;
if ($palette && $palette->colors) { if ($palette && $palette->colors) {
$colorPalette = $palette->colors; $colorPalette = $palette->colors;
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
$colorPalette = $deviceModel->palette->colors;
} }
$imageStage = new ImageStage(); $imageStage = new ImageStage();
@ -107,8 +140,7 @@ class ImageGenerationService
throw new RuntimeException('Image file is empty: '.$outputPath); throw new RuntimeException('Image file is empty: '.$outputPath);
} }
$device->update(['current_screen_image' => $uuid]); Log::info("Generated image: $uuid");
Log::info("Device $device->id: updated with new image: $uuid");
return $uuid; return $uuid;
@ -125,22 +157,7 @@ class ImageGenerationService
{ {
// If device has a DeviceModel, use its settings // If device has a DeviceModel, use its settings
if ($device->deviceModel) { if ($device->deviceModel) {
/** @var DeviceModel $model */ return self::getImageSettingsFromModel($device->deviceModel);
$model = $device->deviceModel;
return [
'width' => $model->width,
'height' => $model->height,
'colors' => $model->colors,
'bit_depth' => $model->bit_depth,
'scale_factor' => $model->scale_factor,
'rotation' => $model->rotation,
'mime_type' => $model->mime_type,
'offset_x' => $model->offset_x,
'offset_y' => $model->offset_y,
'image_format' => self::determineImageFormatFromModel($model),
'use_model_settings' => true,
];
} }
// Fallback to device settings // Fallback to device settings
@ -164,6 +181,43 @@ class ImageGenerationService
]; ];
} }
/**
* Get image generation settings from a DeviceModel
*/
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
{
if ($deviceModel) {
return [
'width' => $deviceModel->width,
'height' => $deviceModel->height,
'colors' => $deviceModel->colors,
'bit_depth' => $deviceModel->bit_depth,
'scale_factor' => $deviceModel->scale_factor,
'rotation' => $deviceModel->rotation,
'mime_type' => $deviceModel->mime_type,
'offset_x' => $deviceModel->offset_x,
'offset_y' => $deviceModel->offset_y,
'image_format' => self::determineImageFormatFromModel($deviceModel),
'use_model_settings' => true,
];
}
// Default settings if no device model provided
return [
'width' => 800,
'height' => 480,
'colors' => 2,
'bit_depth' => 1,
'scale_factor' => 1.0,
'rotation' => 0,
'mime_type' => 'image/png',
'offset_x' => 0,
'offset_y' => 0,
'image_format' => ImageFormat::AUTO->value,
'use_model_settings' => false,
];
}
/** /**
* Determine the appropriate ImageFormat based on DeviceModel settings * Determine the appropriate ImageFormat based on DeviceModel settings
*/ */

View file

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

View file

@ -11,21 +11,18 @@ new class extends Component {
public Plugin $plugin; public Plugin $plugin;
public string|null $trmnlp_id = null; public string|null $trmnlp_id = null;
public string|null $uuid = null; public string|null $uuid = null;
public bool $alias = false;
public int $resetIndex = 0; public int $resetIndex = 0;
public function mount(): void public function mount(): void
{
$this->loadData();
}
public function loadData(): void
{ {
$this->resetErrorBag(); $this->resetErrorBag();
// Reload data // Reload data
$this->plugin = $this->plugin->fresh(); $this->plugin = $this->plugin->fresh();
$this->trmnlp_id = $this->plugin->trmnlp_id; $this->trmnlp_id = $this->plugin->trmnlp_id;
$this->uuid = $this->plugin->uuid; $this->uuid = $this->plugin->uuid;
$this->alias = $this->plugin->alias ?? false;
} }
public function saveTrmnlpId(): void public function saveTrmnlpId(): void
@ -41,15 +38,21 @@ new class extends Component {
->where('user_id', auth()->id()) ->where('user_id', auth()->id())
->ignore($this->plugin->id), ->ignore($this->plugin->id),
], ],
'alias' => 'boolean',
]); ]);
$this->plugin->update([ $this->plugin->update([
'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id, 'trmnlp_id' => empty($this->trmnlp_id) ? null : $this->trmnlp_id,
'alias' => $this->alias,
]); ]);
//$this->loadData(); // Reload to ensure we have the latest data
Flux::modal('trmnlp-settings')->close(); Flux::modal('trmnlp-settings')->close();
} }
public function getAliasUrlProperty(): string
{
return url("/api/display/{$this->uuid}/alias");
}
};?> };?>
<flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6"> <flux:modal name="trmnlp-settings" class="min-w-[400px] space-y-6">
@ -70,6 +73,23 @@ new class extends Component {
<flux:error name="trmnlp_id" /> <flux:error name="trmnlp_id" />
<flux:description>Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with <code>trmnlp</code>. </flux:description> <flux:description>Recipe ID in the TRMNL Recipe Catalog. If set, it can be used with <code>trmnlp</code>. </flux:description>
</flux:field> </flux:field>
<flux:field>
<flux:checkbox wire:model.live="alias" label="Enable Alias" />
<flux:description>Enable a public alias URL for this recipe.</flux:description>
</flux:field>
@if($alias)
<flux:field>
<flux:label>Alias URL</flux:label>
<flux:input
value="{{ $this->aliasUrl }}"
readonly
copyable
/>
<flux:description>Use this URL to access the recipe image directly. Add <code>?device-model=name</code> to specify a device model.</flux:description>
</flux:field>
@endif
</div> </div>
<div class="flex gap-2 mt-4"> <div class="flex gap-2 mt-4">

View file

@ -613,7 +613,7 @@ Route::post('plugin_settings/{uuid}/image', function (Request $request, string $
} }
// Generate a new UUID for each image upload to prevent device caching // Generate a new UUID for each image upload to prevent device caching
$imageUuid = \Illuminate\Support\Str::uuid()->toString(); $imageUuid = Str::uuid()->toString();
$filename = $imageUuid.'.'.$extension; $filename = $imageUuid.'.'.$extension;
$path = 'images/generated/'.$filename; $path = 'images/generated/'.$filename;
@ -678,3 +678,90 @@ Route::post('plugin_settings/{trmnlp_id}/archive', function (Request $request, s
], ],
]); ]);
})->middleware('auth:sanctum'); })->middleware('auth:sanctum');
Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
$plugin = Plugin::where('uuid', $uuid)->firstOrFail();
// Check if alias is active
if (! $plugin->alias) {
return response()->json([
'message' => 'Alias is not active for this plugin',
], 403);
}
// Get device model name from query parameter, default to 'og_png'
$deviceModelName = $request->query('device-model', 'og_png');
$deviceModel = DeviceModel::where('name', $deviceModelName)->first();
if (! $deviceModel) {
return response()->json([
'message' => "Device model '{$deviceModelName}' not found",
], 404);
}
// Check if we can use cached image (only for og_png and if data is not stale)
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
if ($useCache) {
// Return cached image
$imageUuid = $plugin->current_image;
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
$imagePath = 'images/generated/'.$imageUuid.'.'.$fileExtension;
// Check if image exists, otherwise fall back to generation
if (Storage::disk('public')->exists($imagePath)) {
return response()->file(Storage::disk('public')->path($imagePath), [
'Content-Type' => $deviceModel->mime_type,
]);
}
}
// Generate new image
try {
// Update data if needed
if ($plugin->isDataStale()) {
$plugin->updateDataPayload();
$plugin->refresh();
}
// Load device model with palette relationship
$deviceModel->load('palette');
// Create a virtual device for rendering (Plugin::render needs a Device object)
$virtualDevice = new Device();
$virtualDevice->setRelation('deviceModel', $deviceModel);
$virtualDevice->setRelation('user', $plugin->user);
$virtualDevice->setRelation('palette', $deviceModel->palette);
// Render the plugin markup
$markup = $plugin->render(device: $virtualDevice);
// Generate image using the new method that doesn't require a device
$imageUuid = ImageGenerationService::generateImageFromModel(
markup: $markup,
deviceModel: $deviceModel,
user: $plugin->user,
palette: $deviceModel->palette
);
// Update plugin cache if using og_png
if ($deviceModelName === 'og_png') {
$plugin->update(['current_image' => $imageUuid]);
}
// Return the generated image
$fileExtension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
$imagePath = Storage::disk('public')->path('images/generated/'.$imageUuid.'.'.$fileExtension);
return response()->file($imagePath, [
'Content-Type' => $deviceModel->mime_type,
]);
} catch (Exception $e) {
Log::error("Failed to generate alias image for plugin {$plugin->id} ({$plugin->name}): ".$e->getMessage());
return response()->json([
'message' => 'Failed to generate image',
'error' => $e->getMessage(),
], 500);
}
})->name('api.display.alias');