diff --git a/app/Http/Controllers/Admin/DownloadProductController.php b/app/Http/Controllers/Admin/DownloadProductController.php new file mode 100644 index 0000000..e8e7701 --- /dev/null +++ b/app/Http/Controllers/Admin/DownloadProductController.php @@ -0,0 +1,25 @@ +DownloadProduct::latest()->get()]); } + public function store(Request $request) { + $data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:3000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'required|file|max:512000','is_published'=>'sometimes|boolean']); + $file=$request->file('package'); + $path=$file->store('download-products','local'); + DownloadProduct::create(['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'is_published'=>$request->boolean('is_published')]); + return back()->with('status','Product added.'); + } + public function update(Request $request, DownloadProduct $product) { + $data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:3000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'nullable|file|max:512000','is_published'=>'sometimes|boolean']); + $changes=['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'is_published'=>$request->boolean('is_published')]; + if ($request->hasFile('package')) { $file=$request->file('package'); $path=$file->store('download-products','local'); $old=$product->file_path; $changes+=['file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize()]; $product->update($changes); if($old) Storage::disk('local')->delete($old); } + else $product->update($changes); + return back()->with('status','Product updated.'); + } + public function destroy(DownloadProduct $product) { if($product->file_path) Storage::disk('local')->delete($product->file_path); $product->delete(); return back()->with('status','Product deleted.'); } +} diff --git a/app/Http/Controllers/ReleaseController.php b/app/Http/Controllers/ReleaseController.php index f8246af..b961246 100644 --- a/app/Http/Controllers/ReleaseController.php +++ b/app/Http/Controllers/ReleaseController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\SiteSetting; +use App\Models\DownloadProduct; use App\Models\SonarDeskRelease; use Illuminate\Http\Response; use Illuminate\Support\Facades\Storage; @@ -16,8 +17,9 @@ final class ReleaseController extends Controller ?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->first(); $releases = SonarDeskRelease::published()->orderByDesc('published_at')->paginate(12); $settings = $this->settings(); + $products = DownloadProduct::where('is_published', true)->whereNotNull('file_path')->orderBy('name')->get(); - return view('downloads.index', compact('latest', 'releases', 'settings')); + return view('downloads.index', compact('latest', 'releases', 'settings', 'products')); } public function latest(): BinaryFileResponse @@ -32,6 +34,13 @@ final class ReleaseController extends Controller return $this->send($release); } + public function productDownload(DownloadProduct $product): BinaryFileResponse + { + abort_unless($product->is_published && $product->file_path && Storage::disk('local')->exists($product->file_path), 404); + $product->increment('download_count'); + return response()->download(Storage::disk('local')->path($product->file_path), $product->original_filename); + } + public function appcast(): Response { $releases = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->limit(20)->get(); diff --git a/app/Models/DownloadProduct.php b/app/Models/DownloadProduct.php new file mode 100644 index 0000000..431156c --- /dev/null +++ b/app/Models/DownloadProduct.php @@ -0,0 +1,8 @@ +'boolean','file_size'=>'integer','download_count'=>'integer']; } + public function getFormattedSizeAttribute(): string { return $this->file_size >= 1048576 ? number_format($this->file_size/1048576,1).' MB' : number_format($this->file_size/1024,1).' KB'; } +} diff --git a/database/migrations/2026_10_12_100000_create_download_products_table.php b/database/migrations/2026_10_12_100000_create_download_products_table.php new file mode 100644 index 0000000..8368227 --- /dev/null +++ b/database/migrations/2026_10_12_100000_create_download_products_table.php @@ -0,0 +1,18 @@ +id(); $table->string('name'); $table->text('description')->nullable(); + $table->string('version')->nullable(); $table->string('platform')->nullable(); + $table->string('file_path')->nullable(); $table->string('original_filename')->nullable(); + $table->unsignedBigInteger('file_size')->default(0); $table->unsignedBigInteger('download_count')->default(0); + $table->boolean('is_published')->default(false); $table->timestamps(); + }); + } + public function down(): void { Schema::dropIfExists('download_products'); } +}; diff --git a/resources/views/admin/download-products/index.blade.php b/resources/views/admin/download-products/index.blade.php new file mode 100644 index 0000000..3ab2973 --- /dev/null +++ b/resources/views/admin/download-products/index.blade.php @@ -0,0 +1,12 @@ +@extends('layouts.app', ['title' => 'Download products — Admin']) +@section('content') +
Distribution

Other download products

Add named products and files to the public downloads area.

Back to releases
+@if(session('status'))

{{ session('status') }}

@endif +@if($errors->any())
@endif +

Add product

@csrf +

+@foreach($products as $product) +

{{ $product->name }}

{{ $product->download_count }} downloads · {{ $product->formatted_size }} · {{ $product->is_published ? 'Published' : 'Draft' }}

@csrf @method('PUT') +

@csrf @method('DELETE')
+@endforeach +@endsection diff --git a/resources/views/admin/releases/index.blade.php b/resources/views/admin/releases/index.blade.php index fa07a4b..a8caa71 100644 --- a/resources/views/admin/releases/index.blade.php +++ b/resources/views/admin/releases/index.blade.php @@ -1,6 +1,6 @@ @extends('layouts.app', ['title' => 'Releases — SonarDesk Admin']) @section('content') -
Distribution

Release management

Publish SonarDesk builds, manage the public download experience and keep Sparkle updates under control.

Download page settingsNew release
+
Distribution

Release management

Publish SonarDesk builds, manage the public download experience and keep Sparkle updates under control.

Other productsDownload page settingsNew release
{{ $releases->total() }}

Total releases

{{ $publishedCount }}

Published

{{ number_format($downloadCount) }}

Total downloads

Live

Sparkle feed

SonarDesk builds

Only published builds with an uploaded package are available publicly.

View public page
@forelse($releases as $release)@empty@endforelse
ReleaseChannelStatusPackageDownloadsPublished
SonarDesk {{ $release->version }}Build {{ $release->build_number }} · macOS {{ $release->minimum_macos ?: 'Any supported' }}{{ ucfirst($release->channel) }}@if($release->is_published)Published@elseDraft@endif{{ $release->file_path ? $release->formatted_size : 'No package' }}{{ number_format($release->download_count) }}{{ $release->published_at?->format('j M Y H:i') ?: '—' }}Edit
@csrf @method('DELETE')
No releases yet. Create your first signed SonarDesk build.
{{ $releases->links() }} diff --git a/resources/views/downloads/index.blade.php b/resources/views/downloads/index.blade.php index 0d66597..166b5b5 100644 --- a/resources/views/downloads/index.blade.php +++ b/resources/views/downloads/index.blade.php @@ -3,6 +3,9 @@
{{ $settings['eyebrow'] }}

{{ $settings['title'] }}

{{ $settings['intro'] }}

@if($latest)
✓ Signed distribution✓ SHA-256 verified✓ Secure licence activation
@else
The next SonarDesk release is being prepared.Check back shortly or contact support.
@endif
SonarDesk
SonarDesk
Project healthAll systems monitored
NativeBuilt for macOS
Native macOSFast, focused desktop workflow
SonarQubeYour server remains the source of truth
SecurityTokens protected by macOS Keychain
UpdatesProfessional in-app release delivery
@if($latest)
Latest stable release

SonarDesk {{ $latest->version }}

Build {{ $latest->build_number }} · Released {{ $latest->published_at?->format('j F Y') }}

Download {{ $latest->version }}
{!! nl2br(e($latest->release_notes ?: 'This release contains the latest SonarDesk improvements and maintenance updates.')) !!}
SHA-256{{ $latest->sha256 }}
@endif
+@if($products->isNotEmpty()) +
More downloads

Other products

Explore our other available software and downloads.

@foreach($products as $product)
{{ $product->name }}@if($product->version) {{ $product->version }}@endif{{ $product->description }}@if($product->platform){{ $product->platform }}@endif
{{ $product->formatted_size }}Download
@endforeach
+@endif
Release archive

Previous versions

Published SonarDesk builds remain available here for customers who need a previous release.

@forelse($releases as $release)
SonarDesk {{ $release->version }}Build {{ $release->build_number }} · {{ ucfirst($release->channel) }} · {{ $release->published_at?->format('j M Y') }}
{{ $release->formatted_size }}Download
@empty

No published releases are available yet.

@endforelse
{{ $releases->links() }}
Support

Installation or activation help?

{{ $settings['support_text'] }}

Contact SonarDesk support
@endsection diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 99d2e8f..32426c2 100755 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -231,6 +231,7 @@ Admin Users Releases + Download products Support @if ($supportUnread > 0) diff --git a/routes/web.php b/routes/web.php index a78dee0..f1ae5c0 100755 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\ReleaseController; +use App\Http\Controllers\Admin\DownloadProductController; use App\Http\Controllers\Support\SupportController; use App\Http\Middleware\AdminOnly; use Illuminate\Support\Facades\Route; @@ -25,6 +26,7 @@ Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\ Route::view('/features', 'features')->name('features'); Route::get('/release', [ReleaseController::class, 'index'])->name('release'); Route::get('/download', [ReleaseController::class, 'latest'])->name('download.latest'); +Route::get('/downloads/products/{product}', [ReleaseController::class, 'productDownload'])->name('download.product'); Route::get('/downloads/{release}', [ReleaseController::class, 'download'])->name('download.release'); Route::get('/updates/appcast.xml', [ReleaseController::class, 'appcast'])->name('updates.appcast'); Route::get('/support', [SupportController::class, 'create'])->name('support'); @@ -74,6 +76,10 @@ Route::middleware(['auth', 'verified', AdminOnly::class])->prefix('admin')->name Route::get('/cms/{page}/edit', [AdminCmsPageController::class, 'edit'])->name('cms.edit'); Route::put('/cms/{page}', [AdminCmsPageController::class, 'update'])->name('cms.update'); Route::delete('/cms/{page}', [AdminCmsPageController::class, 'destroy'])->name('cms.destroy'); + Route::get('/download-products', [DownloadProductController::class, 'index'])->name('download-products.index'); + Route::post('/download-products', [DownloadProductController::class, 'store'])->name('download-products.store'); + Route::put('/download-products/{product}', [DownloadProductController::class, 'update'])->name('download-products.update'); + Route::delete('/download-products/{product}', [DownloadProductController::class, 'destroy'])->name('download-products.destroy'); Route::get('/releases', [AdminReleaseController::class, 'index'])->name('releases.index'); Route::get('/releases/create', [AdminReleaseController::class, 'create'])->name('releases.create'); Route::post('/releases', [AdminReleaseController::class, 'store'])->name('releases.store');