updates
This commit is contained in:
Generated
+3
@@ -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, '-_', '+/');
|
||||
|
||||
+42
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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')
|
||||
|
||||
+2
-2
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user