TASK 10: Polar billing + Velopack auto-updates (steps 1-7)

This commit is contained in:
2026-08-21 08:18:52 -07:00
parent 2f135687cd
commit 8951141c65
10 changed files with 417 additions and 78 deletions
+70
View File
@@ -1027,6 +1027,76 @@ public class LayoutStore : IDisposable
return (string)cmd.ExecuteScalar()!;
}
// ─── License (TASK 10 — Polar billing) ───
/// <summary>The saved license key, or null if never entered.</summary>
public string? LoadLicenseKey()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseKey';";
var value = cmd.ExecuteScalar() as string;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
public void SaveLicenseKey(string? key)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseKey';";
cmd.ExecuteNonQuery();
if (string.IsNullOrWhiteSpace(key)) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseKey', $value);";
insert.Parameters.AddWithValue("$value", key);
insert.ExecuteNonQuery();
}
/// <summary>When the license was last validated online (ISO 8601), or null if never.</summary>
public DateTime? LoadLicenseValidatedAt()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'LicenseValidatedAt';";
var value = cmd.ExecuteScalar() as string;
if (string.IsNullOrWhiteSpace(value)) return null;
return DateTime.TryParse(value, out var dt) ? dt : null;
}
public void SaveLicenseValidatedAt(DateTime? dt)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'LicenseValidatedAt';";
cmd.ExecuteNonQuery();
if (dt == null) return;
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('LicenseValidatedAt', $value);";
insert.Parameters.AddWithValue("$value", dt.Value.ToString("o"));
insert.ExecuteNonQuery();
}
/// <summary>Cached premium state (1 = premium, 0 = free). Used for offline
/// grace period — the app checks online at startup but uses this cached
/// value when offline.</summary>
public bool LoadIsPremium()
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'IsPremium';";
var value = cmd.ExecuteScalar() as string;
return value == "1";
}
public void SaveIsPremium(bool isPremium)
{
using var cmd = _connection.CreateCommand();
cmd.CommandText = "DELETE FROM Settings WHERE Key = 'IsPremium';";
cmd.ExecuteNonQuery();
using var insert = _connection.CreateCommand();
insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('IsPremium', $value);";
insert.Parameters.AddWithValue("$value", isPremium ? "1" : "0");
insert.ExecuteNonQuery();
}
public void Dispose()
{
_connection.Dispose();
+87
View File
@@ -0,0 +1,87 @@
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ytLive.Services;
/// <summary>
/// Validates LlamaCasty license keys against the Polar.sh customer-portal API.
/// No auth required for client-side validation — the endpoint is public by design.
/// </summary>
public class PolarLicenseService
{
private const string ValidateUrl = "https://api.polar.sh/v1/customer-portal/license-keys/validate";
public const string OrganizationId = "c05fb364-b967-4f6c-adf2-8a144e46d085";
private readonly HttpClient _http;
public PolarLicenseService(HttpClient? http = null)
{
_http = http ?? new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
}
public async Task<LicenseValidationResult> ValidateAsync(string key, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(key))
return new LicenseValidationResult { IsValid = false, Error = "Key is empty" };
try
{
var request = new { key = key.Trim(), organization_id = OrganizationId };
var response = await _http.PostAsJsonAsync(ValidateUrl, request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
return new LicenseValidationResult
{
IsValid = false,
Error = $"Validation failed ({(int)response.StatusCode}): {body}"
};
}
var result = await response.Content.ReadFromJsonAsync<PolarValidateResponse>(ct);
if (result == null)
return new LicenseValidationResult { IsValid = false, Error = "Empty response from Polar" };
var status = result.Status?.ToLowerInvariant();
return new LicenseValidationResult
{
IsValid = status == "granted",
Status = status ?? "unknown",
ExpiresAt = result.ExpiresAt,
Error = status == "granted" ? null : $"License status: {status}"
};
}
catch (TaskCanceledException)
{
return new LicenseValidationResult { IsValid = false, Error = "Validation timed out" };
}
catch (HttpRequestException ex)
{
return new LicenseValidationResult { IsValid = false, Error = $"Network error: {ex.Message}" };
}
catch (Exception ex)
{
return new LicenseValidationResult { IsValid = false, Error = $"Unexpected error: {ex.Message}" };
}
}
private sealed class PolarValidateResponse
{
[JsonPropertyName("status")]
public string? Status { get; set; }
[JsonPropertyName("expires_at")]
public DateTime? ExpiresAt { get; set; }
}
}
public sealed class LicenseValidationResult
{
public bool IsValid { get; set; }
public string? Status { get; set; }
public DateTime? ExpiresAt { get; set; }
public string? Error { get; set; }
}