TASK 10: Polar billing + Velopack auto-updates (steps 1-7)
This commit is contained in:
+163
-6
@@ -152,14 +152,19 @@ public class MainViewModel : ViewModelBase
|
||||
private bool _brandFlashEnabled = true;
|
||||
private bool _brandFlashActive;
|
||||
|
||||
// TASK 10 — Polar license validation
|
||||
private string _licenseKey = string.Empty;
|
||||
private string _licenseMessage = string.Empty;
|
||||
private bool _isLicenseValidating;
|
||||
private bool _isLicenseEntryOpen;
|
||||
private static readonly TimeSpan OfflineGracePeriod = TimeSpan.FromDays(14);
|
||||
|
||||
private const string SupportEmail = "gramps@llamachile.shop";
|
||||
// The creator-hub link targets (About overlay) — public so the hub's
|
||||
// integration test can assert they point at real destinations.
|
||||
public const string ChannelUrl = "https://youtube.com/@llamachileshop";
|
||||
public const string MastodonUrl = "https://mastodon.llamachile.tube/@gramps";
|
||||
// Tabled 2026-08-14: billing provider chosen (Polar — polar.sh);
|
||||
// the subscription page (premium unlock) lands here once set up. Coffee/thanks URL is live.
|
||||
public const string PremiumUrl = "";
|
||||
public const string PremiumUrl = "https://buy.polar.sh/polar_cl_ueMy8AyAClO0o0yo19IroZwKTbD587gzUsjEW3bFapw";
|
||||
public const string CoffeeUrl = "https://buymeacoffee.com/llamachiley";
|
||||
public static string AppVersionLabel => $"Version {typeof(MainViewModel).Assembly.GetName().Version?.ToString(3)}";
|
||||
|
||||
@@ -270,8 +275,19 @@ public class MainViewModel : ViewModelBase
|
||||
/// <summary>The global socials config (entries + bar settings), or null.</summary>
|
||||
public SocialsConfig? Socials => _socials;
|
||||
|
||||
/// <summary>Freemium: 6 slots — YT + 1 other, the rest locked. Premium seam: all 6 open.</summary>
|
||||
private static bool IsPremium => false; // unlock deferred (TASK 10) — seam only
|
||||
/// <summary>True when the user holds a valid LlamaCasty license (Polar). When
|
||||
/// false, the branding flash runs on every live stream.</summary>
|
||||
private bool _isPremium;
|
||||
public bool IsPremium
|
||||
{
|
||||
get => _isPremium;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _isPremium, value)) return;
|
||||
// Flip the branding flash when premium state changes.
|
||||
BrandFlashEnabled = !value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Canvas.Top for the social bar: 0 = top, 1040 = bottom (40px from the 1080 edge).</summary>
|
||||
public double SocialBarTop => _socials?.BarPosition == SocialBarPosition.Top ? 0 : 1040;
|
||||
@@ -942,7 +958,11 @@ public class MainViewModel : ViewModelBase
|
||||
public bool IsLicensesOpen
|
||||
{
|
||||
get => _isLicensesOpen;
|
||||
set => SetProperty(ref _isLicensesOpen, value);
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isLicensesOpen, value))
|
||||
OnPropertyChanged(nameof(IsHubVisible));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAnyOverlayOpen => IsSettingsOpen || IsBugOpen || IsFeatureOpen || IsAboutOpen;
|
||||
@@ -1124,6 +1144,39 @@ public class MainViewModel : ViewModelBase
|
||||
set => SetProperty(ref _featureRequestEmail, value);
|
||||
}
|
||||
|
||||
// TASK 10 — license key entry
|
||||
public string LicenseKey
|
||||
{
|
||||
get => _licenseKey;
|
||||
set => SetProperty(ref _licenseKey, value);
|
||||
}
|
||||
|
||||
public string LicenseMessage
|
||||
{
|
||||
get => _licenseMessage;
|
||||
private set => SetProperty(ref _licenseMessage, value);
|
||||
}
|
||||
|
||||
public bool IsLicenseValidating
|
||||
{
|
||||
get => _isLicenseValidating;
|
||||
private set => SetProperty(ref _isLicenseValidating, value);
|
||||
}
|
||||
|
||||
public bool IsLicenseEntryOpen
|
||||
{
|
||||
get => _isLicenseEntryOpen;
|
||||
set
|
||||
{
|
||||
if (SetProperty(ref _isLicenseEntryOpen, value))
|
||||
OnPropertyChanged(nameof(IsHubVisible));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The About hub is visible only when neither the licenses panel
|
||||
/// nor the license entry panel is open.</summary>
|
||||
public bool IsHubVisible => !IsLicensesOpen && !IsLicenseEntryOpen;
|
||||
|
||||
// Commands
|
||||
public ICommand AddSceneCommand { get; }
|
||||
public ICommand AddSourceCommand { get; }
|
||||
@@ -1159,6 +1212,8 @@ public class MainViewModel : ViewModelBase
|
||||
public ICommand SaveLayoutCommand { get; }
|
||||
public ICommand SaveLayoutAsCommand { get; }
|
||||
public ICommand OpenLayoutCommand { get; }
|
||||
public ICommand ActivateLicenseCommand { get; }
|
||||
public ICommand OpenLicenseEntryCommand { get; }
|
||||
|
||||
public MainViewModel()
|
||||
{
|
||||
@@ -1229,6 +1284,8 @@ public class MainViewModel : ViewModelBase
|
||||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||
SaveLayoutAsCommand = new RelayCommand(_ => SaveLayoutAs());
|
||||
OpenLayoutCommand = new RelayCommand(_ => OpenLayoutFile());
|
||||
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
|
||||
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
|
||||
|
||||
_layoutStore = new LayoutStore(LayoutPathOverride ?? DefaultLayoutPath);
|
||||
_activeLayoutPath = _layoutStore.ActivePath;
|
||||
@@ -1304,6 +1361,7 @@ public class MainViewModel : ViewModelBase
|
||||
_reusableStream = _layoutStore.LoadReusableStream();
|
||||
_reusableStreamUrl = _reusableStream?.RtmpUrl;
|
||||
_ = LoadSavedSessionAsync();
|
||||
_ = ValidateLicenseAtStartupAsync();
|
||||
AppLog.Write("MainViewModel ctor end");
|
||||
}
|
||||
|
||||
@@ -1345,6 +1403,105 @@ public class MainViewModel : ViewModelBase
|
||||
AccountDisplayName = channel?.DisplayName ?? string.Empty;
|
||||
}
|
||||
|
||||
// ─── TASK 10 — Polar license validation ───
|
||||
|
||||
/// <summary>At startup, load the cached license state and re-validate online
|
||||
/// if a key exists. If offline and within the 14-day grace period, the cached
|
||||
/// premium state is trusted. If the grace period is exceeded, premium lapses.</summary>
|
||||
private async Task ValidateLicenseAtStartupAsync()
|
||||
{
|
||||
var savedKey = _layoutStore.LoadLicenseKey();
|
||||
if (string.IsNullOrWhiteSpace(savedKey))
|
||||
{
|
||||
IsPremium = false;
|
||||
return;
|
||||
}
|
||||
|
||||
LicenseKey = savedKey;
|
||||
var validatedAt = _layoutStore.LoadLicenseValidatedAt();
|
||||
var cachedPremium = _layoutStore.LoadIsPremium();
|
||||
|
||||
// If the last validation was within the grace period, trust the cache
|
||||
// while we attempt an online re-validation in the background.
|
||||
if (validatedAt.HasValue && DateTime.UtcNow - validatedAt.Value < OfflineGracePeriod)
|
||||
{
|
||||
IsPremium = cachedPremium;
|
||||
}
|
||||
|
||||
// Try an online check — if it succeeds, it overrides the cache.
|
||||
try
|
||||
{
|
||||
var service = new PolarLicenseService();
|
||||
var result = await service.ValidateAsync(savedKey);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IsPremium = true;
|
||||
_layoutStore.SaveIsPremium(true);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Key revoked or expired — clear premium.
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
AppLog.Write($"License validation failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Offline or network error — keep the cached state, log it.
|
||||
AppLog.Write($"License re-validation skipped (offline?): {ex.Message}");
|
||||
if (!validatedAt.HasValue || DateTime.UtcNow - validatedAt.Value >= OfflineGracePeriod)
|
||||
{
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Called from the Activate button in the About overlay. Validates
|
||||
/// the entered key, persists it, and flips premium state.</summary>
|
||||
private async Task ActivateLicenseAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(LicenseKey)) return;
|
||||
|
||||
IsLicenseValidating = true;
|
||||
LicenseMessage = "Validating\u2026";
|
||||
|
||||
try
|
||||
{
|
||||
var service = new PolarLicenseService();
|
||||
var result = await service.ValidateAsync(LicenseKey);
|
||||
if (result.IsValid)
|
||||
{
|
||||
IsPremium = true;
|
||||
_layoutStore.SaveLicenseKey(LicenseKey.Trim());
|
||||
_layoutStore.SaveIsPremium(true);
|
||||
_layoutStore.SaveLicenseValidatedAt(DateTime.UtcNow);
|
||||
LicenseMessage = "Premium activated! The watermark has been removed.";
|
||||
AppLog.Write("License activated successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
IsPremium = false;
|
||||
_layoutStore.SaveLicenseKey(null);
|
||||
_layoutStore.SaveIsPremium(false);
|
||||
LicenseMessage = result.Error ?? "Invalid license key.";
|
||||
AppLog.Write($"License activation failed: {result.Error}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LicenseMessage = $"Activation error: {ex.Message}";
|
||||
AppLog.Write($"License activation error: {ex}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLicenseValidating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DefaultLayoutPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"ytLlive",
|
||||
|
||||
Reference in New Issue
Block a user