This commit is contained in:
Mark Gregory
2026-10-01 10:58:07 +01:00
parent 3499fe382a
commit ead4483b7c
31 changed files with 1035 additions and 32 deletions
+3
View File
@@ -5,6 +5,9 @@
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/.idea" />
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/public/build" />
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/vendor" />
<excludeFolder url="file://$MODULE_DIR$/SonarDesk.xcodeproj/project.xcworkspace/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/SonarDesk.xcodeproj/xcuserdata" />
<excludeFolder url="file://$MODULE_DIR$/sonar-desk-licensing/storage/app/licensing" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\SiteSetting;
use App\Models\SonarDeskRelease;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rule;
final class ReleaseController extends Controller
{
public function index()
{
return view('admin.releases.index', [
'releases' => 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();
}
}
@@ -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) {
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Models\SiteSetting;
use App\Models\SonarDeskRelease;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ReleaseController extends Controller
{
public function index()
{
$latest = SonarDeskRelease::published()->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.'),
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
final class SiteSetting extends Model
{
protected $table = 'sonardesk_site_settings';
protected $primaryKey = 'key';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['key', 'value'];
public static function valueFor(string $key, ?string $default = null): ?string
{
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => 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);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
final class SonarDeskRelease extends Model
{
protected $table = 'sonardesk_releases';
protected $fillable = [
'version', 'build_number', 'channel', 'minimum_macos', 'file_path',
'original_filename', 'file_size', 'sha256', 'sparkle_signature',
'release_notes', 'is_published', 'is_featured', 'published_at', 'download_count',
];
protected function casts(): array
{
return [
'is_published' => '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';
}
}
@@ -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 {
@@ -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, '-_', '+/');
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('sonardesk_releases', function (Blueprint $table): void {
$table->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');
}
};
+3 -3
View File
@@ -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')
@@ -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
@@ -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.
View File
@@ -2,5 +2,5 @@
@section('content')
<section class="portal-header"><div><span class="eyebrow">Admin</span><h1>Licensing administration</h1><p class="lead compact">Manage licences, activations and customer support.</p></div></section>
<div class="grid"><div class="card"><strong>{{ $licenseCount }}</strong><p>Licences</p></div><div class="card"><strong>{{ $activeCount }}</strong><p>Not revoked</p></div><div class="card"><strong>{{ $activationCount }}</strong><p>Active installations</p></div><div class="card"><strong>{{ $userCount }}</strong><p>Portal users</p></div></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:18px"><a class="btn btn-primary" href="{{ route('admin.releases.index') }}">Manage releases</a><a class="btn" href="{{ route('admin.licenses') }}">Manage licences</a><a class="btn btn-secondary" href="{{ route('admin.cms.index') }}">Manage content</a><a class="btn btn-secondary" href="{{ route('admin.support.index') }}">Support inbox</a></div>
@endsection
@@ -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;
@@ -0,0 +1,2 @@
{!! '<?xml version="1.0" encoding="utf-8"?>' !!}
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><title>SonarDesk Updates</title><link>{{ route('updates.appcast') }}</link><description>SonarDesk stable release feed</description>@foreach($releases as $release)<item><title>SonarDesk {{ $release->version }}</title><pubDate>{{ $release->published_at?->toRfc2822String() }}</pubDate><sparkle:version>{{ $release->build_number }}</sparkle:version><sparkle:shortVersionString>{{ $release->version }}</sparkle:shortVersionString>@if($release->minimum_macos)<sparkle:minimumSystemVersion>{{ $release->minimum_macos }}</sparkle:minimumSystemVersion>@endif<description><![CDATA[{!! nl2br(e($release->release_notes ?: 'SonarDesk update')) !!}]]></description><enclosure url="{{ route('download.release',$release) }}" length="{{ $release->file_size }}" type="application/octet-stream"@if($release->sparkle_signature) sparkle:edSignature="{{ $release->sparkle_signature }}"@endif /></item>@endforeach</channel></rss>
@@ -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')
<main class="download-page"><section class="download-hero"><div class="download-shell"><div class="download-copy"><span class="eyebrow">{{ $settings['eyebrow'] }}</span><h1>{{ $settings['title'] }}</h1><p>{{ $settings['intro'] }}</p>@if($latest)<div class="download-actions"><a class="btn download-primary" href="{{ route('download.latest') }}"><span class="download-icon">↓</span><span><strong>Download SonarDesk {{ $latest->version }}</strong><small>{{ $latest->formatted_size }} · macOS {{ $latest->minimum_macos ?: 'supported versions' }}</small></span></a><a class="btn download-secondary" href="#release-notes">View release notes</a></div><div class="release-verification"><span>✓ Signed distribution</span><span>✓ SHA-256 verified</span><span>✓ Secure licence activation</span></div>@else<div class="no-release"><strong>The next SonarDesk release is being prepared.</strong><span>Check back shortly or contact support.</span></div>@endif</div><div class="product-visual"><div class="mac-window"><div class="mac-titlebar"><i></i><i></i><i></i><span>SonarDesk</span></div><div class="mac-content"><div class="mini-sidebar"><img src="{{ asset('icons/sonardesk-icon-64.png') }}" alt=""><b>SonarDesk</b><span class="active"></span><span></span><span></span><span></span><span></span></div><div class="mini-dashboard"><div class="mini-head"><b>Project health</b><em>All systems monitored</em></div><div class="mini-stats"><i></i><i></i><i></i><i></i></div><div class="mini-chart"><span></span><span></span><span></span><span></span><span></span><span></span></div></div></div></div><div class="visual-badge"><span>Native</span><strong>Built for macOS</strong></div></div></div></section>
<section class="download-confidence"><div class="download-shell confidence-grid"><div><span>Native macOS</span><strong>Fast, focused desktop workflow</strong></div><div><span>SonarQube</span><strong>Your server remains the source of truth</strong></div><div><span>Security</span><strong>Tokens protected by macOS Keychain</strong></div><div><span>Updates</span><strong>Professional in-app release delivery</strong></div></div></section>
<div class="download-shell download-content"><section id="release-notes" class="release-notes-panel">@if($latest)<div class="release-heading"><div><span class="eyebrow">Latest stable release</span><h2>SonarDesk {{ $latest->version }}</h2><p>Build {{ $latest->build_number }} · Released {{ $latest->published_at?->format('j F Y') }}</p></div><a class="btn btn-primary" href="{{ route('download.release',$latest) }}">Download {{ $latest->version }}</a></div><div class="release-note-copy">{!! nl2br(e($latest->release_notes ?: 'This release contains the latest SonarDesk improvements and maintenance updates.')) !!}</div><div class="checksum-box"><span>SHA-256</span><code>{{ $latest->sha256 }}</code></div>@endif</section><aside class="requirements-panel"><span class="eyebrow">Before you install</span><h2>System requirements</h2><p>{{ $settings['requirements'] }}</p><ol><li><span>1</span><div><strong>Download</strong><small>Get the latest signed SonarDesk package.</small></div></li><li><span>2</span><div><strong>Install</strong><small>Open the DMG and move SonarDesk to Applications.</small></div></li><li><span>3</span><div><strong>Activate</strong><small>Enter the licence key delivered after purchase.</small></div></li></ol><a href="{{ route('buy') }}">Need a licence? View pricing →</a></aside></div>
<section class="release-history"><div class="download-shell"><div class="section-heading"><span class="eyebrow">Release archive</span><h2>Previous versions</h2><p>Published SonarDesk builds remain available here for customers who need a previous release.</p></div><div class="release-history-list">@forelse($releases as $release)<article><div><strong>SonarDesk {{ $release->version }}</strong><span>Build {{ $release->build_number }} · {{ ucfirst($release->channel) }} · {{ $release->published_at?->format('j M Y') }}</span></div><div><span>{{ $release->formatted_size }}</span><a href="{{ route('download.release',$release) }}">Download</a></div></article>@empty<p>No published releases are available yet.</p>@endforelse</div>{{ $releases->links() }}</div></section>
<section class="download-support"><div class="download-shell"><div><span class="eyebrow">Support</span><h2>Installation or activation help?</h2><p>{{ $settings['support_text'] }}</p></div><a class="btn btn-secondary" href="{{ route('support') }}">Contact SonarDesk support</a></div></section></main>
@endsection
@@ -18,3 +18,6 @@ To activate:
@endif
Keep this key private.
DOWNLOAD SONARDESK
{{ $downloadUrl }}
@@ -13,6 +13,7 @@
<ol style="padding-left:22px;line-height:1.8;margin-top:0"><li>Open SonarDesk on your Mac.</li><li>Open <strong>Settings → Licence</strong>.</li><li>Paste the licence key above and select <strong>Activate</strong>.</li></ol>
@if($expiresAt)<p style="line-height:1.6">Current entitlement period: <strong>{{ $expiresAt->format('j M Y') }}</strong>.</p>@endif
@if($portalUrl)<p style="margin:24px 0"><a href="{{ $portalUrl }}" style="display:inline-block;background:#2563eb;color:#fff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600">Manage your licence</a></p>@endif
<p style="margin:24px 0"><a href="{{ $downloadUrl }}" style="display:inline-block;background:#0f172a;color:#ffffff;text-decoration:none;font-weight:700;padding:12px 18px;border-radius:8px">Download SonarDesk</a></p>
<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.6">Keep this key private. If you did not make this purchase, contact SonarDesk support.</p>
</td></tr></table>
</td></tr></table>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
@extends('layouts.app', ['title' => 'Refund Policy — SonarDesk'])
@section('content')
@php($legal = config('sonardesk.legal'))
<article class="legal-page">
<header class="legal-head">
<span class="eyebrow">Legal</span>
<h1>Refund Policy</h1>
<p>Last updated 1 October 2026</p>
</header>
<div class="legal-intro">
This policy explains cancellation, refund and statutory consumer rights for SonarDesk subscriptions.
SonarDesk payments are processed by Paddle, our Merchant of Record. Nothing in this policy limits any
rights or remedies that cannot lawfully be limited.
</div>
<section>
<h2>1. Seven-day free trial</h2>
<p>Where shown at checkout, SonarDesk includes a seven-day free trial. You can cancel before the trial
ends to prevent the subscription converting to a paid subscription. If you cancel during the trial
before a charge is made, there is no subscription payment to refund.</p>
</section>
<section>
<h2>2. Cancelling a paid subscription</h2>
<p>You may cancel your subscription through the SonarDesk licence portal or the subscription-management
facilities provided through Paddle. Cancellation stops future automatic renewals. Unless stated
otherwise during cancellation, your licence normally remains available until the end of the billing
period for which you have already paid.</p>
<p>Cancellation does not itself create a refund for an earlier payment.</p>
</section>
<section>
<h2>3. Refund requests</h2>
<p>If you believe a payment should be refunded, contact
<a href="{{ route('support') }}">SonarDesk Support</a> as soon as possible. Please provide the email
address used for the purchase and enough information for us to identify the transaction. Do not send
full payment-card details.</p>
<p>Because Paddle is the Merchant of Record, a refund may be processed by Paddle and may also be
requested through Paddle's buyer-support facilities. Approved refunds are returned to the original
payment method in accordance with Paddle's processing arrangements and the payment provider's timing.</p>
</section>
<section>
<h2>4. UK and EEA consumer withdrawal rights</h2>
<p>If you purchase as a consumer, applicable law may provide a cooling-off or withdrawal period for
distance contracts. For UK consumers this is generally 14 days, subject to the statutory rules and
exceptions that apply to digital content and services.</p>
<p>If you expressly request or consent to digital supply or performance beginning during a statutory
withdrawal period, the law may affect the right to withdraw once supply or performance has begun.
Nothing on this page removes a statutory right that applies to your purchase.</p>
</section>
<section>
<h2>5. Faulty or misdescribed digital content</h2>
<p>This refund policy does not replace your statutory remedies. If SonarDesk is faulty, materially not
as described, or otherwise fails to meet rights that apply to digital content or services under
consumer law, you may be entitled to a repair, replacement, price reduction, refund or another remedy
depending on the circumstances.</p>
</section>
<section>
<h2>6. Duplicate or incorrect charges</h2>
<p>If you believe you have been charged twice, charged after a valid cancellation, charged an incorrect
amount, or charged for the wrong plan, contact us promptly. We will investigate the subscription and
transaction records and, where appropriate, arrange correction or refund through Paddle.</p>
</section>
<section>
<h2>7. Renewal payments</h2>
<p>Subscriptions renew automatically until cancelled. A renewal charge is not automatically refundable
solely because the subscription was not cancelled before renewal. We will nevertheless consider refund
requests fairly, taking account of the circumstances, applicable law, the timing of the request and
whether the renewed service has been used.</p>
</section>
<section>
<h2>8. Chargebacks</h2>
<p>Please contact SonarDesk Support or Paddle about a disputed payment before initiating a chargeback
where practical. This gives us an opportunity to investigate duplicate, fraudulent or incorrect charges
promptly. This does not restrict any right you have to dispute a payment with your payment provider.</p>
</section>
<section>
<h2>9. How refunds affect your licence</h2>
<p>A full refund or reversal of a subscription payment may cause the associated subscription and
SonarDesk licence to be cancelled or revoked. Where only part of a payment is refunded, any effect on
the subscription will depend on the reason for the adjustment and the resulting Paddle subscription
status.</p>
</section>
<section>
<h2>10. Contact</h2>
<p>For refund or billing enquiries, contact
<a href="mailto:{{ $legal['support_email'] }}">{{ $legal['support_email'] }}</a> or use
<a href="{{ route('support') }}">SonarDesk Support</a>. Include your purchase email address and relevant
transaction details, but never send complete card details.</p>
<p>See also our <a href="{{ route('terms') }}">Terms of Service</a>.</p>
</section>
</article>
@endsection
@@ -3,17 +3,144 @@
@section('content')
@php($legal = config('sonardesk.legal'))
<article class="legal-page">
<header class="legal-head"><span class="eyebrow">Legal</span><h1>Terms of Service</h1><p>Last updated 9 September 2026</p></header>
<div class="legal-intro">These terms govern your use of the SonarDesk website, licence portal, subscription service and macOS application.</div>
<section><h2>1. Licence plans</h2><p>Personal permits the number of active Mac installations shown for that plan at purchase. Professional permits the number shown for Professional. A licence is for your use and may not be shared, resold or used to bypass activation limits.</p></section>
<section><h2>2. Trials and subscriptions</h2><p>Where offered, the trial length and renewal terms are shown at checkout. Subscription billing, cancellation, tax and payment processing are handled through Paddle. Your subscription continues until cancelled in accordance with the checkout terms. Changes between Personal and Professional may be prorated by Paddle.</p></section>
<section><h2>3. Activation</h2><p>SonarDesk uses a licence service to activate permitted Macs and periodically confirm that an activation remains valid. A reasonable offline grace period may be provided, but continued use after a subscription or activation is revoked is not authorised.</p></section>
<section><h2>4. Your SonarQube connections</h2><p>You are responsible for the SonarQube servers, credentials, tokens and projects you connect to SonarDesk. SonarDesk displays information made available by your SonarQube account and does not grant additional server permissions.</p></section>
<section><h2>5. Acceptable use</h2><p>You must not attempt to defeat licensing controls, interfere with the service, probe accounts you do not own, distribute malware, or use SonarDesk in violation of applicable law or third-party rights.</p></section>
<section><h2>6. Updates and availability</h2><p>We may update the application, website and licensing service to improve security, compatibility or functionality. We aim to provide a reliable service but do not guarantee uninterrupted availability.</p></section>
<section><h2>7. Consumer rights</h2><p>Nothing in these terms excludes rights that cannot lawfully be excluded, including applicable UK consumer rights. Where mandatory law gives you stronger protections, that law prevails.</p></section>
<section><h2>8. Liability</h2><p>To the extent permitted by law, we are not responsible for indirect or consequential loss arising from incorrect sync configuration, deleted destination data, unavailable third-party systems or failures outside our reasonable control. This does not limit liability that cannot lawfully be limited.</p></section>
<section><h2>9. Privacy</h2><p>Our processing of personal data is described in the <a href="{{ route('privacy') }}">Privacy Policy</a> and <a href="{{ route('cookies') }}">Cookie Policy</a>.</p></section>
<section><h2>10. Contact</h2><p>These terms are provided by {{ $legal['controller_name'] }} in {{ $legal['country'] }}. Questions can be submitted through <a href="{{ route('support') }}">SonarDesk Support</a>.</p></section>
<header class="legal-head">
<span class="eyebrow">Legal</span>
<h1>Terms of Service</h1>
<p>Last updated 1 October 2026</p>
</header>
<div class="legal-intro">
These Terms of Service govern your use of the SonarDesk website, licence portal,
subscription service and macOS application. By purchasing, activating or using SonarDesk,
you agree to these terms. Your statutory rights are not affected.
</div>
<section>
<h2>1. SonarDesk licences</h2>
<p>SonarDesk is licensed, not sold. A Personal subscription permits the number of active Mac
installations stated for that plan at the time of purchase. A Professional subscription permits
the number stated for the Professional plan. Licences are for the subscriber's use and must not
be shared, resold, sublicensed or used to circumvent activation limits.</p>
</section>
<section>
<h2>2. Free trials</h2>
<p>Where a free trial is offered, its duration and the price that will apply afterwards are shown
before checkout. Unless you cancel before the trial ends, the subscription will automatically
convert to the selected paid plan and Paddle will charge the payment method provided at checkout.</p>
</section>
<section>
<h2>3. Subscriptions, billing and renewal</h2>
<p>SonarDesk subscriptions renew automatically at the billing interval selected at checkout until
cancelled. The renewal price, billing period, applicable taxes and other checkout information are
presented before you complete the transaction. Payment processing, tax collection and subscription
billing are handled by Paddle, our Merchant of Record.</p>
<p>Changes between Personal and Professional plans may result in a prorated charge or credit as
calculated by Paddle and displayed as part of the subscription change.</p>
</section>
<section>
<h2>4. Cancellation</h2>
<p>You may cancel a subscription through the SonarDesk licence portal or the subscription-management
facilities made available through Paddle. Cancellation prevents future renewals. Unless the
cancellation flow expressly states otherwise, access normally continues until the end of the
already-paid billing period.</p>
<p>Cancellation and refund requests are separate. Cancelling a subscription does not automatically
refund charges already paid. See our <a href="{{ route('refund') }}">Refund Policy</a>.</p>
</section>
<section>
<h2>5. Activation and licence checks</h2>
<p>SonarDesk uses a licence service to activate permitted Macs and periodically confirm that an
activation remains valid. A reasonable offline grace period may be provided. Continued use after a
licence or subscription has expired or been revoked is not authorised.</p>
</section>
<section>
<h2>6. Your SonarQube connections</h2>
<p>You are responsible for the SonarQube servers, credentials, tokens and projects you connect to
SonarDesk. SonarDesk accesses information made available by the permissions of the credentials you
provide. It does not grant additional permissions on your SonarQube server.</p>
<p>You are responsible for ensuring that you are authorised to access and process the projects and
information you connect to SonarDesk.</p>
</section>
<section>
<h2>7. Acceptable use</h2>
<p>You must not attempt to defeat or circumvent licensing controls, interfere with the SonarDesk
service, gain unauthorised access to accounts or systems, distribute malware through the service,
reverse engineer the service except where applicable law expressly permits it, or use SonarDesk in
violation of applicable law or third-party rights.</p>
</section>
<section>
<h2>8. Software updates and service availability</h2>
<p>We may update SonarDesk, the website and licensing service to improve security, compatibility,
reliability or functionality. We may also discontinue obsolete versions where reasonably necessary.
We aim to provide a reliable service but do not guarantee uninterrupted or error-free availability.</p>
</section>
<section>
<h2>9. Third-party services</h2>
<p>SonarDesk works with third-party services including SonarQube and Paddle. Those services are
operated independently and may have their own terms and policies. We are not responsible for changes,
outages or restrictions imposed by third-party services outside our reasonable control.</p>
</section>
<section>
<h2>10. Consumer rights</h2>
<p>Nothing in these terms excludes or restricts rights that cannot lawfully be excluded or restricted.
If you are a consumer, this includes applicable rights relating to digital content and services under
UK consumer law. Where mandatory law gives you stronger protection than these terms, the mandatory
protection applies.</p>
<p>Information about refunds, statutory remedies and withdrawal rights is set out in our
<a href="{{ route('refund') }}">Refund Policy</a>.</p>
</section>
<section>
<h2>11. Liability</h2>
<p>Nothing in these terms excludes or limits liability where doing so would be unlawful, including
liability for death or personal injury caused by negligence, fraud or fraudulent misrepresentation.</p>
<p>Subject to those protections, we are not responsible for indirect or consequential losses, loss
caused by unauthorised or incorrect configuration, unavailable third-party systems, or events outside
our reasonable control. If you use SonarDesk for business purposes, you are responsible for maintaining
appropriate backups and operational safeguards.</p>
</section>
<section>
<h2>12. Suspension and termination</h2>
<p>We may suspend or terminate access where reasonably necessary to address fraud, security threats,
material breach of these terms, chargebacks, or an invalid or expired subscription. Where appropriate,
we will give reasonable notice and an opportunity to resolve the issue.</p>
</section>
<section>
<h2>13. Changes to these terms</h2>
<p>We may update these terms when the service, law or our commercial arrangements change. The current
version will be published on this page with its revision date. Material changes affecting an existing
subscription will be communicated where required by law.</p>
</section>
<section>
<h2>14. Privacy</h2>
<p>Our processing of personal data is described in the
<a href="{{ route('privacy') }}">Privacy Policy</a> and
<a href="{{ route('cookies') }}">Cookie Policy</a>.</p>
</section>
<section>
<h2>15. Governing law</h2>
<p>These terms are governed by the laws applicable in {{ $legal['country'] }}, subject to any mandatory
consumer protections and rights you have under the law of the country in which you live.</p>
</section>
<section>
<h2>16. Contact</h2>
<p>These terms are provided by <strong>{{ $legal['controller_name'] }}</strong> in
{{ $legal['country'] }}. For licensing, billing or contractual questions, contact
<a href="mailto:{{ $legal['support_email'] }}">{{ $legal['support_email'] }}</a> or use
<a href="{{ route('support') }}">SonarDesk Support</a>.</p>
</section>
</article>
@endsection
@@ -1,6 +1,7 @@
@extends('layouts.app')
@section('content')
<div class="portal-download-banner"><div><span class="eyebrow">Latest SonarDesk</span><strong>Need the Mac app?</strong><small>Download the latest published, signed release from the SonarDesk distribution centre.</small></div><a class="btn btn-primary" href="{{ route('release') }}">Download SonarDesk</a></div>
<section class="portal-header">
<div>
<span class="eyebrow">Account</span>
@@ -1,6 +1,7 @@
@extends('layouts.app')
@section('content')
<div class="portal-download-banner"><div><span class="eyebrow">Latest SonarDesk</span><strong>Need the Mac app?</strong><small>Download the latest published, signed release from the SonarDesk distribution centre.</small></div><a class="btn btn-primary" href="{{ route('release') }}">Download SonarDesk</a></div>
@php
$activeActivations = $license->activations->whereNull('deactivated_at');
$billing = str_contains(strtolower((string) optional($license->product)->code), 'annual') ? 'Annual' : 'Monthly';
@@ -44,5 +44,7 @@
<div class="center-action"><a class="btn btn-primary" href="{{ route('buy') }}">See pricing and start trial</a></div>
</section>
<section class="home-download-showcase"><div class="home-download-copy"><span class="eyebrow">Desktop distribution</span><h2>Ready when you are.</h2><p>Download the latest signed SonarDesk release for macOS, review what changed, verify the package checksum and browse previous versions from one dedicated release centre.</p><div class="support-actions">@if($latestRelease)<a class="btn btn-primary" href="{{ route('download.latest') }}">Download SonarDesk {{ $latestRelease->version }}</a>@else<a class="btn btn-primary" href="{{ route('release') }}">Visit download centre</a>@endif<a class="btn btn-secondary" href="{{ route('release') }}">Release notes</a></div></div><div class="home-download-meta">@if($latestRelease)<span>Latest stable</span><strong>Version {{ $latestRelease->version }}</strong><small>Build {{ $latestRelease->build_number }} · {{ $latestRelease->formatted_size }} · macOS {{ $latestRelease->minimum_macos ?: 'supported versions' }}</small><div><b>SHA-256</b><code>{{ Str::limit($latestRelease->sha256, 34) }}</code></div>@else<span>SonarDesk releases</span><strong>Distribution centre</strong><small>Published builds will appear here automatically.</small>@endif</div></section>
<section class="cta-panel"><div><span class="eyebrow">SonarDesk for macOS</span><h2>Make SonarQube easier to keep an eye on.</h2><p>Try SonarDesk for seven days and connect it to your existing SonarQube environment.</p></div><a class="btn btn-primary" href="{{ route('buy') }}">Start free trial</a></section>
@endsection
+16 -2
View File
@@ -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');
+257
View File
@@ -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
+114
View File
@@ -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 "----------------------------------------"
@@ -0,0 +1,45 @@
<?php
namespace Tests\Feature;
use App\Models\SonarDeskRelease;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
final class ReleaseDistributionTest extends TestCase
{
use RefreshDatabase;
public function test_download_page_is_public(): void
{
$this->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);
}
}