diff --git a/.idea/SonarDesk.iml b/.idea/SonarDesk.iml index 2be73f3..0fd1146 100644 --- a/.idea/SonarDesk.iml +++ b/.idea/SonarDesk.iml @@ -5,6 +5,9 @@ + + + diff --git a/sonar-desk-licensing/app/Http/Controllers/Admin/ReleaseController.php b/sonar-desk-licensing/app/Http/Controllers/Admin/ReleaseController.php new file mode 100644 index 0000000..2d2b5e2 --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/Admin/ReleaseController.php @@ -0,0 +1,101 @@ + SonarDeskRelease::latest()->paginate(30), + 'publishedCount' => SonarDeskRelease::where('is_published', true)->count(), + 'downloadCount' => SonarDeskRelease::sum('download_count'), + ]); + } + + public function create() { return view('admin.releases.form', ['release' => new SonarDeskRelease]); } + public function edit(SonarDeskRelease $release) { return view('admin.releases.form', compact('release')); } + + public function store(Request $request): RedirectResponse + { + $release = new SonarDeskRelease; + $this->persist($request, $release); + return redirect()->route('admin.releases.index')->with('status', 'Release created.'); + } + + public function update(Request $request, SonarDeskRelease $release): RedirectResponse + { + $this->persist($request, $release); + return redirect()->route('admin.releases.index')->with('status', 'Release updated.'); + } + + public function destroy(SonarDeskRelease $release): RedirectResponse + { + if ($release->file_path) Storage::disk('local')->delete($release->file_path); + $release->delete(); + return back()->with('status', 'Release deleted.'); + } + + public function settings() + { + $keys = ['downloads.eyebrow','downloads.title','downloads.intro','downloads.requirements','downloads.support_text']; + $settings = collect($keys)->mapWithKeys(fn ($key) => [$key => SiteSetting::valueFor($key)]); + return view('admin.releases.settings', compact('settings')); + } + + public function updateSettings(Request $request): RedirectResponse + { + $data = $request->validate([ + 'eyebrow' => ['required','string','max:80'], 'title' => ['required','string','max:120'], + 'intro' => ['required','string','max:500'], 'requirements' => ['required','string','max:500'], + 'support_text' => ['required','string','max:500'], + ]); + foreach ($data as $key => $value) SiteSetting::put('downloads.'.$key, $value); + return back()->with('status', 'Download page content updated.'); + } + + private function persist(Request $request, SonarDeskRelease $release): void + { + $data = $request->validate([ + 'version' => ['required','string','max:40'], 'build_number' => ['required','integer','min:1'], + 'channel' => ['required', Rule::in(['stable','beta'])], 'minimum_macos' => ['nullable','string','max:30'], + 'release_notes' => ['nullable','string','max:20000'], 'sparkle_signature' => ['nullable','string','max:2000'], + 'package' => [ + $release->exists ? 'nullable' : 'required', + 'file', + 'max:1048576', + function (string $attribute, $value, \Closure $fail): void { + $extension = strtolower($value->getClientOriginalExtension()); + if (! in_array($extension, ['dmg', 'zip'], true)) { + $fail('The package must be a DMG or ZIP file.'); + } + }, + ], + 'is_published' => ['nullable','boolean'], 'is_featured' => ['nullable','boolean'], + ]); + $data['is_published'] = $request->boolean('is_published'); + $data['is_featured'] = $request->boolean('is_featured'); + if ($data['is_featured']) SonarDeskRelease::query()->update(['is_featured' => false]); + + if ($request->hasFile('package')) { + if ($release->file_path) Storage::disk('local')->delete($release->file_path); + $file = $request->file('package'); + $path = $file->store('sonardesk/releases', 'local'); + $data['file_path'] = $path; + $data['original_filename'] = $file->getClientOriginalName(); + $data['file_size'] = $file->getSize(); + $data['sha256'] = hash_file('sha256', $file->getRealPath()); + } + unset($data['package']); + $data['published_at'] = $data['is_published'] ? ($release->published_at ?: now()) : null; + $release->fill($data)->save(); + } +} diff --git a/sonar-desk-licensing/app/Http/Controllers/CheckoutController.php b/sonar-desk-licensing/app/Http/Controllers/CheckoutController.php index 47a5f82..9c8d5c5 100755 --- a/sonar-desk-licensing/app/Http/Controllers/CheckoutController.php +++ b/sonar-desk-licensing/app/Http/Controllers/CheckoutController.php @@ -17,10 +17,10 @@ final class CheckoutController extends Controller } $plans = [ - 'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£2.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1], - 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£24.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1], - 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3], - 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£44.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3], + 'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1], + 'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£39.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1], + 'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£8.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3], + 'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£79.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3], ]; foreach ($plans as $plan) { diff --git a/sonar-desk-licensing/app/Http/Controllers/ReleaseController.php b/sonar-desk-licensing/app/Http/Controllers/ReleaseController.php new file mode 100644 index 0000000..f8246af --- /dev/null +++ b/sonar-desk-licensing/app/Http/Controllers/ReleaseController.php @@ -0,0 +1,58 @@ +where('is_featured', true)->orderByDesc('published_at')->first() + ?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->first(); + $releases = SonarDeskRelease::published()->orderByDesc('published_at')->paginate(12); + $settings = $this->settings(); + + return view('downloads.index', compact('latest', 'releases', 'settings')); + } + + public function latest(): BinaryFileResponse + { + $release = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->firstOrFail(); + return $this->send($release); + } + + public function download(SonarDeskRelease $release): BinaryFileResponse + { + abort_unless($release->is_published && $release->published_at && $release->file_path, 404); + return $this->send($release); + } + + public function appcast(): Response + { + $releases = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->limit(20)->get(); + return response()->view('downloads.appcast', compact('releases'), 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']); + } + + private function send(SonarDeskRelease $release): BinaryFileResponse + { + abort_unless(Storage::disk('local')->exists($release->file_path), 404); + $release->increment('download_count'); + return response()->download(Storage::disk('local')->path($release->file_path), $release->original_filename ?: 'SonarDesk-'.$release->version.'.dmg'); + } + + private function settings(): array + { + return [ + 'eyebrow' => SiteSetting::valueFor('downloads.eyebrow', 'SonarDesk for macOS'), + 'title' => SiteSetting::valueFor('downloads.title', 'Download SonarDesk'), + 'intro' => SiteSetting::valueFor('downloads.intro', 'A focused native SonarQube workspace, designed for macOS and built for daily engineering work.'), + 'requirements' => SiteSetting::valueFor('downloads.requirements', 'Apple silicon and Intel Macs supported. A SonarQube server and valid SonarDesk licence are required.'), + 'support_text' => SiteSetting::valueFor('downloads.support_text', 'Need help installing or activating SonarDesk? Our support team can help.'), + ]; + } +} diff --git a/sonar-desk-licensing/app/Models/SiteSetting.php b/sonar-desk-licensing/app/Models/SiteSetting.php new file mode 100644 index 0000000..83af974 --- /dev/null +++ b/sonar-desk-licensing/app/Models/SiteSetting.php @@ -0,0 +1,26 @@ + static::query()->find($key)?->value ?? $default); + } + + public static function put(string $key, ?string $value): void + { + static::query()->updateOrCreate(['key' => $key], ['value' => $value]); + Cache::forget('sonardesk.setting.'.$key); + } +} diff --git a/sonar-desk-licensing/app/Models/SonarDeskRelease.php b/sonar-desk-licensing/app/Models/SonarDeskRelease.php new file mode 100644 index 0000000..df4dbb2 --- /dev/null +++ b/sonar-desk-licensing/app/Models/SonarDeskRelease.php @@ -0,0 +1,40 @@ + 'boolean', + 'is_featured' => 'boolean', + 'published_at' => 'datetime', + 'file_size' => 'integer', + 'download_count' => 'integer', + ]; + } + + public function scopePublished($query) + { + return $query->where('is_published', true)->whereNotNull('file_path')->whereNotNull('published_at'); + } + + public function getFormattedSizeAttribute(): string + { + $bytes = (int) $this->file_size; + if ($bytes < 1024) return $bytes.' B'; + if ($bytes < 1048576) return number_format($bytes / 1024, 1).' KB'; + if ($bytes < 1073741824) return number_format($bytes / 1048576, 1).' MB'; + return number_format($bytes / 1073741824, 2).' GB'; + } +} diff --git a/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php b/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php index 38a2c05..0c1e584 100755 --- a/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php +++ b/sonar-desk-licensing/app/Services/Licensing/LicenseDeliveryService.php @@ -38,6 +38,7 @@ final class LicenseDeliveryService 'licenseKey' => $plain, 'expiresAt' => $license->expires_at, 'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'), + 'downloadUrl' => route('release'), ]; Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void { diff --git a/sonar-desk-licensing/app/Services/Licensing/LicenseTokenVerifier.php b/sonar-desk-licensing/app/Services/Licensing/LicenseTokenVerifier.php index fa497fc..a13ba47 100755 --- a/sonar-desk-licensing/app/Services/Licensing/LicenseTokenVerifier.php +++ b/sonar-desk-licensing/app/Services/Licensing/LicenseTokenVerifier.php @@ -14,10 +14,11 @@ final class LicenseTokenVerifier } $payload = $this->decode($parts[1]); $signature = $this->decode($parts[2]); - $public = base64_decode((string) config('sonardesk.public_key'), true); - if ($public === false || strlen($public) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { - throw new RuntimeException('Server licence public key is invalid.'); - } + // Derive the verification key from the same private seed used by LicenseSigner. + // Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift: + // activation would sign a perfectly valid token with the new private key, then + // /validate would reject that same token using a stale public key. + $public = $this->publicKeyFromPrivateSeed(); if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) { throw new RuntimeException('Licence token signature is invalid.'); } @@ -26,6 +27,23 @@ final class LicenseTokenVerifier return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.'); } + private function publicKeyFromPrivateSeed(): string + { + $path = config('sonardesk.private_key_path'); + if (! $path || ! is_file($path)) { + throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.'); + } + + $seed = base64_decode(trim((string) file_get_contents($path)), true); + if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.'); + } + + $pair = sodium_crypto_sign_seed_keypair($seed); + + return sodium_crypto_sign_publickey($pair); + } + private function decode(string $value): string { $value = strtr($value, '-_', '+/'); diff --git a/sonar-desk-licensing/database/migrations/2026_09_30_150000_create_sonardesk_releases_table.php b/sonar-desk-licensing/database/migrations/2026_09_30_150000_create_sonardesk_releases_table.php new file mode 100644 index 0000000..e82cce2 --- /dev/null +++ b/sonar-desk-licensing/database/migrations/2026_09_30_150000_create_sonardesk_releases_table.php @@ -0,0 +1,42 @@ +id(); + $table->string('version', 40); + $table->unsignedInteger('build_number')->default(1); + $table->string('channel', 20)->default('stable'); + $table->string('minimum_macos', 30)->nullable(); + $table->string('file_path')->nullable(); + $table->string('original_filename')->nullable(); + $table->unsignedBigInteger('file_size')->nullable(); + $table->string('sha256', 64)->nullable(); + $table->text('sparkle_signature')->nullable(); + $table->text('release_notes')->nullable(); + $table->boolean('is_published')->default(false)->index(); + $table->boolean('is_featured')->default(false)->index(); + $table->timestamp('published_at')->nullable()->index(); + $table->unsignedBigInteger('download_count')->default(0); + $table->timestamps(); + $table->unique(['version', 'build_number', 'channel']); + }); + + Schema::create('sonardesk_site_settings', function (Blueprint $table): void { + $table->string('key')->primary(); + $table->text('value')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sonardesk_site_settings'); + Schema::dropIfExists('sonardesk_releases'); + } +}; diff --git a/sonar-desk-licensing/deploy.php b/sonar-desk-licensing/deploy.php index 628690d..634da4c 100755 --- a/sonar-desk-licensing/deploy.php +++ b/sonar-desk-licensing/deploy.php @@ -18,11 +18,11 @@ add('shared_dirs', ['storage']); add('writable_dirs', ['storage', 'bootstrap/cache']); // Host Definitions -host('production') - ->set('hostname', 'sonardsk.absdev.net') +host('main') + ->set('hostname', 'sonardesk.absdev.net') ->set('remote_user', 'mag') ->set('branch', 'main') - ->set('deploy_path', '/var/www/sonardesk/main'); + ->set('deploy_path', '/var/www/sonardesk/prod'); host('stage') ->set('hostname', 'sonardesk.stage.absdev.net') diff --git a/sonar-desk-licensing/AGENTS.md b/sonar-desk-licensing/docs/AGENTS.md similarity index 100% rename from sonar-desk-licensing/AGENTS.md rename to sonar-desk-licensing/docs/AGENTS.md diff --git a/sonar-desk-licensing/CLAUDE.md b/sonar-desk-licensing/docs/CLAUDE.md similarity index 100% rename from sonar-desk-licensing/CLAUDE.md rename to sonar-desk-licensing/docs/CLAUDE.md diff --git a/sonar-desk-licensing/MARKETING_LAUNCH_GUIDE.md b/sonar-desk-licensing/docs/MARKETING_LAUNCH_GUIDE.md similarity index 99% rename from sonar-desk-licensing/MARKETING_LAUNCH_GUIDE.md rename to sonar-desk-licensing/docs/MARKETING_LAUNCH_GUIDE.md index a948ee0..3b43fd8 100644 --- a/sonar-desk-licensing/MARKETING_LAUNCH_GUIDE.md +++ b/sonar-desk-licensing/docs/MARKETING_LAUNCH_GUIDE.md @@ -107,7 +107,7 @@ The current code uses subscription events as the authoritative state for recurri Copy the webhook endpoint secret. -### E. Production `.env` +### E. Production `../.env` Set the following values on the production server. Do not commit them. @@ -135,7 +135,7 @@ Also configure the existing SonarDesk licence signing keys, mailer, support emai ### F. Seed the price mapping -After the production IDs are in `.env`: +After the production IDs are in `../.env`: ```bash php artisan config:clear diff --git a/sonar-desk-licensing/docs/RELEASE_DISTRIBUTION.md b/sonar-desk-licensing/docs/RELEASE_DISTRIBUTION.md new file mode 100644 index 0000000..536ab03 --- /dev/null +++ b/sonar-desk-licensing/docs/RELEASE_DISTRIBUTION.md @@ -0,0 +1,28 @@ +# SonarDesk release distribution + +The website now owns SonarDesk release distribution from the admin area. + +## Deploy + +1. Deploy the code normally. +2. Run `php artisan migrate --force`. +3. Ensure Laravel's `storage` directory remains shared/persistent between releases (the existing Deployer configuration already shares `storage`). +4. Ensure the web/PHP upload limits are large enough for the DMG. For a 1 GB application limit, configure nginx `client_max_body_size` and PHP `upload_max_filesize` / `post_max_size` accordingly. + +## Publishing a build + +Open **Admin → Releases → New release**. Enter the marketing version, build number, minimum macOS version and release notes, upload the signed/notarized DMG or ZIP, and paste the Sparkle EdDSA signature when Sparkle is enabled. Save as a draft until the package is ready, then publish it. + +Published stable releases automatically feed: + +- `/release` — customer-facing distribution centre +- `/download` — latest stable package +- `/updates/appcast.xml` — Sparkle feed +- the licence portal download call-to-action +- licence delivery email download call-to-action + +The SHA-256 checksum and file size are calculated on upload. Download counts are tracked per release. + +## Sparkle + +Generate the EdDSA signature with the Sparkle release tooling and paste it into the release editor. Configure the macOS app's feed URL to the production `/updates/appcast.xml` endpoint. Do not place Sparkle private signing keys on the public web server. diff --git a/sonar-desk-licensing/false b/sonar-desk-licensing/false new file mode 100644 index 0000000..e69de29 diff --git a/sonar-desk-licensing/resources/views/admin/index.blade.php b/sonar-desk-licensing/resources/views/admin/index.blade.php index 85171f0..f4198c6 100755 --- a/sonar-desk-licensing/resources/views/admin/index.blade.php +++ b/sonar-desk-licensing/resources/views/admin/index.blade.php @@ -2,5 +2,5 @@ @section('content')
Admin

Licensing administration

Manage licences, activations and customer support.

{{ $licenseCount }}

Licences

{{ $activeCount }}

Not revoked

{{ $activationCount }}

Active installations

{{ $userCount }}

Portal users

- + @endsection diff --git a/sonar-desk-licensing/resources/views/checkout/buy.blade.php b/sonar-desk-licensing/resources/views/checkout/buy.blade.php index 76ff5be..cb36c03 100755 --- a/sonar-desk-licensing/resources/views/checkout/buy.blade.php +++ b/sonar-desk-licensing/resources/views/checkout/buy.blade.php @@ -61,7 +61,7 @@ Paddle.PricePreview({items: previewItems}).then(result => { for (const item of (result?.data?.details?.lineItems ?? [])) { const element = priceElements.get(item?.price?.id); - const formatted = item?.formattedTotals?.subtotal ?? item?.formattedUnitTotals?.subtotal; + const formatted = item?.formattedTotals?.total ?? item?.formattedUnitTotals?.total; if (!element || !formatted) continue; const suffix = element.dataset.billing === 'annual' ? ' / year' : ' / month'; element.textContent = formatted + suffix; diff --git a/sonar-desk-licensing/resources/views/downloads/appcast.blade.php b/sonar-desk-licensing/resources/views/downloads/appcast.blade.php new file mode 100644 index 0000000..ca21258 --- /dev/null +++ b/sonar-desk-licensing/resources/views/downloads/appcast.blade.php @@ -0,0 +1,2 @@ +{!! '' !!} +SonarDesk Updates{{ route('updates.appcast') }}SonarDesk stable release feed@foreach($releases as $release)SonarDesk {{ $release->version }}{{ $release->published_at?->toRfc2822String() }}{{ $release->build_number }}{{ $release->version }}@if($release->minimum_macos){{ $release->minimum_macos }}@endifrelease_notes ?: 'SonarDesk update')) !!}]]>sparkle_signature) sparkle:edSignature="{{ $release->sparkle_signature }}"@endif />@endforeach diff --git a/sonar-desk-licensing/resources/views/downloads/index.blade.php b/sonar-desk-licensing/resources/views/downloads/index.blade.php new file mode 100644 index 0000000..0d66597 --- /dev/null +++ b/sonar-desk-licensing/resources/views/downloads/index.blade.php @@ -0,0 +1,8 @@ +@extends('layouts.app', ['title' => 'Download SonarDesk — Native SonarQube monitoring for macOS', 'description' => 'Download the latest signed SonarDesk release for macOS, review release notes and system requirements.']) +@section('fullbleed') +
{{ $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
+
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/sonar-desk-licensing/resources/views/emails/license-key-text.blade.php b/sonar-desk-licensing/resources/views/emails/license-key-text.blade.php index cdaf808..d6b0a09 100644 --- a/sonar-desk-licensing/resources/views/emails/license-key-text.blade.php +++ b/sonar-desk-licensing/resources/views/emails/license-key-text.blade.php @@ -18,3 +18,6 @@ To activate: @endif Keep this key private. + +DOWNLOAD SONARDESK +{{ $downloadUrl }} diff --git a/sonar-desk-licensing/resources/views/emails/license-key.blade.php b/sonar-desk-licensing/resources/views/emails/license-key.blade.php index bc23ef9..0a052a4 100644 --- a/sonar-desk-licensing/resources/views/emails/license-key.blade.php +++ b/sonar-desk-licensing/resources/views/emails/license-key.blade.php @@ -13,6 +13,7 @@
  1. Open SonarDesk on your Mac.
  2. Open Settings → Licence.
  3. Paste the licence key above and select Activate.
@if($expiresAt)

Current entitlement period: {{ $expiresAt->format('j M Y') }}.

@endif @if($portalUrl)

Manage your licence

@endif +

Download SonarDesk

Keep this key private. If you did not make this purchase, contact SonarDesk support.

diff --git a/sonar-desk-licensing/resources/views/layouts/app.blade.php b/sonar-desk-licensing/resources/views/layouts/app.blade.php index 8f376d5..4dbe278 100755 --- a/sonar-desk-licensing/resources/views/layouts/app.blade.php +++ b/sonar-desk-licensing/resources/views/layouts/app.blade.php @@ -169,7 +169,14 @@ .admin-ticket-list{display:grid;gap:10px}.admin-ticket-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto auto;gap:18px;align-items:center;padding:16px 18px;background:#fff;border:1px solid var(--line);border-radius:14px;text-decoration:none;box-shadow:var(--shadow-sm)}.admin-ticket-row:hover{border-color:#b8cdf6}.admin-ticket-row>div:first-child{display:flex;flex-direction:column;min-width:0}.admin-ticket-row small{color:var(--muted)}.ticket-ref{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--blue);font-weight:850}.ticket-category,.ticket-status{padding:5px 8px;border-radius:999px;font-size:12px;font-weight:800;background:#f2f4f7;color:#475467}.status-new{background:#eff6ff;color:#1d4ed8}.status-open{background:#ecfdf3;color:#067647}.status-waiting{background:#fff7ed;color:#9a3412}.status-resolved,.status-closed{background:#f2f4f7;color:#475467}.admin-ticket-row time{color:var(--muted);font-size:12px}.filter-bar{display:flex;align-items:center;gap:10px;margin-bottom:18px}.filter-bar select{width:auto;margin:0}.ticket-detail-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:22px}.ticket-detail-head h1{font-size:34px;line-height:1.12;letter-spacing:-.03em;margin:6px 0}.ticket-detail-head p{margin:0;color:var(--muted)}.ticket-message .ticket-meta{display:flex;gap:12px;color:var(--muted);font-size:13px;margin:8px 0 20px}.message-body{white-space:normal;background:#f8fafc;border:1px solid var(--line);border-radius:12px;padding:18px;line-height:1.7;margin-bottom:18px} @media(max-width:800px){.support-layout{grid-template-columns:1fr}.form-grid.two{grid-template-columns:1fr}.legal-page{padding:28px 22px}.legal-table>div,.cookie-table>div{grid-template-columns:1fr;gap:4px}.cookie-banner-grid{grid-template-columns:1fr}.cookie-actions{justify-content:flex-start}.admin-ticket-row{grid-template-columns:1fr auto}.admin-ticket-row .ticket-category,.admin-ticket-row time{display:none}.ticket-detail-head{flex-direction:column}} - + + /* SonarDesk distribution centre */ + .download-page{background:#f6f8fc;min-height:80vh}.download-shell{max-width:1220px;margin:0 auto;padding-left:24px;padding-right:24px}.download-hero{position:relative;overflow:hidden;background:radial-gradient(circle at 78% 22%,rgba(37,99,235,.22),transparent 32%),linear-gradient(135deg,#07111f 0%,#0b1730 55%,#101c35 100%);color:#fff;padding:92px 0 78px}.download-hero:after{content:'';position:absolute;inset:auto -10% -220px 35%;height:420px;background:rgba(59,130,246,.08);filter:blur(60px);border-radius:50%}.download-hero .download-shell{display:grid;grid-template-columns:1.02fr .98fr;gap:70px;align-items:center;position:relative;z-index:1}.download-copy h1{font-size:68px;line-height:.98;letter-spacing:-.055em;margin:12px 0 22px}.download-copy>p{font-size:20px;line-height:1.65;color:#cbd5e1;max-width:660px}.download-actions{display:flex;gap:12px;align-items:stretch;margin:32px 0 20px;flex-wrap:wrap}.download-primary{background:#fff!important;color:#0f172a!important;padding:13px 18px!important;gap:12px;text-align:left;box-shadow:0 16px 40px rgba(0,0,0,.22)}.download-primary:hover{transform:translateY(-1px)}.download-primary small{display:block;color:#64748b;font-size:11px;margin-top:3px}.download-icon{width:32px;height:32px;display:grid;place-items:center;border-radius:8px;background:#2563eb;color:#fff;font-size:20px}.download-secondary{background:rgba(255,255,255,.07)!important;border:1px solid rgba(255,255,255,.18)!important;color:#fff!important}.release-verification{display:flex;gap:18px;flex-wrap:wrap;color:#94a3b8;font-size:12px}.release-verification span:First-letter{color:#86efac}.product-visual{position:relative;perspective:1000px}.mac-window{border:1px solid rgba(255,255,255,.15);background:#111827;border-radius:18px;box-shadow:0 36px 90px rgba(0,0,0,.45);overflow:hidden;transform:rotateY(-5deg) rotateX(2deg)}.mac-titlebar{height:42px;background:#1b2434;display:flex;align-items:center;gap:7px;padding:0 14px;border-bottom:1px solid rgba(255,255,255,.06)}.mac-titlebar i{width:10px;height:10px;border-radius:50%;background:#fb7185}.mac-titlebar i:nth-child(2){background:#fbbf24}.mac-titlebar i:nth-child(3){background:#4ade80}.mac-titlebar span{margin-left:auto;margin-right:auto;color:#94a3b8;font-size:11px}.mac-content{display:grid;grid-template-columns:110px 1fr;height:310px}.mini-sidebar{background:#0b1220;padding:18px 12px;display:flex;flex-direction:column;gap:12px}.mini-sidebar img{width:30px;border-radius:8px}.mini-sidebar b{font-size:10px}.mini-sidebar span{height:9px;border-radius:4px;background:#26344c;width:78%}.mini-sidebar span.active{background:#2563eb;width:90%}.mini-dashboard{padding:24px}.mini-head{display:flex;justify-content:space-between;align-items:center}.mini-head b{font-size:14px}.mini-head em{font-size:8px;color:#4ade80;font-style:normal}.mini-stats{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;margin:20px 0}.mini-stats i{height:58px;border:1px solid #273449;border-radius:8px;background:linear-gradient(145deg,#162033,#101827)}.mini-chart{height:130px;border:1px solid #273449;border-radius:9px;display:flex;align-items:flex-end;gap:9px;padding:16px}.mini-chart span{flex:1;background:linear-gradient(#3b82f6,#1d4ed8);border-radius:4px 4px 2px 2px}.mini-chart span:nth-child(1){height:35%}.mini-chart span:nth-child(2){height:58%}.mini-chart span:nth-child(3){height:45%}.mini-chart span:nth-child(4){height:78%}.mini-chart span:nth-child(5){height:66%}.mini-chart span:nth-child(6){height:90%}.visual-badge{position:absolute;right:-20px;bottom:-22px;background:#fff;color:#0f172a;border-radius:12px;padding:12px 18px;box-shadow:0 14px 34px rgba(0,0,0,.3)}.visual-badge span{display:block;font-size:9px;color:#2563eb;text-transform:uppercase;font-weight:900;letter-spacing:.12em}.visual-badge strong{font-size:13px}.download-confidence{background:#fff;border-bottom:1px solid #e5e7eb}.confidence-grid{display:grid;grid-template-columns:repeat(4,1fr);padding-top:25px;padding-bottom:25px}.confidence-grid div{padding:0 24px;border-right:1px solid #e5e7eb}.confidence-grid div:first-child{padding-left:0}.confidence-grid div:last-child{border:0}.confidence-grid span{display:block;text-transform:uppercase;letter-spacing:.1em;font-size:9px;font-weight:900;color:#2563eb;margin-bottom:4px}.confidence-grid strong{font-size:12px;color:#344054}.download-content{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(280px,.6fr);gap:28px;padding-top:72px;padding-bottom:72px}.release-notes-panel,.requirements-panel{background:#fff;border:1px solid #e4e7ec;border-radius:20px;padding:34px;box-shadow:0 12px 35px rgba(15,23,42,.05)}.release-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;border-bottom:1px solid #eaecf0;padding-bottom:24px}.release-heading h2,.requirements-panel h2{font-size:30px;letter-spacing:-.03em;margin:7px 0 5px}.release-heading p{margin:0;color:#667085}.release-note-copy{padding:28px 0;color:#475467;font-size:16px;line-height:1.8}.checksum-box{background:#f8fafc;border:1px solid #e5e7eb;border-radius:10px;padding:14px}.checksum-box span{display:block;font-size:9px;font-weight:900;letter-spacing:.12em;color:#64748b;margin-bottom:5px}.checksum-box code{font-size:11px;overflow-wrap:anywhere;color:#334155}.requirements-panel p{color:#667085}.requirements-panel ol{list-style:none;padding:15px 0;margin:0}.requirements-panel li{display:flex;gap:12px;padding:13px 0;border-top:1px solid #eef2f6}.requirements-panel li>span{display:grid;place-items:center;width:28px;height:28px;background:#eff6ff;color:#2563eb;border-radius:8px;font-weight:900;font-size:11px}.requirements-panel li strong,.requirements-panel li small{display:block}.requirements-panel li small{color:#667085;margin-top:2px}.requirements-panel>a{color:#2563eb;font-weight:800;text-decoration:none;font-size:13px}.release-history{background:#fff;border-top:1px solid #e5e7eb;padding:70px 0}.release-history-list{border:1px solid #e5e7eb;border-radius:16px;overflow:hidden}.release-history-list article{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid #e5e7eb}.release-history-list article:last-child{border:0}.release-history-list strong,.release-history-list span{display:block}.release-history-list span{font-size:12px;color:#667085;margin-top:3px}.release-history-list article>div:last-child{display:flex;align-items:center;gap:20px}.release-history-list a{color:#2563eb;font-weight:800;text-decoration:none}.download-support{background:#0b1220;color:#fff;padding:55px 0}.download-support .download-shell{display:flex;align-items:center;justify-content:space-between;gap:30px}.download-support h2{font-size:32px;margin:7px 0}.download-support p{color:#94a3b8;margin:0}.no-release{margin-top:28px;padding:18px;border:1px solid rgba(255,255,255,.15);border-radius:12px;background:rgba(255,255,255,.06)}.no-release span{display:block;color:#94a3b8;margin-top:3px} + .admin-release-head{display:flex;align-items:flex-end;justify-content:space-between;gap:20px}.admin-metrics{margin-bottom:24px}.admin-table-card,.editor-card{background:#fff;border:1px solid #e4e7ec;border-radius:16px;box-shadow:var(--shadow-sm)}.admin-table-title{display:flex;justify-content:space-between;align-items:center;padding:22px 24px;border-bottom:1px solid #e4e7ec}.admin-table-title h2{margin:0;font-size:19px}.admin-table-title p{margin:3px 0 0;color:#667085;font-size:13px}.table-scroll{overflow-x:auto}.pro-table{width:100%;border-collapse:collapse}.pro-table th{background:#f8fafc;text-align:left;padding:11px 16px;color:#667085;font-size:10px;text-transform:uppercase;letter-spacing:.08em}.pro-table td{padding:15px 16px;border-top:1px solid #eef2f6;vertical-align:middle}.pro-table td strong,.pro-table td small{display:block}.pro-table td small{color:#667085;margin-top:3px}.table-actions{display:flex;gap:7px;justify-content:flex-end}.table-actions form{margin:0}.status-chip{display:inline-flex;padding:4px 8px;border-radius:99px;background:#eff6ff;color:#1d4ed8;font-size:10px;font-weight:850}.status-chip.success{background:#ecfdf3;color:#067647}.status-chip.muted{background:#f2f4f7;color:#667085}.empty-cell{text-align:center!important;color:#667085;padding:40px!important}.release-editor{display:grid;grid-template-columns:minmax(0,1fr) 320px;gap:22px;align-items:start}.editor-main{display:grid;gap:18px}.editor-card{padding:24px}.editor-card-head{display:flex;gap:13px;margin-bottom:22px}.editor-card-head>span{display:grid;place-items:center;width:34px;height:34px;border-radius:9px;background:#eff6ff;color:#2563eb;font-size:10px;font-weight:900}.editor-card h2{margin:0;font-size:19px}.editor-card p{margin:3px 0 0;color:#667085;font-size:13px}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.editor-card label,.settings-editor label{display:grid;gap:7px;font-size:12px;font-weight:800;color:#344054}.editor-card input,.editor-card select,.editor-card textarea,.settings-editor input,.settings-editor textarea{width:100%;border:1px solid #d0d5dd;border-radius:9px;padding:10px 12px;background:#fff;color:#101828;font:inherit}.upload-zone{padding:22px!important;border:1px dashed #98a2b3;border-radius:12px;background:#f9fafb;margin-bottom:18px}.upload-zone span{color:#667085;font-weight:500}.upload-zone input{border:0!important;padding:10px 0 0!important;background:transparent!important}.package-current{padding:13px 15px;border-radius:10px;background:#ecfdf3;margin-bottom:14px}.package-current strong,.package-current span{display:block}.package-current span{font-size:10px;color:#067647;overflow-wrap:anywhere;margin-top:4px}.sticky-card{position:sticky;top:92px}.toggle-row{display:flex!important;align-items:flex-start;gap:10px!important;padding:14px 0;border-top:1px solid #eef2f6}.toggle-row input{width:auto!important;margin-top:3px}.toggle-row strong,.toggle-row small{display:block}.toggle-row small{color:#667085;margin-top:2px;font-weight:500}.sticky-card .btn{margin-top:10px}.settings-editor{max-width:820px;display:grid;gap:18px} + @media(max-width:900px){.download-hero .download-shell{grid-template-columns:1fr}.product-visual{display:none}.download-copy h1{font-size:52px}.confidence-grid{grid-template-columns:repeat(2,1fr);gap:20px}.confidence-grid div{border:0;padding:0}.download-content,.release-editor{grid-template-columns:1fr}.sticky-card{position:static}.download-support .download-shell,.admin-release-head{align-items:flex-start;flex-direction:column}}@media(max-width:600px){.download-hero{padding:64px 0}.download-copy h1{font-size:42px}.download-actions,.release-heading{flex-direction:column}.download-primary,.download-secondary{width:100%}.confidence-grid{grid-template-columns:1fr}.release-history-list article{align-items:flex-start;gap:12px;flex-direction:column}.download-content{padding-top:40px;padding-bottom:40px}.release-notes-panel,.requirements-panel{padding:22px}.form-grid{grid-template-columns:1fr}.download-support .download-shell{align-items:stretch}} +.portal-download-banner{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:18px 20px;margin-bottom:22px;background:linear-gradient(135deg,#0f172a,#172554);color:#fff;border-radius:14px;box-shadow:var(--shadow-md)}.portal-download-banner strong,.portal-download-banner small{display:block}.portal-download-banner strong{font-size:18px;margin:3px 0}.portal-download-banner small{color:#cbd5e1}.portal-download-banner .eyebrow{color:#93c5fd}@media(max-width:650px){.portal-download-banner{align-items:stretch;flex-direction:column}} +.home-download-showcase{display:grid;grid-template-columns:1.25fr .75fr;gap:36px;align-items:center;margin:0 0 64px;padding:38px;background:linear-gradient(135deg,#f8fafc,#eff6ff);border:1px solid #dbeafe;border-radius:22px}.home-download-copy h2{font-size:38px;letter-spacing:-.035em;margin:7px 0 10px}.home-download-copy p{color:#667085;font-size:16px;max-width:680px}.home-download-meta{background:#0b1220;color:#fff;border-radius:16px;padding:24px;box-shadow:0 18px 40px rgba(15,23,42,.16)}.home-download-meta>span{display:block;color:#93c5fd;text-transform:uppercase;font-size:9px;font-weight:900;letter-spacing:.12em}.home-download-meta>strong{display:block;font-size:24px;margin:7px 0 3px}.home-download-meta>small{color:#94a3b8}.home-download-meta>div{margin-top:20px;padding-top:16px;border-top:1px solid rgba(255,255,255,.1)}.home-download-meta b,.home-download-meta code{display:block}.home-download-meta b{font-size:9px;color:#64748b;letter-spacing:.1em}.home-download-meta code{font-size:10px;color:#cbd5e1;margin-top:5px}@media(max-width:760px){.home-download-showcase{grid-template-columns:1fr;padding:24px}} + @stack('head') @@ -183,7 +190,7 @@ Product Features Pricing - Release + Download Support Privacy @auth @@ -200,6 +207,7 @@ @endphp Admin + Releases Support @if ($supportUnread > 0) @@ -241,10 +249,11 @@ Product Features Pricing - Release + Download Support Privacy Terms + Refunds Cookies @auth @if(auth()->user()->hasVerifiedEmail())Licence portal@endif @elseSign in@endauth diff --git a/sonar-desk-licensing/resources/views/legal/refund.blade.php b/sonar-desk-licensing/resources/views/legal/refund.blade.php new file mode 100644 index 0000000..4410517 --- /dev/null +++ b/sonar-desk-licensing/resources/views/legal/refund.blade.php @@ -0,0 +1,102 @@ +@extends('layouts.app', ['title' => 'Refund Policy — SonarDesk']) + +@section('content') +@php($legal = config('sonardesk.legal')) + +@endsection diff --git a/sonar-desk-licensing/resources/views/legal/terms.blade.php b/sonar-desk-licensing/resources/views/legal/terms.blade.php index db09d91..45c57de 100755 --- a/sonar-desk-licensing/resources/views/legal/terms.blade.php +++ b/sonar-desk-licensing/resources/views/legal/terms.blade.php @@ -3,17 +3,144 @@ @section('content') @php($legal = config('sonardesk.legal')) @endsection diff --git a/sonar-desk-licensing/resources/views/portal/index.blade.php b/sonar-desk-licensing/resources/views/portal/index.blade.php index 0a874fb..5424076 100755 --- a/sonar-desk-licensing/resources/views/portal/index.blade.php +++ b/sonar-desk-licensing/resources/views/portal/index.blade.php @@ -1,6 +1,7 @@ @extends('layouts.app') @section('content') +
Latest SonarDeskNeed the Mac app?Download the latest published, signed release from the SonarDesk distribution centre.
Download SonarDesk
Account diff --git a/sonar-desk-licensing/resources/views/portal/show.blade.php b/sonar-desk-licensing/resources/views/portal/show.blade.php index 2b09e8b..cc75ebb 100755 --- a/sonar-desk-licensing/resources/views/portal/show.blade.php +++ b/sonar-desk-licensing/resources/views/portal/show.blade.php @@ -1,6 +1,7 @@ @extends('layouts.app') @section('content') +
Latest SonarDeskNeed the Mac app?Download the latest published, signed release from the SonarDesk distribution centre.
Download SonarDesk
@php $activeActivations = $license->activations->whereNull('deactivated_at'); $billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly'; diff --git a/sonar-desk-licensing/resources/views/portal/welcome.blade.php b/sonar-desk-licensing/resources/views/portal/welcome.blade.php index 55091b4..c56a643 100755 --- a/sonar-desk-licensing/resources/views/portal/welcome.blade.php +++ b/sonar-desk-licensing/resources/views/portal/welcome.blade.php @@ -44,5 +44,7 @@
+
Desktop distribution

Ready when you are.

Download the latest signed SonarDesk release for macOS, review what changed, verify the package checksum and browse previous versions from one dedicated release centre.

@if($latestRelease)Latest stableVersion {{ $latestRelease->version }}Build {{ $latestRelease->build_number }} · {{ $latestRelease->formatted_size }} · macOS {{ $latestRelease->minimum_macos ?: 'supported versions' }}
SHA-256{{ Str::limit($latestRelease->sha256, 34) }}
@elseSonarDesk releasesDistribution centrePublished builds will appear here automatically.@endif
+
SonarDesk for macOS

Make SonarQube easier to keep an eye on.

Try SonarDesk for seven days and connect it to your existing SonarQube environment.

Start free trial
@endsection diff --git a/sonar-desk-licensing/routes/web.php b/sonar-desk-licensing/routes/web.php index 6112df6..0438c31 100755 --- a/sonar-desk-licensing/routes/web.php +++ b/sonar-desk-licensing/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Admin\AdminController; use App\Http\Controllers\Admin\SupportTicketController; +use App\Http\Controllers\Admin\ReleaseController as AdminReleaseController; use App\Http\Controllers\Admin\CmsPageController as AdminCmsPageController; use App\Http\Controllers\CmsPageController; use App\Http\Controllers\Auth\AuthenticatedSessionController; @@ -9,19 +10,24 @@ use App\Http\Controllers\Auth\EmailVerificationController; use App\Http\Controllers\Auth\PasswordController; use App\Http\Controllers\Auth\RegisteredUserController; use App\Http\Controllers\CheckoutController; +use App\Http\Controllers\ReleaseController; use App\Http\Controllers\Portal\PortalController; use App\Http\Controllers\Support\SupportController; use App\Http\Middleware\AdminOnly; use Illuminate\Support\Facades\Route; -Route::get('/', fn () => view('portal.welcome'))->name('home'); +Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home'); Route::get('/buy', CheckoutController::class)->name('buy'); Route::view('/features', 'features')->name('features'); -Route::view('/release', 'release')->name('release'); +Route::get('/release', [ReleaseController::class, 'index'])->name('release'); +Route::get('/download', [ReleaseController::class, 'latest'])->name('download.latest'); +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'); Route::post('/support', [SupportController::class, 'store'])->middleware('throttle:5,10')->name('support.store'); Route::view('/privacy', 'legal.privacy')->name('privacy'); Route::view('/terms', 'legal.terms')->name('terms'); +Route::view('/refund-policy', 'legal.refund')->name('refund'); Route::view('/cookies', 'legal.cookies')->name('cookies'); Route::middleware('guest')->group(function () { Route::get('/login', [AuthenticatedSessionController::class, 'create'])->name('login'); @@ -59,6 +65,14 @@ 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('/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'); + Route::get('/releases/settings', [AdminReleaseController::class, 'settings'])->name('releases.settings'); + Route::put('/releases/settings', [AdminReleaseController::class, 'updateSettings'])->name('releases.settings.update'); + Route::get('/releases/{release}/edit', [AdminReleaseController::class, 'edit'])->name('releases.edit'); + Route::put('/releases/{release}', [AdminReleaseController::class, 'update'])->name('releases.update'); + Route::delete('/releases/{release}', [AdminReleaseController::class, 'destroy'])->name('releases.destroy'); Route::get('/support', [SupportTicketController::class, 'index'])->name('support.index'); Route::get('/support/{ticket}', [SupportTicketController::class, 'show'])->name('support.show'); Route::patch('/support/{ticket}',[SupportTicketController::class, 'update'])->name('support.update'); diff --git a/sonar-desk-licensing/scripts/paddle-production-prices.sh b/sonar-desk-licensing/scripts/paddle-production-prices.sh new file mode 100755 index 0000000..f17726d --- /dev/null +++ b/sonar-desk-licensing/scripts/paddle-production-prices.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash + +set -euo pipefail + +PADDLE_API_URL="https://api.paddle.com" + +: "${PADDLE_API_KEY:?PADDLE_API_KEY is not set}" +: "${PADDLE_PRODUCT_ID:?PADDLE_PRODUCT_ID is not set}" + +command -v curl >/dev/null || { + echo "ERROR: curl is required." + exit 1 +} + +command -v jq >/dev/null || { + echo "ERROR: jq is required." + exit 1 +} + +if [[ "$PADDLE_API_KEY" != pdl_live_apikey_* ]]; then + echo "ERROR: This script requires a Paddle LIVE API key." + exit 1 +fi + +if [[ "$PADDLE_PRODUCT_ID" != pro_* ]]; then + echo "ERROR: Invalid Paddle product ID." + exit 1 +fi + +echo "============================================" +echo " SonarDesk Paddle Production Price Setup" +echo "============================================" +echo +echo "Product: $PADDLE_PRODUCT_ID" +echo + +# ------------------------------------------------------------ +# Verify product +# ------------------------------------------------------------ + +PRODUCT_RESPONSE="$( + curl -fsS \ + -H "Authorization: Bearer $PADDLE_API_KEY" \ + -H "Accept: application/json" \ + "$PADDLE_API_URL/products/$PADDLE_PRODUCT_ID" +)" + +PRODUCT_NAME="$(echo "$PRODUCT_RESPONSE" | jq -r '.data.name // empty')" + +if [[ -z "$PRODUCT_NAME" ]]; then + echo "ERROR: Could not retrieve Paddle product." + exit 1 +fi + +echo "Found product: $PRODUCT_NAME" +echo + +# ------------------------------------------------------------ +# Existing prices +# ------------------------------------------------------------ + +get_prices() { + curl -fsS \ + -H "Authorization: Bearer $PADDLE_API_KEY" \ + -H "Accept: application/json" \ + "$PADDLE_API_URL/prices?product_id=$PADDLE_PRODUCT_ID&status=active&per_page=100" +} + +find_price_by_description() { + local description="$1" + + get_prices | + jq -r \ + --arg description "$description" \ + '.data[] + | select(.description == $description) + | .id' | + head -n 1 +} + +create_price() { + local description="$1" + local amount="$2" + local interval="$3" + + echo "Creating: $description" + + curl -fsS \ + -X POST \ + "$PADDLE_API_URL/prices" \ + -H "Authorization: Bearer $PADDLE_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data "$( + jq -n \ + --arg product "$PADDLE_PRODUCT_ID" \ + --arg description "$description" \ + --arg amount "$amount" \ + --arg interval "$interval" \ + '{ + product_id: $product, + description: $description, + unit_price: { + amount: $amount, + currency_code: "GBP" + }, + billing_cycle: { + interval: $interval, + frequency: 1 + }, + quantity: { + minimum: 1, + maximum: 1 + }, + tax_mode: "account_setting" + }' + )" | + jq -r '.data.id' +} + +update_price_amount() { + local price_id="$1" + local description="$2" + local amount="$3" + + echo "Updating: $description ($price_id)" + + curl -fsS \ + -X PATCH \ + "$PADDLE_API_URL/prices/$price_id" \ + -H "Authorization: Bearer $PADDLE_API_KEY" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + --data "$( + jq -n \ + --arg description "$description" \ + --arg amount "$amount" \ + '{ + description: $description, + unit_price: { + amount: $amount, + currency_code: "GBP" + } + }' + )" >/dev/null +} + +ensure_price() { + local description="$1" + local amount="$2" + local interval="$3" + + local existing + existing="$(find_price_by_description "$description")" + + if [[ -n "$existing" ]]; then + local current_amount + current_amount="$( + get_prices | + jq -r \ + --arg id "$existing" \ + '.data[] + | select(.id == $id) + | .unit_price.amount' + )" + + if [[ "$current_amount" != "$amount" ]]; then + echo "Existing price $existing has amount $current_amount." + echo "Required amount is $amount." + update_price_amount \ + "$existing" \ + "$description" \ + "$amount" + else + echo "Exists: $description ($existing)" + fi + + echo "$existing" + return + fi + + create_price \ + "$description" \ + "$amount" \ + "$interval" +} + +# ------------------------------------------------------------ +# SonarDesk production prices +# +# Paddle amounts are in pennies: +# +# £2.99 = 299 +# £24.99 = 2499 +# £4.99 = 499 +# £44.99 = 4499 +# ------------------------------------------------------------ + +echo "Configuring prices..." +echo + +PERSONAL_MONTHLY="$( + ensure_price \ + "SonarDesk Personal Monthly" \ + "299" \ + "month" | + tail -n 1 +)" + +PERSONAL_ANNUAL="$( + ensure_price \ + "SonarDesk Personal Annual" \ + "2499" \ + "year" | + tail -n 1 +)" + +PROFESSIONAL_MONTHLY="$( + ensure_price \ + "SonarDesk Professional Monthly" \ + "499" \ + "month" | + tail -n 1 +)" + +PROFESSIONAL_ANNUAL="$( + ensure_price \ + "SonarDesk Professional Annual" \ + "4499" \ + "year" | + tail -n 1 +)" + +echo +echo "============================================" +echo " Paddle Production Prices" +echo "============================================" +echo +echo "Personal Monthly: $PERSONAL_MONTHLY" +echo "Personal Annual: $PERSONAL_ANNUAL" +echo "Professional Monthly: $PROFESSIONAL_MONTHLY" +echo "Professional Annual: $PROFESSIONAL_ANNUAL" +echo +echo "Add these to the production .env:" +echo +echo "PADDLE_ENVIRONMENT=production" +echo "PADDLE_API_URL=https://api.paddle.com" +echo "PADDLE_PRODUCT_ID=$PADDLE_PRODUCT_ID" +echo "PADDLE_PERSONAL_MONTHLY_PRICE_ID=$PERSONAL_MONTHLY" +echo "PADDLE_PERSONAL_ANNUAL_PRICE_ID=$PERSONAL_ANNUAL" +echo "PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=$PROFESSIONAL_MONTHLY" +echo "PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=$PROFESSIONAL_ANNUAL" +echo +echo "IMPORTANT:" +echo "PADDLE_API_KEY and PADDLE_CLIENT_SIDE_TOKEN must also be LIVE values." +echo "PADDLE_WEBHOOK_SECRET must come from the LIVE webhook destination." +echo diff --git a/sonar-desk-licensing/scripts/update-paddle-prices.sh b/sonar-desk-licensing/scripts/update-paddle-prices.sh new file mode 100755 index 0000000..522a2ee --- /dev/null +++ b/sonar-desk-licensing/scripts/update-paddle-prices.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "ERROR: Cannot find $ENV_FILE" + exit 1 +fi + +# Load only the Paddle values we need. +get_env() { + grep -E "^${1}=" "$ENV_FILE" | head -1 | cut -d= -f2- +} + +PADDLE_ENVIRONMENT="$(get_env PADDLE_ENVIRONMENT)" +PADDLE_API_URL="$(get_env PADDLE_API_URL)" +PADDLE_API_KEY="$(get_env PADDLE_API_KEY)" + +PERSONAL_MONTHLY="$(get_env PADDLE_PERSONAL_MONTHLY_PRICE_ID)" +PERSONAL_ANNUAL="$(get_env PADDLE_PERSONAL_ANNUAL_PRICE_ID)" +PRO_MONTHLY="$(get_env PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID)" +PRO_ANNUAL="$(get_env PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID)" + +if [[ -z "$PADDLE_API_KEY" ]]; then + echo "ERROR: PADDLE_API_KEY is missing" + exit 1 +fi + +echo +echo "Paddle environment: $PADDLE_ENVIRONMENT" +echo "API URL: $PADDLE_API_URL" +echo +echo "Prices to update:" +echo " Personal monthly -> £4.99" +echo " Personal annual -> £39.99" +echo " Professional monthly -> £8.99" +echo " Professional annual -> £79.99" +echo + +if [[ "$PADDLE_ENVIRONMENT" == "production" ]]; then + echo "WARNING: THIS IS THE LIVE PADDLE ACCOUNT." + printf "Type LIVE to continue: " + read -r confirmation + + if [[ "$confirmation" != "LIVE" ]]; then + echo "Cancelled." + exit 1 + fi +else + printf "Type YES to update the sandbox prices: " + read -r confirmation + + if [[ "$confirmation" != "YES" ]]; then + echo "Cancelled." + exit 1 + fi +fi + +update_price() { + local id="$1" + local amount="$2" + local description="$3" + + if [[ -z "$id" ]]; then + echo "ERROR: Missing price ID for $description" + exit 1 + fi + + echo + echo "Updating $description ($id)..." + + response="$( + curl --silent --show-error --fail-with-body \ + --request PATCH \ + "${PADDLE_API_URL}/prices/${id}" \ + --header "Authorization: Bearer ${PADDLE_API_KEY}" \ + --header "Content-Type: application/json" \ + --data "{ + \"unit_price\": { + \"amount\": \"${amount}\", + \"currency_code\": \"GBP\" + } + }" + )" + + if command -v jq >/dev/null 2>&1; then + echo "$response" | jq '{ + id: .data.id, + description: .data.description, + price: .data.unit_price, + billing_cycle: .data.billing_cycle, + status: .data.status + }' + else + echo "$response" + fi +} + +# Paddle amounts are in pennies: +# £4.99 = 499 +# £39.99 = 3999 +# £8.99 = 899 +# £79.99 = 7999 + +update_price "$PERSONAL_MONTHLY" 499 "Personal Monthly" +update_price "$PERSONAL_ANNUAL" 3999 "Personal Annual" +update_price "$PRO_MONTHLY" 899 "Professional Monthly" +update_price "$PRO_ANNUAL" 7999 "Professional Annual" + +echo +echo "----------------------------------------" +echo "All Paddle prices updated successfully." +echo "----------------------------------------" diff --git a/sonar-desk-licensing/tests/Feature/ReleaseDistributionTest.php b/sonar-desk-licensing/tests/Feature/ReleaseDistributionTest.php new file mode 100644 index 0000000..a12217a --- /dev/null +++ b/sonar-desk-licensing/tests/Feature/ReleaseDistributionTest.php @@ -0,0 +1,45 @@ +get('/release')->assertOk()->assertSee('Download SonarDesk'); + } + + public function test_latest_download_returns_published_package(): void + { + Storage::fake('local'); + Storage::disk('local')->put('sonardesk/releases/SonarDesk.dmg', 'package'); + SonarDeskRelease::create([ + 'version' => '1.2.0', 'build_number' => 120, 'channel' => 'stable', + 'file_path' => 'sonardesk/releases/SonarDesk.dmg', 'original_filename' => 'SonarDesk-1.2.0.dmg', + 'file_size' => 7, 'sha256' => hash('sha256', 'package'), 'is_published' => true, 'published_at' => now(), + ]); + $this->get('/download')->assertOk()->assertDownload('SonarDesk-1.2.0.dmg'); + } + + public function test_draft_release_is_not_downloadable(): void + { + $release = SonarDeskRelease::create(['version'=>'1.0.0','build_number'=>1,'channel'=>'stable','is_published'=>false]); + $this->get('/downloads/'.$release->id)->assertNotFound(); + } + + public function test_appcast_contains_published_release(): void + { + SonarDeskRelease::create([ + 'version'=>'2.0.0','build_number'=>200,'channel'=>'stable','file_path'=>'sonardesk/releases/a.dmg', + 'original_filename'=>'a.dmg','file_size'=>10,'sha256'=>str_repeat('a',64),'is_published'=>true,'published_at'=>now(), + ]); + $this->get('/updates/appcast.xml')->assertOk()->assertSee('SonarDesk 2.0.0')->assertSee('sparkle:version', false); + } +}