From 297a17d00b9b203f71a1bdff510deced59368491 Mon Sep 17 00:00:00 2001 From: Benjamin Nussbaum Date: Fri, 16 Jan 2026 13:39:11 +0100 Subject: [PATCH] feat: add update page, refactor update checking process --- app/Jobs/CheckVersionUpdateJob.php | 216 ++++++++ app/Settings/UpdateSettings.php | 15 + composer.json | 1 + composer.lock | 499 ++++++++++-------- config/app.php | 2 + config/settings.php | 94 ++++ ...026_01_16_083707_create_settings_table.php | 24 + ...26_01_16_102043_create_update_settings.php | 11 + resources/views/layouts/app/header.blade.php | 6 + .../views/livewire/update-badge.blade.php | 48 ++ .../views/livewire/update-check.blade.php | 66 +++ .../views/pages/settings/layout.blade.php | 1 + .../views/pages/settings/update.blade.php | 258 +++++++++ resources/views/welcome.blade.php | 35 +- routes/settings.php | 1 + .../Jobs/CheckVersionUpdateJobTest.php | 147 ++++++ tests/Feature/Settings/UpdateSettingsTest.php | 29 + 17 files changed, 1212 insertions(+), 241 deletions(-) create mode 100644 app/Jobs/CheckVersionUpdateJob.php create mode 100644 app/Settings/UpdateSettings.php create mode 100644 config/settings.php create mode 100644 database/migrations/2026_01_16_083707_create_settings_table.php create mode 100644 database/settings/2026_01_16_102043_create_update_settings.php create mode 100644 resources/views/livewire/update-badge.blade.php create mode 100644 resources/views/livewire/update-check.blade.php create mode 100644 resources/views/pages/settings/update.blade.php create mode 100644 tests/Feature/Jobs/CheckVersionUpdateJobTest.php create mode 100644 tests/Feature/Settings/UpdateSettingsTest.php diff --git a/app/Jobs/CheckVersionUpdateJob.php b/app/Jobs/CheckVersionUpdateJob.php new file mode 100644 index 0000000..0794688 --- /dev/null +++ b/app/Jobs/CheckVersionUpdateJob.php @@ -0,0 +1,216 @@ +errorResponse(); + } + + $backoffUntil = Cache::get(self::BACKOFF_KEY); + if ($this->isInBackoffPeriod($backoffUntil)) { + return $this->rateLimitResponse($backoffUntil); + } + + $cachedResponse = Cache::get(self::CACHE_KEY); + $response = $this->fetchOrUseCache($cachedResponse, $updateSettings->prereleases, $backoffUntil); + + if (! $response) { + return $this->errorResponse('fetch_failed'); + } + + [$latestVersion, $releaseData] = $this->extractLatestVersion($response, $updateSettings->prereleases); + $isNewer = $latestVersion && version_compare($latestVersion, $currentVersion, '>'); + + return [ + 'latest_version' => $latestVersion, + 'is_newer' => $isNewer, + 'release_data' => $releaseData, + 'error' => null, + ]; + } catch (ConnectionException $e) { + Log::error('Version check failed: '.$e->getMessage()); + + return $this->errorResponse('connection_failed'); + } catch (Exception $e) { + Log::error('Unexpected error in version check: '.$e->getMessage()); + + return $this->errorResponse('unexpected_error'); + } + } + + private function isInBackoffPeriod(?\Illuminate\Support\Carbon $backoffUntil): bool + { + return $backoffUntil !== null && now()->isBefore($backoffUntil); + } + + private function rateLimitResponse(\Illuminate\Support\Carbon $backoffUntil): array + { + return [ + 'latest_version' => null, + 'is_newer' => false, + 'release_data' => null, + 'error' => 'rate_limit', + 'backoff_until' => $backoffUntil->timestamp, + ]; + } + + private function errorResponse(?string $error = null): array + { + return [ + 'latest_version' => null, + 'is_newer' => false, + 'release_data' => null, + 'error' => $error, + ]; + } + + private function fetchOrUseCache(?array $cachedResponse, bool $enablePrereleases, ?\Illuminate\Support\Carbon $backoffUntil): ?array + { + if ($cachedResponse && ! $this->forceRefresh) { + return $cachedResponse; + } + + if ($this->isInBackoffPeriod($backoffUntil)) { + return $cachedResponse; + } + + try { + $httpResponse = $this->fetchReleases($enablePrereleases); + + if ($httpResponse->status() === 429) { + return $this->handleRateLimit($cachedResponse); + } + + if ($httpResponse->successful()) { + $responseData = $httpResponse->json(); + Cache::put(self::CACHE_KEY, $responseData, self::CACHE_TTL); + + return $responseData; + } + + Log::warning('GitHub API request failed', [ + 'status' => $httpResponse->status(), + 'body' => $httpResponse->body(), + ]); + + return $cachedResponse; + } catch (ConnectionException $e) { + Log::debug('Failed to fetch releases: '.$e->getMessage()); + + return $cachedResponse ?? null; + } catch (Exception $e) { + Log::debug('Failed to fetch releases: '.$e->getMessage()); + + return $cachedResponse ?? null; + } + } + + private function fetchReleases(bool $enablePrereleases) + { + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + $endpoint = $enablePrereleases ? "{$apiBaseUrl}/releases" : "{$apiBaseUrl}/releases/latest"; + + return Http::timeout(10)->connectTimeout(5)->get($endpoint); + } + + private function handleRateLimit(?array $cachedResponse): ?array + { + $backoffUntil = now()->addMinutes(self::BACKOFF_MINUTES); + Cache::put(self::BACKOFF_KEY, $backoffUntil, 600); + Log::warning('GitHub API rate limit exceeded. Backing off for 10 minutes.'); + + return $cachedResponse; + } + + private function extractLatestVersion(array $response, bool $enablePrereleases): array + { + if (! $enablePrereleases || ! is_array($response) || ! isset($response[0])) { + return [ + Arr::get($response, 'tag_name'), + $response, + ]; + } + + [$stableRelease, $prerelease] = $this->findReleases($response); + + if ($prerelease && $stableRelease) { + $prereleaseVersion = Arr::get($prerelease, 'tag_name'); + $stableVersion = Arr::get($stableRelease, 'tag_name'); + + if (version_compare($prereleaseVersion, $stableVersion, '>')) { + return [$prereleaseVersion, $prerelease]; + } + + return [$stableVersion, $stableRelease]; + } + + if ($prerelease) { + return [Arr::get($prerelease, 'tag_name'), $prerelease]; + } + + if ($stableRelease) { + return [Arr::get($stableRelease, 'tag_name'), $stableRelease]; + } + + return [null, null]; + } + + private function findReleases(array $allReleases): array + { + $stableRelease = null; + $prerelease = null; + + foreach ($allReleases as $release) { + $tagName = Arr::get($release, 'tag_name'); + if (! $tagName) { + continue; + } + + $isPrerelease = (bool) Arr::get($release, 'prerelease', false); + + if ($isPrerelease && ! $prerelease) { + $prerelease = $release; + } elseif (! $isPrerelease && ! $stableRelease) { + $stableRelease = $release; + } + + if ($stableRelease && $prerelease) { + break; + } + } + + return [$stableRelease, $prerelease]; + } +} diff --git a/app/Settings/UpdateSettings.php b/app/Settings/UpdateSettings.php new file mode 100644 index 0000000..d3ab45c --- /dev/null +++ b/app/Settings/UpdateSettings.php @@ -0,0 +1,15 @@ +=13" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^13", + "phpstan/phpstan": "1.4.10 || 2.1.11", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + }, + "time": "2025-04-07T20:06:18+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -4041,6 +4089,117 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "1.12.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", + "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.3 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^1.18|^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.1", + "phpunit/phpunit": "^9.5", + "rector/rector": "^0.13.9", + "vimeo/psalm": "^4.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" + }, + "time": "2025-11-21T15:09:14+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -4226,6 +4385,53 @@ ], "time": "2025-12-15T11:51:42+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.1", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" + }, + "time": "2026-01-12T11:33:04+00:00" + }, { "name": "pragmarx/google2fa", "version": "v9.0.0", @@ -5096,6 +5302,91 @@ ], "time": "2025-07-17T15:46:43+00:00" }, + { + "name": "spatie/laravel-settings", + "version": "3.6.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-settings.git", + "reference": "fae93dadb8f748628ecaf5710f494adf790255b2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-settings/zipball/fae93dadb8f748628ecaf5710f494adf790255b2", + "reference": "fae93dadb8f748628ecaf5710f494adf790255b2", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/database": "^11.0|^12.0", + "php": "^8.2", + "phpdocumentor/type-resolver": "^1.5", + "spatie/temporary-directory": "^1.3|^2.0" + }, + "require-dev": { + "ext-redis": "*", + "mockery/mockery": "^1.4", + "orchestra/testbench": "^9.0|^10.0", + "pestphp/pest": "^2.0|^3.0", + "pestphp/pest-plugin-laravel": "^2.0|^3.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "spatie/laravel-data": "^2.0.0|^4.0.0", + "spatie/pest-plugin-snapshots": "^2.0", + "spatie/phpunit-snapshot-assertions": "^4.2|^5.0", + "spatie/ray": "^1.36" + }, + "suggest": { + "spatie/data-transfer-object": "Allows for DTO casting to settings. (deprecated)" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\LaravelSettings\\LaravelSettingsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelSettings\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ruben Van Assche", + "email": "ruben@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Store your application settings", + "homepage": "https://github.com/spatie/laravel-settings", + "keywords": [ + "laravel-settings", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-settings/issues", + "source": "https://github.com/spatie/laravel-settings/tree/3.6.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-12-03T10:29:27+00:00" + }, { "name": "spatie/temporary-directory", "version": "2.3.1", @@ -8336,54 +8627,6 @@ ], "time": "2026-01-08T07:23:06+00:00" }, - { - "name": "doctrine/deprecations", - "version": "1.1.5", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", - "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" - }, - "time": "2025-04-07T20:06:18+00:00" - }, { "name": "fakerphp/faker", "version": "v1.24.1", @@ -10121,59 +10364,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, { "name": "phpdocumentor/reflection-docblock", "version": "5.6.6", @@ -10238,111 +10428,6 @@ }, "time": "2025-12-22T21:13:58+00:00" }, - { - "name": "phpdocumentor/type-resolver", - "version": "1.12.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-1.x": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - } - ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" - }, - "time": "2025-11-21T15:09:14+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "2.3.1", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/16dbf9937da8d4528ceb2145c9c7c0bd29e26374", - "reference": "16dbf9937da8d4528ceb2145c9c7c0bd29e26374", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.1" - }, - "time": "2026-01-12T11:33:04+00:00" - }, { "name": "phpstan/phpstan", "version": "2.1.33", diff --git a/config/app.php b/config/app.php index c7cb051..6a47a72 100644 --- a/config/app.php +++ b/config/app.php @@ -153,4 +153,6 @@ return [ 'version' => env('APP_VERSION', null), 'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'), + + 'github_repo' => env('GITHUB_REPO', 'usetrmnl/byos_laravel'), ]; diff --git a/config/settings.php b/config/settings.php new file mode 100644 index 0000000..1065503 --- /dev/null +++ b/config/settings.php @@ -0,0 +1,94 @@ + [ + + ], + + /* + * The path where the settings classes will be created. + */ + 'setting_class_path' => app_path('Settings'), + + /* + * In these directories settings migrations will be stored and ran when migrating. A settings + * migration created via the make:settings-migration command will be stored in the first path or + * a custom defined path when running the command. + */ + 'migrations_paths' => [ + database_path('settings'), + ], + + /* + * When no repository was set for a settings class the following repository + * will be used for loading and saving settings. + */ + 'default_repository' => 'database', + + /* + * Settings will be stored and loaded from these repositories. + */ + 'repositories' => [ + 'database' => [ + 'type' => Spatie\LaravelSettings\SettingsRepositories\DatabaseSettingsRepository::class, + 'model' => null, + 'table' => null, + 'connection' => null, + ], + 'redis' => [ + 'type' => Spatie\LaravelSettings\SettingsRepositories\RedisSettingsRepository::class, + 'connection' => null, + 'prefix' => null, + ], + ], + + /* + * The encoder and decoder will determine how settings are stored and + * retrieved in the database. By default, `json_encode` and `json_decode` + * are used. + */ + 'encoder' => null, + 'decoder' => null, + + /* + * The contents of settings classes can be cached through your application, + * settings will be stored within a provided Laravel store and can have an + * additional prefix. + */ + 'cache' => [ + 'enabled' => env('SETTINGS_CACHE_ENABLED', false), + 'store' => null, + 'prefix' => null, + 'ttl' => null, + ], + + /* + * These global casts will be automatically used whenever a property within + * your settings class isn't a default PHP type. + */ + 'global_casts' => [ + DateTimeInterface::class => Spatie\LaravelSettings\SettingsCasts\DateTimeInterfaceCast::class, + DateTimeZone::class => Spatie\LaravelSettings\SettingsCasts\DateTimeZoneCast::class, + // Spatie\DataTransferObject\DataTransferObject::class => Spatie\LaravelSettings\SettingsCasts\DtoCast::class, + Spatie\LaravelData\Data::class => Spatie\LaravelSettings\SettingsCasts\DataCast::class, + ], + + /* + * The package will look for settings in these paths and automatically + * register them. + */ + 'auto_discover_settings' => [ + app_path('Settings'), + ], + + /* + * Automatically discovered settings classes can be cached, so they don't + * need to be searched each time the application boots up. + */ + 'discovered_settings_cache_path' => base_path('bootstrap/cache'), +]; diff --git a/database/migrations/2026_01_16_083707_create_settings_table.php b/database/migrations/2026_01_16_083707_create_settings_table.php new file mode 100644 index 0000000..9b14b86 --- /dev/null +++ b/database/migrations/2026_01_16_083707_create_settings_table.php @@ -0,0 +1,24 @@ +id(); + + $table->string('group'); + $table->string('name'); + $table->boolean('locked')->default(false); + $table->json('payload'); + + $table->timestamps(); + + $table->unique(['group', 'name']); + }); + } +}; diff --git a/database/settings/2026_01_16_102043_create_update_settings.php b/database/settings/2026_01_16_102043_create_update_settings.php new file mode 100644 index 0000000..02be86d --- /dev/null +++ b/database/settings/2026_01_16_102043_create_update_settings.php @@ -0,0 +1,11 @@ +migrator->add('update.prereleases', false); + } +}; diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php index 7a9f2c0..740e3b1 100644 --- a/resources/views/layouts/app/header.blade.php +++ b/resources/views/layouts/app/header.blade.php @@ -70,6 +70,12 @@ Settings Support + +
+ Updates + +
+
diff --git a/resources/views/livewire/update-badge.blade.php b/resources/views/livewire/update-badge.blade.php new file mode 100644 index 0000000..c9e9853 --- /dev/null +++ b/resources/views/livewire/update-badge.blade.php @@ -0,0 +1,48 @@ +checkForUpdate(); + } + + public function checkForUpdate(): void + { + $currentVersion = config('app.version'); + if (! $currentVersion) { + return; + } + + $response = Cache::get('latest_release'); + if (! $response) { + return; + } + + // Handle both single release object and array of releases + if (is_array($response) && isset($response[0])) { + // Array of releases - find the latest one + $latestRelease = $response[0]; + $latestVersion = Arr::get($latestRelease, 'tag_name'); + } else { + // Single release object + $latestVersion = Arr::get($response, 'tag_name'); + } + + if ($latestVersion && version_compare($latestVersion, $currentVersion, '>')) { + $this->hasUpdate = true; + } + } +} ?> + + + @if($hasUpdate) + + @endif + diff --git a/resources/views/livewire/update-check.blade.php b/resources/views/livewire/update-check.blade.php new file mode 100644 index 0000000..4dd8a49 --- /dev/null +++ b/resources/views/livewire/update-check.blade.php @@ -0,0 +1,66 @@ +processCachedResponse($cachedResponse, $currentVersion); + } else { + // Defer job in background using dispatchAfterResponse + CheckVersionUpdateJob::dispatchAfterResponse(); + } + } + + private function processCachedResponse($response, string $currentVersion): void + { + $latestVersion = null; + + // Handle both single release object and array of releases + if (is_array($response) && isset($response[0])) { + // Array of releases - find the latest one + $latestRelease = $response[0]; + $latestVersion = Arr::get($latestRelease, 'tag_name'); + $this->releaseData = $latestRelease; + } else { + // Single release object + $latestVersion = Arr::get($response, 'tag_name'); + $this->releaseData = $response; + } + + if ($latestVersion && version_compare($latestVersion, $currentVersion, '>')) { + $this->latestVersion = $latestVersion; + $this->isUpdateAvailable = true; + } + } +} ?> + +
+ @if(config('app.version') && $isUpdateAvailable && $latestVersion) + + Update available + + There is a newer version {{ $latestVersion }} available. Update to the latest version for the best experience. + Release notes + + + @endif +
diff --git a/resources/views/pages/settings/layout.blade.php b/resources/views/pages/settings/layout.blade.php index d3667d6..0ad8019 100644 --- a/resources/views/pages/settings/layout.blade.php +++ b/resources/views/pages/settings/layout.blade.php @@ -8,6 +8,7 @@ {{ __('Password') }} @endif {{ __('Support') }} + {{ __('Updates') }} diff --git a/resources/views/pages/settings/update.blade.php b/resources/views/pages/settings/update.blade.php new file mode 100644 index 0000000..f613c29 --- /dev/null +++ b/resources/views/pages/settings/update.blade.php @@ -0,0 +1,258 @@ +updateSettings = $updateSettings; + } + + public function mount(): void + { + $this->prereleases = $this->updateSettings->prereleases; + $this->loadFromCache(); + } + + private function loadFromCache(): void + { + $currentVersion = config('app.version'); + if (! $currentVersion) { + return; + } + + // Load from cache without fetching + $cachedRelease = Cache::get('latest_release'); + if ($cachedRelease) { + $this->processCachedRelease($cachedRelease, $currentVersion); + } + } + + private function processCachedRelease($cachedRelease, string $currentVersion): void + { + $latestVersion = null; + $releaseData = null; + + // Handle both single release object and array of releases + if (is_array($cachedRelease) && isset($cachedRelease[0])) { + // Array of releases - find the latest one + $releaseData = $cachedRelease[0]; + $latestVersion = Arr::get($releaseData, 'tag_name'); + } else { + // Single release object + $releaseData = $cachedRelease; + $latestVersion = Arr::get($releaseData, 'tag_name'); + } + + if ($latestVersion) { + $this->latestVersion = $latestVersion; + $this->isUpdateAvailable = version_compare($latestVersion, $currentVersion, '>'); + $this->releaseUrl = Arr::get($releaseData, 'html_url'); + $this->loadReleaseNotes(); + } + } + + public function checkForUpdates(): void + { + $this->isChecking = true; + $this->errorMessage = null; + $this->backoffUntil = null; + + try { + $result = CheckVersionUpdateJob::dispatchSync(); + + $this->latestVersion = $result['latest_version']; + $this->isUpdateAvailable = $result['is_newer']; + $this->releaseUrl = Arr::get($result['release_data'] ?? [], 'html_url'); + + // Handle errors + if (isset($result['error'])) { + if ($result['error'] === 'rate_limit') { + $this->backoffUntil = $result['backoff_until'] ?? null; + $this->errorMessage = 'GitHub API rate limit exceeded. Please try again later.'; + } elseif ($result['error'] === 'connection_failed') { + $this->errorMessage = 'Request timed out or failed to connect to GitHub. Please check your internet connection and try again.'; + } elseif ($result['error'] === 'fetch_failed') { + $this->errorMessage = 'Failed to fetch update information from GitHub. Please try again later.'; + } else { + $this->errorMessage = 'An unexpected error occurred while checking for updates. Please try again later.'; + } + } else { + // Reload release notes if we have a new version + if ($this->latestVersion) { + $this->loadReleaseNotes(); + } + } + } catch (Illuminate\Http\Client\ConnectionException $e) { + $this->errorMessage = 'Request timed out or failed to connect to GitHub. Please check your internet connection and try again.'; + Log::error('Update check connection failed: '.$e->getMessage()); + } catch (Exception $e) { + $this->errorMessage = 'Request timed out or failed. Please check your internet connection and try again.'; + Log::error('Update check failed: '.$e->getMessage()); + } finally { + $this->isChecking = false; + } + } + + public function updatedPrereleases(): void + { + $this->updateSettings->prereleases = $this->prereleases; + $this->updateSettings->save(); + + // Clear cache and recheck for updates with new preference + Cache::forget('latest_release'); + $this->checkForUpdates(); + } + + public function loadReleaseNotes(): void + { + if (! $this->latestVersion) { + return; + } + + $cacheKey = "release_notes_{$this->latestVersion}"; + $currentVersionKey = 'release_notes_current_version'; + + // Check if we have a previous version cached + $previousVersion = Cache::get($currentVersionKey); + + // Clean up old version cache if different + if ($previousVersion && $previousVersion !== $this->latestVersion) { + Cache::forget("release_notes_{$previousVersion}"); + } + + // Try to get from cache first - always load from cache if available + $cachedNotes = Cache::get($cacheKey); + if ($cachedNotes) { + $this->releaseNotes = $cachedNotes; + // Update current version tracker + Cache::put($currentVersionKey, $this->latestVersion, 86400); + + return; + } + + // Fetch release notes if we have a version but no cache + // This will fetch on mount if an update is available, or when explicitly checking + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + + try { + // Fetch release notes for the specific version with HTML format + $response = Http::withHeaders([ + 'Accept' => 'application/vnd.github.v3.html+json', + ])->timeout(10)->get("{$apiBaseUrl}/releases/tags/{$this->latestVersion}"); + if ($response->successful()) { + $releaseData = $response->json(); + $bodyHtml = Arr::get($releaseData, 'body_html'); + + if ($bodyHtml) { + // Cache for 24 hours + Cache::put($cacheKey, $bodyHtml, 86400); + $this->releaseNotes = $bodyHtml; + } + } + } catch (Exception $e) { + Log::debug('Failed to fetch release notes: '.$e->getMessage()); + } + + // Update current version tracker + Cache::put($currentVersionKey, $this->latestVersion, 86400); + } +} ?> + +
+ @include('partials.settings-heading') + + +
+ @if(config('app.version')) +
+
+ Current Version + + + {{ config('app.version') }} + + +
+ + Check for Updates + +
+ @endif + +
+ +
+ +
+ @if($errorMessage) + + Error + + {{ $errorMessage }} + @if($backoffUntil) +
You can try again after {{ \Carbon\Carbon::createFromTimestamp($backoffUntil)->format('H:i') }}. + @endif +
+
+ @elseif($isUpdateAvailable && $latestVersion) + + Update Available + + A newer version {{ $latestVersion }} is available. Update to the latest version for the best experience. + + @if($releaseNotes) +
+ {!! $releaseNotes !!} +
+ @endif + @if($releaseUrl) +
+ + View on GitHub + +
+ @endif +
+ @elseif($latestVersion && !$isUpdateAvailable) + + Up to Date + + You are running the latest version {{ $latestVersion }}. + + + @endif +
+
+
+
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 7b4cba9..100b57b 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -32,39 +32,6 @@ @endif @auth - @if(config('app.version')) - Version: {{ config('app.version') }} - - - @php - $response = Cache::remember('latest_release', 86400, function () { - try { - $response = Http::get('https://api.github.com/repos/usetrmnl/byos_laravel/releases/latest'); - if ($response->successful()) { - return $response->json(); - } - } catch (\Exception $e) { - Log::debug('Failed to fetch latest release: ' . $e->getMessage()); - } - return null; - }); - $latestVersion = Arr::get($response, 'tag_name'); - - if ($latestVersion && version_compare($latestVersion, config('app.version'), '>')) { - $newVersion = $latestVersion; - } - @endphp - - @if(isset($newVersion)) - - Update available - - There is a newer version {{ $newVersion }} available. Update to the latest version for the best experience. - Release notes - - - @endif - @endif + @endauth diff --git a/routes/settings.php b/routes/settings.php index e575755..96f1201 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -20,4 +20,5 @@ Route::middleware(['auth'])->group(function () { ) ->name('two-factor.show'); Route::livewire('settings/support', 'pages::settings.support')->name('settings.support'); + Route::livewire('settings/update', 'pages::settings.update')->name('settings.update'); }); diff --git a/tests/Feature/Jobs/CheckVersionUpdateJobTest.php b/tests/Feature/Jobs/CheckVersionUpdateJobTest.php new file mode 100644 index 0000000..b37a8b3 --- /dev/null +++ b/tests/Feature/Jobs/CheckVersionUpdateJobTest.php @@ -0,0 +1,147 @@ + '1.0.0']); +}); + +test('it returns latest version when update is available', function (): void { + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + + Http::fake([ + "{$apiBaseUrl}/releases/latest" => Http::response([ + 'tag_name' => '1.1.0', + 'body' => 'Release notes', + ], 200), + ]); + + $result = (new CheckVersionUpdateJob)->handle(app(UpdateSettings::class)); + + expect($result) + ->latest_version->toBe('1.1.0') + ->is_newer->toBeTrue() + ->release_data->not->toBeNull(); +}); + +test('it returns false when no update is available', function (): void { + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + + Http::fake([ + "{$apiBaseUrl}/releases/latest" => Http::response([ + 'tag_name' => '1.0.0', + 'body' => 'Release notes', + ], 200), + ]); + + $result = (new CheckVersionUpdateJob)->handle(app(UpdateSettings::class)); + + expect($result) + ->latest_version->toBe('1.0.0') + ->is_newer->toBeFalse(); +}); + +test('it caches the release data', function (): void { + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + + Http::fake([ + "{$apiBaseUrl}/releases/latest" => Http::response([ + 'tag_name' => '1.1.0', + 'body' => 'Release notes', + ], 200), + ]); + + (new CheckVersionUpdateJob)->handle(app(UpdateSettings::class)); + + expect(Cache::has('latest_release'))->toBeTrue(); +}); + +test('it forces refresh when forceRefresh is true', function (): void { + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + + Http::fake([ + "{$apiBaseUrl}/releases/latest" => Http::sequence() + ->push([ + 'tag_name' => '1.1.0', + 'body' => 'Release notes', + ], 200) + ->push([ + 'tag_name' => '1.2.0', + 'body' => 'New release notes', + ], 200), + ]); + + // First call caches + (new CheckVersionUpdateJob)->handle(app(UpdateSettings::class)); + + // Second call with force refresh should make new request + $result = (new CheckVersionUpdateJob(true))->handle(app(UpdateSettings::class)); + + expect($result['latest_version'])->toBe('1.2.0'); + Http::assertSentCount(2); +}); + +test('it handles pre-releases when enabled', function (): void { + $updateSettings = app(UpdateSettings::class); + $updateSettings->prereleases = true; + $updateSettings->save(); + + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + + Http::fake([ + "{$apiBaseUrl}/releases" => Http::response([ + [ + 'tag_name' => '1.2.0-beta', + 'body' => 'Beta release', + 'prerelease' => true, + ], + [ + 'tag_name' => '1.1.0', + 'body' => 'Stable release', + 'prerelease' => false, + ], + ], 200), + ]); + + $result = (new CheckVersionUpdateJob)->handle($updateSettings); + + // Should prefer pre-release if newer + expect($result['latest_version'])->toBe('1.2.0-beta'); +}); + +test('it returns null when no version is configured', function (): void { + config(['app.version' => null]); + + $result = (new CheckVersionUpdateJob)->handle(app(UpdateSettings::class)); + + expect($result) + ->latest_version->toBeNull() + ->is_newer->toBeFalse() + ->release_data->toBeNull(); +}); + +test('it handles API failures gracefully', function (): void { + $githubRepo = config('app.github_repo'); + $apiBaseUrl = "https://api.github.com/repos/{$githubRepo}"; + + Http::fake([ + "{$apiBaseUrl}/releases/latest" => Http::response([], 500), + ]); + + $result = (new CheckVersionUpdateJob)->handle(app(UpdateSettings::class)); + + expect($result) + ->latest_version->toBeNull() + ->is_newer->toBeFalse() + ->release_data->toBeNull(); +}); diff --git a/tests/Feature/Settings/UpdateSettingsTest.php b/tests/Feature/Settings/UpdateSettingsTest.php new file mode 100644 index 0000000..66a2b79 --- /dev/null +++ b/tests/Feature/Settings/UpdateSettingsTest.php @@ -0,0 +1,29 @@ +prereleases)->toBeFalse(); +}); + +test('it can update prereleases', function (): void { + $settings = app(UpdateSettings::class); + $settings->prereleases = true; + $settings->save(); + + $settings->refresh(); + + expect($settings->prereleases)->toBeTrue(); +}); + +test('it persists prereleases across instances', function (): void { + $settings1 = app(UpdateSettings::class); + $settings1->prereleases = true; + $settings1->save(); + + $settings2 = app(UpdateSettings::class); + + expect($settings2->prereleases)->toBeTrue(); +});