update to provisioner code nwew paddle code
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<?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','mimes:dmg,zip'],
|
||||
'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();
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,415 @@
|
||||
# SonarDesk Commercial Launch & Selling Guide
|
||||
|
||||
## 1. Recommended commercial model
|
||||
|
||||
Launch SonarDesk as a **direct-download macOS product with recurring licensing**. Use **Paddle as the production Merchant of Record (MoR)** for the initial launch because this codebase already has Paddle checkout, subscription lifecycle handling, customer lookup, upgrades/cancellation, webhook verification and product mapping implemented.
|
||||
|
||||
Do **not** operate Paddle and Lemon Squeezy as two public checkout systems at launch. Pick one system of record. Running two providers doubles reconciliation, refund, subscription-state and support complexity.
|
||||
|
||||
### Launch pricing
|
||||
|
||||
| Plan | Monthly | Annual | Activations | Positioning |
|
||||
|---|---:|---:|---:|---|
|
||||
| Personal | £2.99/month | £24.99/year | 1 Mac | Individual developer |
|
||||
| Professional | £4.99/month | £44.99/year | 3 Macs | Multi-Mac / professional workflow |
|
||||
|
||||
Both plans should include the complete feature set and a **7-day free trial**. Do not artificially cripple Personal. The upgrade is based on activation allowance, which is simple to understand and easy to support.
|
||||
|
||||
Annual should be the visual default. At these prices it produces better unit economics than very low monthly pricing because both Paddle and Lemon Squeezy publish a fixed transaction component in addition to their percentage fee.
|
||||
|
||||
### Launch offer
|
||||
|
||||
For the first 30 days, use a single promotion rather than permanently reducing the list price:
|
||||
|
||||
- 20% off the first annual term using a launch coupon such as `LAUNCH20`.
|
||||
- Do not discount monthly plans.
|
||||
- Do not promise “lifetime” access while the licensing model is subscription-based.
|
||||
- Keep the public list price stable so the product does not look disposable or permanently on sale.
|
||||
|
||||
## 2. Why Paddle is the recommended launch provider
|
||||
|
||||
Paddle's public pay-as-you-go pricing is 5% + $0.50 per checkout transaction and includes payments/billing, tax compliance and related Merchant of Record services. Paddle supports a sandbox, checkout, subscription lifecycle events and signed webhooks.
|
||||
|
||||
This project is already designed around Paddle:
|
||||
|
||||
- `CheckoutController` supplies Paddle price IDs to Paddle.js.
|
||||
- `/api/v1/webhooks/paddle` accepts Paddle events.
|
||||
- `PaddleWebhookVerifier` verifies signed webhook requests.
|
||||
- `PurchaseProvisioner` provisions from `subscription.*` and `transaction.completed` events.
|
||||
- `PaddleCatalogSeeder` maps Paddle products/prices to SonarDesk licence products.
|
||||
- The portal can upgrade Personal to Professional and cancel Paddle subscriptions.
|
||||
|
||||
That makes Paddle the lowest-risk route to a release.
|
||||
|
||||
Official references:
|
||||
|
||||
- Paddle pricing: https://www.paddle.com/pricing
|
||||
- Paddle quickstart: https://developer.paddle.com/get-started/quickstart/
|
||||
- Paddle webhooks: https://developer.paddle.com/webhooks/
|
||||
- Paddle webhook simulator: https://developer.paddle.com/webhooks/simulator/
|
||||
|
||||
## 3. Paddle setup — complete production checklist
|
||||
|
||||
### A. Create/complete the Paddle account
|
||||
|
||||
1. Create the Paddle account and complete business verification.
|
||||
2. Add the legal/business details that Paddle requests.
|
||||
3. Configure payout details.
|
||||
4. Add the production website/domain.
|
||||
5. Complete Paddle's domain/default payment link approval before launch.
|
||||
6. Keep the account in sandbox while integrating. Switch the application to production only after the full purchase flow has passed end-to-end testing.
|
||||
|
||||
### B. Create the catalog
|
||||
|
||||
Create two Paddle products:
|
||||
|
||||
**SonarDesk Personal**
|
||||
- Monthly recurring price: **£3.99 GBP / month**
|
||||
- Annual recurring price: **£29.99 GBP / year**
|
||||
- Trial: **7 days**
|
||||
|
||||
**SonarDesk Professional**
|
||||
- Monthly recurring price: **£6.99 GBP / month**
|
||||
- Annual recurring price: **£59.99 GBP / year**
|
||||
- Trial: **7 days**
|
||||
|
||||
Use tax-inclusive/exclusive behaviour consistently with the checkout experience you want Paddle to present. Paddle, as MoR, calculates the applicable transaction taxes in checkout.
|
||||
|
||||
Copy the resulting two product IDs and four price IDs.
|
||||
|
||||
### C. Create application credentials
|
||||
|
||||
In Paddle Developer Tools:
|
||||
|
||||
1. Create a server API key for the licensing application.
|
||||
2. Create a client-side token for Paddle.js.
|
||||
3. Never put the server API key into browser JavaScript or source control.
|
||||
4. Keep sandbox and production credentials separate.
|
||||
|
||||
### D. Configure the webhook
|
||||
|
||||
Create a Paddle notification destination:
|
||||
|
||||
`https://YOUR-DOMAIN/api/v1/webhooks/paddle`
|
||||
|
||||
Subscribe at minimum to:
|
||||
|
||||
- `transaction.completed`
|
||||
- `subscription.created`
|
||||
- `subscription.updated`
|
||||
- `subscription.activated`
|
||||
- `subscription.canceled`
|
||||
- `subscription.paused`
|
||||
- `subscription.resumed`
|
||||
- `subscription.past_due`
|
||||
|
||||
The current code uses subscription events as the authoritative state for recurring licences. Paddle documents `transaction.completed` as the point at which transaction processing is complete. Paddle also documents at-least-once webhook delivery, so webhook handlers must remain idempotent; this project stores webhook event IDs for that purpose.
|
||||
|
||||
Copy the webhook endpoint secret.
|
||||
|
||||
### E. Production `.env`
|
||||
|
||||
Set the following values on the production server. Do not commit them.
|
||||
|
||||
```dotenv
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://YOUR-DOMAIN
|
||||
|
||||
PADDLE_ENVIRONMENT=production
|
||||
PADDLE_API_URL=https://api.paddle.com
|
||||
PADDLE_API_KEY=YOUR_PRODUCTION_API_KEY
|
||||
PADDLE_CLIENT_SIDE_TOKEN=YOUR_PRODUCTION_CLIENT_TOKEN
|
||||
PADDLE_WEBHOOK_SECRET=YOUR_PRODUCTION_WEBHOOK_SECRET
|
||||
PADDLE_WEBHOOK_TOLERANCE=300
|
||||
|
||||
PADDLE_PERSONAL_PRODUCT_ID=pro_...
|
||||
PADDLE_PERSONAL_MONTHLY_PRICE_ID=pri_...
|
||||
PADDLE_PERSONAL_ANNUAL_PRICE_ID=pri_...
|
||||
PADDLE_PROFESSIONAL_PRODUCT_ID=pro_...
|
||||
PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID=pri_...
|
||||
PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID=pri_...
|
||||
```
|
||||
|
||||
Also configure the existing SonarDesk licence signing keys, mailer, support email and application database.
|
||||
|
||||
### F. Seed the price mapping
|
||||
|
||||
After the production IDs are in `.env`:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
php artisan db:seed --class=PaddleCatalogSeeder
|
||||
php artisan config:cache
|
||||
```
|
||||
|
||||
Check the `license_products` table and confirm there are four active Paddle rows with the correct product/price IDs and activation limits.
|
||||
|
||||
### G. Test in sandbox before production
|
||||
|
||||
Test every one of these scenarios:
|
||||
|
||||
1. Personal monthly trial signup.
|
||||
2. Personal annual trial signup.
|
||||
3. Professional monthly trial signup.
|
||||
4. Professional annual trial signup.
|
||||
5. Licence email is delivered.
|
||||
6. Licence activates in SonarDesk.
|
||||
7. Second activation is rejected for Personal.
|
||||
8. Up to three activations work for Professional.
|
||||
9. Portal displays the licence.
|
||||
10. Deactivate a Mac and activate a replacement Mac.
|
||||
11. Upgrade Personal to Professional.
|
||||
12. Cancel a subscription.
|
||||
13. Simulate a failed payment / past-due subscription.
|
||||
14. Simulate cancellation/expiry and confirm entitlement behaviour.
|
||||
15. Replay the same webhook and confirm a duplicate licence is not created.
|
||||
16. Refund test transaction and verify the intended entitlement state.
|
||||
17. Verify support and transactional email deliverability.
|
||||
18. Verify checkout on Safari and Chrome, including a private window.
|
||||
|
||||
Use Paddle's webhook simulator for lifecycle testing as well as a real sandbox checkout.
|
||||
|
||||
### H. Go live
|
||||
|
||||
1. Create the production catalog (do not reuse sandbox IDs).
|
||||
2. Add production API key/client token/webhook secret.
|
||||
3. Seed the production catalog mapping.
|
||||
4. Change `PADDLE_ENVIRONMENT=production` and `PADDLE_API_URL=https://api.paddle.com`.
|
||||
5. Run `php artisan config:cache`.
|
||||
6. Make one real low-value purchase with a controlled account.
|
||||
7. Confirm the webhook, licence issuance, email, activation, portal and cancellation path.
|
||||
8. Refund the test purchase in Paddle after validation if appropriate.
|
||||
|
||||
## 4. Lemon Squeezy — alternative setup
|
||||
|
||||
Lemon Squeezy is also a Merchant of Record and publishes base ecommerce pricing of 5% + $0.50 per transaction, with additional fees in some cases (for example certain international, PayPal and subscription payments). It also provides software licence keys and a License API.
|
||||
|
||||
Official references:
|
||||
|
||||
- Pricing: https://www.lemonsqueezy.com/pricing
|
||||
- Fees: https://docs.lemonsqueezy.com/help/getting-started/fees
|
||||
- Webhooks: https://docs.lemonsqueezy.com/help/webhooks
|
||||
- License API: https://docs.lemonsqueezy.com/api/license-api
|
||||
|
||||
### Important for this codebase
|
||||
|
||||
The application has a Lemon Squeezy webhook verifier and basic purchase provisioning, but the **public checkout and customer portal are currently Paddle-first**. Lemon Squeezy should therefore be treated as an alternative migration path, not a second launch checkout, until its full subscription lifecycle and portal actions are implemented and tested.
|
||||
|
||||
If switching to Lemon Squeezy:
|
||||
|
||||
1. Create a store and complete activation/verification.
|
||||
2. Create Personal and Professional products/variants with the same public pricing.
|
||||
3. Decide whether SonarDesk's own signed licence system remains authoritative (recommended for consistency with the Mac app) or whether you migrate fully to Lemon Squeezy's License API. Do not mix both without a clear source of truth.
|
||||
4. Create a webhook at:
|
||||
|
||||
`https://YOUR-DOMAIN/api/v1/webhooks/lemon-squeezy`
|
||||
|
||||
5. Generate a strong signing secret and set:
|
||||
|
||||
```dotenv
|
||||
LEMON_SQUEEZY_WEBHOOK_SECRET=YOUR_SECRET
|
||||
```
|
||||
|
||||
6. Subscribe to the events the application needs, including order creation/refund and all relevant subscription state events.
|
||||
7. Add `license_products` mappings where `payment_provider=lemon-squeezy`, mapping Lemon Squeezy variant IDs to `external_price_id`.
|
||||
8. Implement/verify checkout links or overlay checkout on `/buy` before exposing Lemon Squeezy publicly.
|
||||
9. Extend `PurchaseProvisioner::provisionLemonSqueezy()` so subscription updates, cancellations, pauses, payment failures/recovery and upgrades are handled with the same completeness as Paddle.
|
||||
10. Add customer-portal actions for cancellation/change-of-plan or route customers to Lemon Squeezy's supported customer management flow.
|
||||
11. Run the same end-to-end test matrix used for Paddle.
|
||||
|
||||
Lemon Squeezy webhooks are signed and should always be verified. Their documentation states that unsuccessful webhook deliveries are retried; keep processing idempotent and persist event identity/state.
|
||||
|
||||
## 5. Website conversion strategy
|
||||
|
||||
The updated site now has a clearer commercial funnel:
|
||||
|
||||
`Home → Features → Pricing → Paddle checkout → licence email → app activation → customer portal`
|
||||
|
||||
### Home page
|
||||
|
||||
The home page should answer these questions in order:
|
||||
|
||||
1. What is it? A native macOS companion for SonarQube.
|
||||
2. Why should I care? Faster access to project health and issues without browser-tab friction.
|
||||
3. What does it do? Projects, quality gates, measures, issues, multiple servers and secure token storage.
|
||||
4. Is it safe? SonarQube tokens are stored in macOS Keychain and server permissions remain authoritative.
|
||||
5. What does it cost? Clear Personal and Professional pricing.
|
||||
6. What should I do next? Start the 7-day free trial.
|
||||
|
||||
### Product language to keep consistent
|
||||
|
||||
**Primary one-line pitch**
|
||||
|
||||
> SonarDesk is a native macOS companion for SonarQube that puts project health, quality gates and issues in one focused desktop workspace.
|
||||
|
||||
**Short description**
|
||||
|
||||
> Monitor SonarQube projects, quality gates, bugs, vulnerabilities, code smells and measures without living in a browser.
|
||||
|
||||
**Positioning rule**
|
||||
|
||||
Never imply SonarDesk replaces SonarQube. The stronger and more credible position is: **SonarQube remains the source of truth; SonarDesk makes everyday monitoring faster on macOS.**
|
||||
|
||||
## 6. Launch channels
|
||||
|
||||
### Phase 1 — pre-launch (7–14 days)
|
||||
|
||||
- Finish notarised/signable release build and direct-download hosting.
|
||||
- Finish Paddle production approval.
|
||||
- Record a 30–60 second clean screen capture showing: connect server → project dashboard → issues → issue details.
|
||||
- Produce 4–6 high-resolution screenshots.
|
||||
- Create the launch coupon.
|
||||
- Test website analytics and conversion events.
|
||||
- Prepare support macros for activation, lost key, machine replacement, refund and SonarQube connection problems.
|
||||
- Recruit 5–10 testers from existing developer contacts for final feedback and testimonials you have permission to publish.
|
||||
|
||||
### Phase 2 — launch day
|
||||
|
||||
Publish in this order:
|
||||
|
||||
1. SonarDesk release page on your own site.
|
||||
2. LinkedIn launch post.
|
||||
3. Medium article.
|
||||
4. Relevant developer communities where self-promotion is allowed and useful.
|
||||
5. Existing customer/user mailing list only if recipients have consented to product marketing.
|
||||
|
||||
Do not spam generic communities. Show the product solving a concrete SonarQube workflow problem.
|
||||
|
||||
### Phase 3 — first 30 days
|
||||
|
||||
Publish practical content rather than repeating sales copy:
|
||||
|
||||
- “A faster way to review SonarQube issues on macOS”
|
||||
- “How I use SonarQube quality gates in a desktop workflow”
|
||||
- “What SonarDesk stores locally and how tokens are protected”
|
||||
- “Personal vs Professional: which SonarDesk licence do I need?”
|
||||
- Short release notes for every update
|
||||
|
||||
Track: website → pricing clicks, checkout starts, trial starts, trial-to-paid conversion, cancellations, refunds, activation failures and support topics.
|
||||
|
||||
## 7. LinkedIn release post
|
||||
|
||||
**SonarDesk 1.0 is now available for macOS.**
|
||||
|
||||
If SonarQube is part of your development workflow, you probably know the routine: open the browser, find the project, check the quality gate, scan the latest issues, then switch back to your editor.
|
||||
|
||||
I built SonarDesk to make that recurring workflow faster on the Mac.
|
||||
|
||||
SonarDesk is a native macOS companion for SonarQube. It brings project health, quality gates, bugs, vulnerabilities, code smells and measures into one focused desktop workspace.
|
||||
|
||||
Version 1.0 includes:
|
||||
|
||||
- Project dashboards and quality gate status
|
||||
- Bugs, vulnerabilities and code smells
|
||||
- Detailed issue views and filtering
|
||||
- Server-side pagination for larger issue sets
|
||||
- Multiple SonarQube server configurations
|
||||
- SonarQube token storage in macOS Keychain
|
||||
- Direct licensing and a self-service customer portal
|
||||
|
||||
SonarQube remains the source of truth. SonarDesk is designed to make the information you check throughout the day quicker to reach and easier to keep visible.
|
||||
|
||||
Personal is £24.99/year for one active Mac. Professional is £44.99/year for up to three active Macs. Monthly options are also available, and every plan starts with a 7-day free trial.
|
||||
|
||||
Learn more and try SonarDesk: **YOUR_RELEASE_URL**
|
||||
|
||||
#macOS #SonarQube #SoftwareDevelopment #CodeQuality #DeveloperTools
|
||||
|
||||
## 8. Medium launch article
|
||||
|
||||
# Introducing SonarDesk: a native SonarQube companion for macOS
|
||||
|
||||
SonarQube is a valuable part of the software-development toolchain. It gives teams a central place to understand code quality, security findings and the state of quality gates. But the information developers need throughout the day often lives one browser context-switch away from the work itself.
|
||||
|
||||
That is the problem I wanted SonarDesk to solve.
|
||||
|
||||
## A focused Mac workspace for SonarQube
|
||||
|
||||
SonarDesk is a native macOS companion for SonarQube. It is not a replacement for SonarQube, and it does not try to become another analysis engine. Your SonarQube server remains the source of truth.
|
||||
|
||||
Instead, SonarDesk focuses on the recurring desktop workflow: open a project, see its health, check the quality gate, browse bugs, vulnerabilities and code smells, inspect an issue, and get back to the code.
|
||||
|
||||
## What SonarDesk 1.0 includes
|
||||
|
||||
The first release includes project dashboards, quality gate status, important measures, issue browsing and detailed issue views. Issue lists support practical filtering and server-side pagination, which helps keep the workflow manageable as result sets grow.
|
||||
|
||||
SonarDesk can also keep multiple SonarQube server configurations available from the same Mac application. SonarQube access tokens are stored using macOS Keychain, and access remains governed by the permissions of the credentials you provide.
|
||||
|
||||
## Why a native app?
|
||||
|
||||
The browser is excellent for deep investigation and administration, and SonarDesk is not intended to remove it from the workflow. The benefit of a dedicated desktop app is focus. The project-health information you check repeatedly can have a consistent place on the Mac, separate from documentation, tickets, dashboards and all the other tabs competing for attention.
|
||||
|
||||
## Direct distribution and licensing
|
||||
|
||||
SonarDesk is distributed directly for macOS. Customers receive a licence and can manage activations through the SonarDesk customer portal.
|
||||
|
||||
There are two plans. Personal supports one active Mac and costs £24.99 per year or £2.99 per month. Professional supports up to three active Macs and costs £44.99 per year or £4.99 per month. Both include the complete feature set and start with a seven-day free trial.
|
||||
|
||||
## What comes next
|
||||
|
||||
Version 1.0 establishes the core SonarQube monitoring workflow on macOS. Future releases will continue to focus on making project and issue review faster, clearer and more useful without turning SonarDesk into a replacement for the platform it connects to.
|
||||
|
||||
If SonarQube is part of your day-to-day development workflow and you work on a Mac, you can learn more and start a free trial here:
|
||||
|
||||
**YOUR_RELEASE_URL**
|
||||
|
||||
## 9. Short announcement copy
|
||||
|
||||
**SonarDesk 1.0 for macOS is available now.** Monitor SonarQube project health, quality gates, bugs, vulnerabilities and code smells from a focused native Mac app. Personal and Professional plans include a 7-day free trial. **YOUR_RELEASE_URL**
|
||||
|
||||
## 10. Product listing copy
|
||||
|
||||
### Name
|
||||
SonarDesk
|
||||
|
||||
### Tagline
|
||||
Native SonarQube monitoring for macOS.
|
||||
|
||||
### 80-character style description
|
||||
Monitor SonarQube projects, quality gates and issues from your Mac.
|
||||
|
||||
### Full product description
|
||||
SonarDesk is a native macOS companion for developers and engineering teams using SonarQube. It provides a focused desktop view of project health, quality gates, measures, bugs, vulnerabilities and code smells, with detailed issue views, filtering, server-side pagination and support for multiple SonarQube server configurations. SonarQube tokens are stored in macOS Keychain, and your existing SonarQube permissions remain authoritative.
|
||||
|
||||
## 11. Release readiness checklist
|
||||
|
||||
Do not announce the public release until all items below are true:
|
||||
|
||||
- Production app is signed with Developer ID and notarised.
|
||||
- DMG/ZIP download is served over HTTPS.
|
||||
- Version and build number are final.
|
||||
- Sparkle/update mechanism, if used, is production-configured and signed.
|
||||
- Privacy policy, terms and cookie information match actual behaviour.
|
||||
- Support address is monitored.
|
||||
- Paddle production checkout is approved and working.
|
||||
- Webhook signature verification is enabled.
|
||||
- Production webhook endpoint returns successful responses.
|
||||
- Production product and price IDs are mapped in the database.
|
||||
- Licence signing private key is backed up securely and is not in source control.
|
||||
- Licence activation/validation/deactivation work from a clean Mac.
|
||||
- Purchase email and support email pass SPF/DKIM/DMARC checks.
|
||||
- Trial, paid renewal, cancellation, failed payment and refund paths are tested.
|
||||
- Website has working Home, Features, Pricing, Release, Support, Privacy and Terms pages.
|
||||
- Open Graph image and favicon render correctly.
|
||||
- A real production purchase has been tested end-to-end.
|
||||
- Download analytics and checkout conversion events are visible.
|
||||
- Launch screenshots/video are ready.
|
||||
- LinkedIn and Medium copy has been proofread and final URLs inserted.
|
||||
|
||||
## 12. First commercial targets
|
||||
|
||||
For the first 90 days, optimize for learning rather than vanity traffic. Review weekly:
|
||||
|
||||
- Unique visitors to the product page
|
||||
- Pricing-page visits
|
||||
- Checkout starts
|
||||
- Trial starts
|
||||
- Trial-to-paid conversion
|
||||
- Annual vs monthly mix
|
||||
- Personal vs Professional mix
|
||||
- Cancellation/refund reasons
|
||||
- Activation failure rate
|
||||
- Top support issues
|
||||
|
||||
Do not change pricing every week. Collect enough data to understand whether the problem is traffic, positioning, checkout friction, onboarding or product value before changing the commercial model.
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
@extends('layouts.app', ['title' => ($release->exists ? 'Edit release' : 'New release').' — SonarDesk Admin'])
|
||||
@section('content')
|
||||
<section class="portal-header"><div><span class="eyebrow">Distribution</span><h1>{{ $release->exists ? 'Edit SonarDesk '.$release->version : 'Create a release' }}</h1><p class="lead compact">Upload the notarized distribution package and control exactly when it becomes public.</p></div><a class="btn btn-secondary" href="{{ route('admin.releases.index') }}">Back to releases</a></section>
|
||||
<form class="release-editor" method="post" enctype="multipart/form-data" action="{{ $release->exists ? route('admin.releases.update',$release) : route('admin.releases.store') }}">@csrf @if($release->exists) @method('PUT') @endif
|
||||
<div class="editor-main"><section class="editor-card"><div class="editor-card-head"><span>01</span><div><h2>Release identity</h2><p>Version and compatibility information shown to customers.</p></div></div><div class="form-grid"><label>Version<input name="version" value="{{ old('version',$release->version) }}" placeholder="1.2.0" required></label><label>Build number<input type="number" min="1" name="build_number" value="{{ old('build_number',$release->build_number ?: 1) }}" required></label><label>Channel<select name="channel"><option value="stable" @selected(old('channel',$release->channel)==='stable')>Stable</option><option value="beta" @selected(old('channel',$release->channel)==='beta')>Beta</option></select></label><label>Minimum macOS<input name="minimum_macos" value="{{ old('minimum_macos',$release->minimum_macos) }}" placeholder="14.0"></label></div></section>
|
||||
<section class="editor-card"><div class="editor-card-head"><span>02</span><div><h2>Distribution package</h2><p>DMG or ZIP. SHA-256 is calculated automatically after upload.</p></div></div>@if($release->file_path)<div class="package-current"><strong>{{ $release->original_filename }}</strong><span>{{ $release->formatted_size }} · SHA-256 {{ $release->sha256 }}</span></div>@endif<label class="upload-zone"><strong>{{ $release->file_path ? 'Replace package' : 'Choose distribution package' }}</strong><span>Signed and notarized .dmg or .zip · up to 1 GB</span><input type="file" name="package" accept=".dmg,.zip" {{ $release->exists ? '' : 'required' }}></label><label>Sparkle EdDSA signature<textarea name="sparkle_signature" rows="3" placeholder="Paste the signature generated by Sparkle's sign_update tool">{{ old('sparkle_signature',$release->sparkle_signature) }}</textarea></label></section>
|
||||
<section class="editor-card"><div class="editor-card-head"><span>03</span><div><h2>Release notes</h2><p>Professional customer-facing notes. Plain text is rendered safely with line breaks.</p></div></div><label>What's new<textarea name="release_notes" rows="12" placeholder="New in this release…">{{ old('release_notes',$release->release_notes) }}</textarea></label></section></div>
|
||||
<aside class="editor-side"><div class="editor-card sticky-card"><h2>Publishing</h2><p>A draft is never exposed publicly or in the Sparkle feed.</p><label class="toggle-row"><input type="checkbox" name="is_published" value="1" @checked(old('is_published',$release->is_published))><span><strong>Published</strong><small>Make this build downloadable</small></span></label><label class="toggle-row"><input type="checkbox" name="is_featured" value="1" @checked(old('is_featured',$release->is_featured))><span><strong>Featured release</strong><small>Mark as the highlighted build</small></span></label><button class="btn btn-primary full-width" type="submit">{{ $release->exists ? 'Save release' : 'Create release' }}</button>@if($release->is_published)<a class="btn btn-secondary full-width" href="{{ route('download.release',$release) }}">Test download</a>@endif</div></aside></form>
|
||||
@endsection
|
||||
@@ -0,0 +1,7 @@
|
||||
@extends('layouts.app', ['title' => 'Releases — SonarDesk Admin'])
|
||||
@section('content')
|
||||
<section class="portal-header admin-release-head"><div><span class="eyebrow">Distribution</span><h1>Release management</h1><p class="lead compact">Publish SonarDesk builds, manage the public download experience and keep Sparkle updates under control.</p></div><div class="support-actions"><a class="btn btn-secondary" href="{{ route('admin.releases.settings') }}">Download page settings</a><a class="btn btn-primary" href="{{ route('admin.releases.create') }}">New release</a></div></section>
|
||||
<div class="grid admin-metrics"><div class="card"><strong>{{ $releases->total() }}</strong><p>Total releases</p></div><div class="card"><strong>{{ $publishedCount }}</strong><p>Published</p></div><div class="card"><strong>{{ number_format($downloadCount) }}</strong><p>Total downloads</p></div><div class="card"><strong>Live</strong><p>Sparkle feed</p></div></div>
|
||||
<div class="admin-table-card"><div class="admin-table-title"><div><h2>SonarDesk builds</h2><p>Only published builds with an uploaded package are available publicly.</p></div><a href="{{ route('release') }}" class="btn btn-secondary" target="_blank">View public page</a></div><div class="table-scroll"><table class="pro-table"><thead><tr><th>Release</th><th>Channel</th><th>Status</th><th>Package</th><th>Downloads</th><th>Published</th><th></th></tr></thead><tbody>@forelse($releases as $release)<tr><td><strong>SonarDesk {{ $release->version }}</strong><small>Build {{ $release->build_number }} · macOS {{ $release->minimum_macos ?: 'Any supported' }}</small></td><td><span class="status-chip">{{ ucfirst($release->channel) }}</span></td><td>@if($release->is_published)<span class="status-chip success">Published</span>@else<span class="status-chip muted">Draft</span>@endif</td><td>{{ $release->file_path ? $release->formatted_size : 'No package' }}</td><td>{{ number_format($release->download_count) }}</td><td>{{ $release->published_at?->format('j M Y H:i') ?: '—' }}</td><td class="table-actions"><a class="btn btn-secondary" href="{{ route('admin.releases.edit',$release) }}">Edit</a><form method="post" action="{{ route('admin.releases.destroy',$release) }}" onsubmit="return confirm('Delete this release and its package?')">@csrf @method('DELETE')<button class="danger-outline" type="submit">Delete</button></form></td></tr>@empty<tr><td colspan="7" class="empty-cell">No releases yet. Create your first signed SonarDesk build.</td></tr>@endforelse</tbody></table></div></div>
|
||||
{{ $releases->links() }}
|
||||
@endsection
|
||||
@@ -0,0 +1,5 @@
|
||||
@extends('layouts.app', ['title' => 'Download page — SonarDesk Admin'])
|
||||
@section('content')
|
||||
<section class="portal-header"><div><span class="eyebrow">Website</span><h1>Download page content</h1><p class="lead compact">Control the customer-facing download area without editing a template or deploying code.</p></div><a class="btn btn-secondary" href="{{ route('admin.releases.index') }}">Back to releases</a></section>
|
||||
<form class="editor-card settings-editor" method="post" action="{{ route('admin.releases.settings.update') }}">@csrf @method('PUT')<label>Eyebrow<input name="eyebrow" value="{{ old('eyebrow',$settings['downloads.eyebrow'] ?: 'SonarDesk for macOS') }}" required></label><label>Page title<input name="title" value="{{ old('title',$settings['downloads.title'] ?: 'Download SonarDesk') }}" required></label><label>Introduction<textarea name="intro" rows="4" required>{{ old('intro',$settings['downloads.intro'] ?: 'A focused native SonarQube workspace, designed for macOS and built for daily engineering work.') }}</textarea></label><label>System requirements<textarea name="requirements" rows="4" required>{{ old('requirements',$settings['downloads.requirements'] ?: 'Apple silicon and Intel Macs supported. A SonarQube server and valid SonarDesk licence are required.') }}</textarea></label><label>Support message<textarea name="support_text" rows="4" required>{{ old('support_text',$settings['downloads.support_text'] ?: 'Need help installing or activating SonarDesk? Our support team can help.') }}</textarea></label><div><button class="btn btn-primary" type="submit">Save website content</button> <a class="btn btn-secondary" target="_blank" href="{{ route('release') }}">Preview page</a></div></form>
|
||||
@endsection
|
||||
@@ -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
@@ -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
|
||||
|
||||
+15
-2
@@ -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,15 +10,19 @@ 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');
|
||||
@@ -59,6 +64,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');
|
||||
|
||||
@@ -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