From 2f00a136be76c498ee37ffd5579de07db494aecb Mon Sep 17 00:00:00 2001
From: Nyk <0xnykcd@googlemail.com>
Date: Wed, 4 Mar 2026 00:41:36 +0700
Subject: [PATCH] feat: implement pro entitlement activation and updater gating
---
docs/commercial/checkout-migration.md | 8 +
landing/README.md | 9 +-
landing/api/pro/verify.js | 98 +++++++++++
landing/index.html | 7 +
src-tauri/src/commands/mod.rs | 1 +
src-tauri/src/commands/pro.rs | 154 ++++++++++++++++++
src-tauri/src/lib.rs | 8 +-
src-tauri/src/settings.rs | 24 +++
src-tauri/src/shortcut/mod.rs | 4 +
src-tauri/src/tray.rs | 2 +-
.../settings/UpdateChecksToggle.tsx | 37 ++++-
.../settings/about/AboutSettings.tsx | 106 +++++++++++-
.../update-checker/UpdateChecker.tsx | 5 +-
src/hooks/useProEntitlement.ts | 13 ++
src/i18n/locales/en/translation.json | 15 +-
src/stores/proEntitlementStore.ts | 87 ++++++++++
src/utils/proEntitlement.ts | 32 ++++
17 files changed, 588 insertions(+), 22 deletions(-)
create mode 100644 landing/api/pro/verify.js
create mode 100644 src-tauri/src/commands/pro.rs
create mode 100644 src/hooks/useProEntitlement.ts
create mode 100644 src/stores/proEntitlementStore.ts
create mode 100644 src/utils/proEntitlement.ts
diff --git a/docs/commercial/checkout-migration.md b/docs/commercial/checkout-migration.md
index 79b38ea..e82864c 100644
--- a/docs/commercial/checkout-migration.md
+++ b/docs/commercial/checkout-migration.md
@@ -42,6 +42,13 @@ Entitlement grants:
- Auto-update eligibility
- Priority support queue (if enabled)
+Activation flow in app:
+
+- User opens **Settings -> About -> Upgrade to Dictx Pro**
+- User enters purchase email + Polar checkout ID (`polar_cl_...`)
+- App verifies against `https://dictx.splitlabs.io/api/pro/verify`
+- On success, app stores active entitlement and enables updater checks
+
## 3) Webhook Processing
Use a webhook endpoint to sync purchases to your entitlement store.
@@ -84,6 +91,7 @@ Before launch:
- Checkout success flow creates receipt + customer record
- Webhook signature validation works in production
- `dictx_pro` entitlement is granted/revoked correctly
+- `landing/api/pro/verify` returns `{ active: true }` only for valid paid checkout + matching email
- Customer portal access works from receipt email
- Purchase links from app + README resolve to `https://dictx.splitlabs.io/buy`
diff --git a/landing/README.md b/landing/README.md
index b0175cc..c7752d3 100644
--- a/landing/README.md
+++ b/landing/README.md
@@ -16,4 +16,11 @@ Attach `dictx.splitlabs.io` to this Vercel project.
## Routes
- `/` serves `landing/index.html`
-- `/buy` redirects to `https://buy.splitlabs.io`
+- `/buy` redirects to Polar checkout
+- `/api/pro/verify` validates a Polar checkout/email pair for in-app Pro activation
+
+## Environment Variables (Vercel)
+
+- `POLAR_ACCESS_TOKEN`: Polar API token
+- `POLAR_DICTX_PRODUCT_IDS`: optional comma-separated product IDs allowed for Dictx Pro activation
+- `POLAR_API_BASE`: optional override (defaults to `https://api.polar.sh/v1`)
diff --git a/landing/api/pro/verify.js b/landing/api/pro/verify.js
new file mode 100644
index 0000000..5e684be
--- /dev/null
+++ b/landing/api/pro/verify.js
@@ -0,0 +1,98 @@
+const POLAR_API_BASE = process.env.POLAR_API_BASE || "https://api.polar.sh/v1";
+const POLAR_ACCESS_TOKEN = process.env.POLAR_ACCESS_TOKEN || "";
+const ALLOWED_PRODUCT_IDS = (process.env.POLAR_DICTX_PRODUCT_IDS || "")
+ .split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+
+const normalizeEmail = (value) => value.trim().toLowerCase();
+
+const readBody = (req) => {
+ if (!req.body) return {};
+ if (typeof req.body === "string") {
+ try {
+ return JSON.parse(req.body);
+ } catch (_error) {
+ return {};
+ }
+ }
+ return req.body;
+};
+
+const statusLooksPaid = (status, paid) => {
+ if (paid === true) return true;
+ const value = (status || "").toLowerCase();
+ return (
+ value === "succeeded" ||
+ value === "confirmed" ||
+ value === "paid" ||
+ value === "completed"
+ );
+};
+
+module.exports = async (req, res) => {
+ if (req.method !== "POST") {
+ res.status(405).json({ error: "method_not_allowed" });
+ return;
+ }
+
+ if (!POLAR_ACCESS_TOKEN) {
+ res.status(500).json({ error: "missing_polar_access_token" });
+ return;
+ }
+
+ const body = readBody(req);
+ const checkoutId = (body.checkoutId || "").trim();
+ const email = normalizeEmail(body.email || "");
+
+ if (!checkoutId || !email) {
+ res.status(400).json({ error: "checkoutId_and_email_required" });
+ return;
+ }
+
+ try {
+ const response = await fetch(
+ `${POLAR_API_BASE}/checkouts/${encodeURIComponent(checkoutId)}`,
+ {
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${POLAR_ACCESS_TOKEN}`,
+ "Content-Type": "application/json",
+ },
+ },
+ );
+
+ if (response.status === 404) {
+ res.status(404).json({ active: false });
+ return;
+ }
+
+ if (!response.ok) {
+ const text = await response.text();
+ res.status(502).json({ error: "polar_api_error", detail: text });
+ return;
+ }
+
+ const checkout = await response.json();
+ const checkoutEmail = normalizeEmail(
+ checkout.customer_email ||
+ checkout.customer?.email ||
+ checkout.metadata?.customer_email ||
+ "",
+ );
+
+ const productId =
+ checkout.product_id == null ? "" : String(checkout.product_id);
+ const productAllowed =
+ ALLOWED_PRODUCT_IDS.length === 0 || ALLOWED_PRODUCT_IDS.includes(productId);
+ const emailMatches = checkoutEmail !== "" && checkoutEmail === email;
+ const paid = statusLooksPaid(checkout.status, checkout.paid);
+
+ res.status(200).json({ active: Boolean(productAllowed && emailMatches && paid) });
+ } catch (error) {
+ res.status(500).json({
+ error: "internal_error",
+ detail: error instanceof Error ? error.message : String(error),
+ });
+ }
+};
diff --git a/landing/index.html b/landing/index.html
index a972324..526fb91 100644
--- a/landing/index.html
+++ b/landing/index.html
@@ -198,6 +198,13 @@
Where does /buy go?
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 015c60b..127d16b 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -1,6 +1,7 @@
pub mod audio;
pub mod history;
pub mod models;
+pub mod pro;
pub mod transcription;
use crate::settings::{get_settings, write_settings, AppSettings, LogLevel};
diff --git a/src-tauri/src/commands/pro.rs b/src-tauri/src/commands/pro.rs
new file mode 100644
index 0000000..c6dad03
--- /dev/null
+++ b/src-tauri/src/commands/pro.rs
@@ -0,0 +1,154 @@
+use crate::settings::{self, ProEntitlement};
+use chrono::Utc;
+use reqwest::StatusCode;
+use serde::{Deserialize, Serialize};
+use tauri::AppHandle;
+
+const DEFAULT_PRO_VERIFY_URL: &str = "https://dictx.splitlabs.io/api/pro/verify";
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct VerifyRequest {
+ checkout_id: String,
+ email: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct VerifyResponse {
+ active: bool,
+}
+
+fn get_verify_url() -> String {
+ std::env::var("DICTX_PRO_VERIFY_URL").unwrap_or_else(|_| DEFAULT_PRO_VERIFY_URL.to_string())
+}
+
+fn normalize_email(email: &str) -> String {
+ email.trim().to_lowercase()
+}
+
+async fn verify_checkout(checkout_id: &str, email: &str) -> Result {
+ let payload = VerifyRequest {
+ checkout_id: checkout_id.trim().to_string(),
+ email: normalize_email(email),
+ };
+
+ if payload.checkout_id.is_empty() || payload.email.is_empty() {
+ return Err("Checkout ID and email are required".to_string());
+ }
+
+ let client = reqwest::Client::new();
+ let response = client
+ .post(get_verify_url())
+ .json(&payload)
+ .send()
+ .await
+ .map_err(|e| format!("Verification request failed: {}", e))?;
+
+ if response.status() == StatusCode::OK {
+ let body: VerifyResponse = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse verification response: {}", e))?;
+ Ok(body.active)
+ } else if response.status() == StatusCode::UNAUTHORIZED
+ || response.status() == StatusCode::NOT_FOUND
+ {
+ Ok(false)
+ } else {
+ let status = response.status();
+ let text = response
+ .text()
+ .await
+ .unwrap_or_else(|_| "unknown error".to_string());
+ Err(format!("Verification API error ({}): {}", status, text))
+ }
+}
+
+#[tauri::command]
+#[specta::specta]
+pub fn get_pro_entitlement(app: AppHandle) -> Result {
+ let settings = settings::get_settings(&app);
+ Ok(settings.pro_entitlement)
+}
+
+#[tauri::command]
+#[specta::specta]
+pub fn clear_pro_entitlement(app: AppHandle) -> Result {
+ let mut app_settings = settings::get_settings(&app);
+ app_settings.pro_entitlement = ProEntitlement::default();
+ app_settings.update_checks_enabled = false;
+ settings::write_settings(&app, app_settings.clone());
+ crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None);
+ Ok(app_settings.pro_entitlement)
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn activate_pro_entitlement(
+ app: AppHandle,
+ checkout_id: String,
+ email: String,
+) -> Result {
+ let active = verify_checkout(&checkout_id, &email).await?;
+ if !active {
+ return Err("No active Dictx Pro entitlement found for this checkout/email".to_string());
+ }
+
+ let now = Utc::now().timestamp();
+ let mut app_settings = settings::get_settings(&app);
+ app_settings.pro_entitlement = ProEntitlement {
+ active: true,
+ email: Some(normalize_email(&email)),
+ checkout_id: Some(checkout_id.trim().to_string()),
+ activated_at: Some(now),
+ last_verified_at: Some(now),
+ verification_error: None,
+ };
+ app_settings.update_checks_enabled = true;
+ settings::write_settings(&app, app_settings.clone());
+ crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None);
+
+ Ok(app_settings.pro_entitlement)
+}
+
+#[tauri::command]
+#[specta::specta]
+pub async fn refresh_pro_entitlement(app: AppHandle) -> Result {
+ let mut app_settings = settings::get_settings(&app);
+
+ let checkout_id = match app_settings.pro_entitlement.checkout_id.clone() {
+ Some(value) if !value.trim().is_empty() => value,
+ _ => return Ok(app_settings.pro_entitlement),
+ };
+
+ let email = match app_settings.pro_entitlement.email.clone() {
+ Some(value) if !value.trim().is_empty() => value,
+ _ => return Ok(app_settings.pro_entitlement),
+ };
+
+ let now = Utc::now().timestamp();
+ match verify_checkout(&checkout_id, &email).await {
+ Ok(true) => {
+ app_settings.pro_entitlement.active = true;
+ app_settings.pro_entitlement.last_verified_at = Some(now);
+ app_settings.pro_entitlement.verification_error = None;
+ }
+ Ok(false) => {
+ app_settings.pro_entitlement.active = false;
+ app_settings.pro_entitlement.last_verified_at = Some(now);
+ app_settings.pro_entitlement.verification_error =
+ Some("Entitlement no longer active".to_string());
+ app_settings.update_checks_enabled = false;
+ }
+ Err(err) => {
+ app_settings.pro_entitlement.verification_error = Some(err.clone());
+ settings::write_settings(&app, app_settings.clone());
+ return Err(err);
+ }
+ }
+
+ settings::write_settings(&app, app_settings.clone());
+ crate::tray::update_tray_menu(&app, &crate::tray::TrayIconState::Idle, None);
+ Ok(app_settings.pro_entitlement)
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index bed808f..45c4add 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -178,7 +178,7 @@ fn initialize_core_logic(app_handle: &AppHandle) {
}
"check_updates" => {
let settings = settings::get_settings(app);
- if settings.update_checks_enabled {
+ if settings.update_checks_enabled && settings.pro_entitlement.active {
show_main_window(app);
let _ = app.emit("check-for-updates", ());
}
@@ -250,7 +250,7 @@ fn initialize_core_logic(app_handle: &AppHandle) {
#[specta::specta]
fn trigger_update_check(app: AppHandle) -> Result<(), String> {
let settings = settings::get_settings(&app);
- if !settings.update_checks_enabled {
+ if !settings.update_checks_enabled || !settings.pro_entitlement.active {
return Ok(());
}
app.emit("check-for-updates", ())
@@ -358,6 +358,10 @@ pub fn run(cli_args: CliArgs) {
commands::history::delete_history_entry,
commands::history::update_history_limit,
commands::history::update_recording_retention_period,
+ commands::pro::get_pro_entitlement,
+ commands::pro::activate_pro_entitlement,
+ commands::pro::refresh_pro_entitlement,
+ commands::pro::clear_pro_entitlement,
helpers::clamshell::is_laptop,
]);
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 514c025..3265f1b 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -275,6 +275,22 @@ impl Default for TypingTool {
}
}
+#[derive(Serialize, Deserialize, Debug, Clone, Type, Default)]
+pub struct ProEntitlement {
+ #[serde(default)]
+ pub active: bool,
+ #[serde(default)]
+ pub email: Option,
+ #[serde(default)]
+ pub checkout_id: Option,
+ #[serde(default)]
+ pub activated_at: Option,
+ #[serde(default)]
+ pub last_verified_at: Option,
+ #[serde(default)]
+ pub verification_error: Option,
+}
+
/* still handy for composing the initial JSON in the store ------------- */
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
pub struct AppSettings {
@@ -368,6 +384,8 @@ pub struct AppSettings {
pub obsidian_export_subfolder: Option,
#[serde(default)]
pub obsidian_append_to_daily: bool,
+ #[serde(default)]
+ pub pro_entitlement: ProEntitlement,
}
fn default_model() -> String {
@@ -740,6 +758,7 @@ pub fn get_default_settings() -> AppSettings {
obsidian_vault_path: None,
obsidian_export_subfolder: default_obsidian_export_subfolder(),
obsidian_append_to_daily: false,
+ pro_entitlement: ProEntitlement::default(),
}
}
@@ -868,6 +887,11 @@ pub fn get_history_limit(app: &AppHandle) -> usize {
settings.history_limit
}
+pub fn is_pro_active(app: &AppHandle) -> bool {
+ let settings = get_settings(app);
+ settings.pro_entitlement.active
+}
+
pub fn get_recording_retention_period(app: &AppHandle) -> RecordingRetentionPeriod {
let settings = get_settings(app);
settings.recording_retention_period
diff --git a/src-tauri/src/shortcut/mod.rs b/src-tauri/src/shortcut/mod.rs
index 84de4b5..52b9203 100644
--- a/src-tauri/src/shortcut/mod.rs
+++ b/src-tauri/src/shortcut/mod.rs
@@ -622,6 +622,10 @@ pub fn change_autostart_setting(app: AppHandle, enabled: bool) -> Result<(), Str
#[tauri::command]
#[specta::specta]
pub fn change_update_checks_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
+ if enabled && !settings::is_pro_active(&app) {
+ return Err("Dictx Pro activation is required for auto-updates".to_string());
+ }
+
let mut settings = settings::get_settings(&app);
settings.update_checks_enabled = enabled;
settings::write_settings(&app, settings);
diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs
index 407b7b6..5a7706d 100644
--- a/src-tauri/src/tray.rs
+++ b/src-tauri/src/tray.rs
@@ -112,7 +112,7 @@ pub fn update_tray_menu(app: &AppHandle, state: &TrayIconState, locale: Option<&
app,
"check_updates",
&strings.check_updates,
- settings.update_checks_enabled,
+ settings.update_checks_enabled && settings.pro_entitlement.active,
None::<&str>,
)
.expect("failed to create check updates item");
diff --git a/src/components/settings/UpdateChecksToggle.tsx b/src/components/settings/UpdateChecksToggle.tsx
index 675dc3a..bb06155 100644
--- a/src/components/settings/UpdateChecksToggle.tsx
+++ b/src/components/settings/UpdateChecksToggle.tsx
@@ -2,6 +2,9 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { ToggleSwitch } from "../ui/ToggleSwitch";
import { useSettings } from "../../hooks/useSettings";
+import { useProEntitlement } from "@/hooks/useProEntitlement";
+import { openProPurchasePage } from "@/utils/commerce";
+import { Button } from "../ui/Button";
interface UpdateChecksToggleProps {
descriptionMode?: "inline" | "tooltip";
@@ -14,17 +17,33 @@ export const UpdateChecksToggle: React.FC = ({
}) => {
const { t } = useTranslation();
const { getSetting, updateSetting, isUpdating } = useSettings();
+ const { entitlement } = useProEntitlement();
const updateChecksEnabled = getSetting("update_checks_enabled") ?? true;
+ const proActive = entitlement?.active ?? false;
return (
- updateSetting("update_checks_enabled", enabled)}
- isUpdating={isUpdating("update_checks_enabled")}
- label={t("settings.debug.updateChecks.label")}
- description={t("settings.debug.updateChecks.description")}
- descriptionMode={descriptionMode}
- grouped={grouped}
- />
+
+
updateSetting("update_checks_enabled", enabled)}
+ isUpdating={isUpdating("update_checks_enabled")}
+ label={t("settings.debug.updateChecks.label")}
+ description={
+ proActive
+ ? t("settings.debug.updateChecks.description")
+ : t("settings.debug.updateChecks.proRequired")
+ }
+ descriptionMode={descriptionMode}
+ grouped={grouped}
+ disabled={!proActive}
+ />
+ {!proActive && (
+
+
+
+ )}
+
);
};
diff --git a/src/components/settings/about/AboutSettings.tsx b/src/components/settings/about/AboutSettings.tsx
index d94b6d2..3e0e3d6 100644
--- a/src/components/settings/about/AboutSettings.tsx
+++ b/src/components/settings/about/AboutSettings.tsx
@@ -5,14 +5,27 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import { SettingsGroup } from "../../ui/SettingsGroup";
import { SettingContainer } from "../../ui/SettingContainer";
import { Button } from "../../ui/Button";
+import { Input } from "../../ui/Input";
import { openProPurchasePage } from "@/utils/commerce";
import { AppDataDirectory } from "../AppDataDirectory";
import { AppLanguageSelector } from "../AppLanguageSelector";
import { LogDirectory } from "../debug";
+import { useProEntitlement } from "@/hooks/useProEntitlement";
export const AboutSettings: React.FC = () => {
const { t } = useTranslation();
const [version, setVersion] = useState("");
+ const [checkoutId, setCheckoutId] = useState("");
+ const [email, setEmail] = useState("");
+ const {
+ entitlement,
+ isLoading: proLoading,
+ isSubmitting: proSubmitting,
+ error: proError,
+ activate,
+ refresh,
+ clear,
+ } = useProEntitlement();
useEffect(() => {
const fetchVersion = async () => {
@@ -28,6 +41,13 @@ export const AboutSettings: React.FC = () => {
fetchVersion();
}, []);
+ const handleActivate = async () => {
+ const ok = await activate(checkoutId, email);
+ if (ok) {
+ setCheckoutId("");
+ }
+ };
+
return (
@@ -45,14 +65,86 @@ export const AboutSettings: React.FC = () => {
title={t("settings.about.supportDevelopment.title")}
description={t("settings.about.supportDevelopment.description")}
grouped={true}
+ layout="stacked"
>
-
+
+
+
+
+ {entitlement?.active && (
+
+ )}
+
+
+
+
+ {entitlement?.active
+ ? t("settings.about.proActivation.active")
+ : t("settings.about.proActivation.inactive")}
+
+ {entitlement?.email && (
+
+ {t("settings.about.proActivation.email")}: {entitlement.email}
+
+ )}
+ {entitlement?.checkout_id && (
+
+ {t("settings.about.proActivation.checkoutId")}:{" "}
+ {entitlement.checkout_id}
+
+ )}
+
+ setEmail(event.target.value)}
+ placeholder={t("settings.about.proActivation.emailPlaceholder")}
+ disabled={proSubmitting}
+ />
+ setCheckoutId(event.target.value)}
+ placeholder={t(
+ "settings.about.proActivation.checkoutIdPlaceholder",
+ )}
+ disabled={proSubmitting}
+ />
+
+
+ {proError &&
{proError}
}
+ {entitlement?.verification_error && (
+
+ {entitlement.verification_error}
+
+ )}
+
+
= ({ className = "" }) => {
const [showUpToDate, setShowUpToDate] = useState(false);
const { settings, isLoading } = useSettings();
+ const { entitlement } = useProEntitlement();
const settingsLoaded = !isLoading && settings !== null;
- const updateChecksEnabled = settings?.update_checks_enabled ?? false;
+ const proActive = entitlement?.active ?? false;
+ const updateChecksEnabled = (settings?.update_checks_enabled ?? false) && proActive;
const upToDateTimeoutRef = useRef>();
const isManualCheckRef = useRef(false);
diff --git a/src/hooks/useProEntitlement.ts b/src/hooks/useProEntitlement.ts
new file mode 100644
index 0000000..36ac9ac
--- /dev/null
+++ b/src/hooks/useProEntitlement.ts
@@ -0,0 +1,13 @@
+import { useEffect } from "react";
+import { useProEntitlementStore } from "@/stores/proEntitlementStore";
+
+export const useProEntitlement = () => {
+ const initialize = useProEntitlementStore((state) => state.initialize);
+ const store = useProEntitlementStore();
+
+ useEffect(() => {
+ void initialize();
+ }, [initialize]);
+
+ return store;
+};
diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json
index 24e6288..be6e8e7 100644
--- a/src/i18n/locales/en/translation.json
+++ b/src/i18n/locales/en/translation.json
@@ -457,7 +457,9 @@
},
"updateChecks": {
"label": "Check for Updates",
- "description": "Automatically check for new versions of Dictx"
+ "description": "Automatically check for new versions of Dictx",
+ "proRequired": "Auto-updates are available for active Dictx Pro installations.",
+ "upgrade": "Activate or Upgrade Pro"
},
"soundTheme": {
"label": "Sound Theme",
@@ -537,6 +539,17 @@
"description": "One-time purchase for signed builds, in-app auto-updates, and priority support.",
"button": "Upgrade Now"
},
+ "proActivation": {
+ "active": "Dictx Pro is active on this device.",
+ "inactive": "Dictx Pro is not active. Enter your purchase email and checkout ID to activate updater entitlement.",
+ "activate": "Activate Dictx Pro",
+ "refresh": "Re-check Entitlement",
+ "clear": "Remove Activation",
+ "email": "Email",
+ "checkoutId": "Checkout ID",
+ "emailPlaceholder": "Purchase email",
+ "checkoutIdPlaceholder": "polar_cl_..."
+ },
"acknowledgments": {
"title": "Acknowledgments",
"whisper": {
diff --git a/src/stores/proEntitlementStore.ts b/src/stores/proEntitlementStore.ts
new file mode 100644
index 0000000..5c1db6e
--- /dev/null
+++ b/src/stores/proEntitlementStore.ts
@@ -0,0 +1,87 @@
+import { create } from "zustand";
+import {
+ activateProEntitlement,
+ clearProEntitlement,
+ getProEntitlement,
+ refreshProEntitlement,
+ type ProEntitlement,
+} from "@/utils/proEntitlement";
+
+interface ProEntitlementStore {
+ entitlement: ProEntitlement | null;
+ isLoading: boolean;
+ isSubmitting: boolean;
+ error: string | null;
+ initialize: () => Promise;
+ activate: (checkoutId: string, email: string) => Promise;
+ refresh: () => Promise;
+ clear: () => Promise;
+}
+
+export const useProEntitlementStore = create(
+ (set, get) => ({
+ entitlement: null,
+ isLoading: true,
+ isSubmitting: false,
+ error: null,
+
+ initialize: async () => {
+ if (!get().isLoading) {
+ return;
+ }
+ try {
+ let entitlement = await getProEntitlement();
+ if (entitlement.checkout_id && entitlement.email) {
+ try {
+ entitlement = await refreshProEntitlement();
+ } catch (_error) {
+ // Keep last known state if background verification fails.
+ }
+ }
+ set({ entitlement, error: null });
+ } catch (error) {
+ set({ error: error instanceof Error ? error.message : String(error) });
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ activate: async (checkoutId: string, email: string) => {
+ set({ isSubmitting: true, error: null });
+ try {
+ const entitlement = await activateProEntitlement(checkoutId, email);
+ set({ entitlement });
+ return true;
+ } catch (error) {
+ set({ error: error instanceof Error ? error.message : String(error) });
+ return false;
+ } finally {
+ set({ isSubmitting: false });
+ }
+ },
+
+ refresh: async () => {
+ set({ isSubmitting: true, error: null });
+ try {
+ const entitlement = await refreshProEntitlement();
+ set({ entitlement });
+ } catch (error) {
+ set({ error: error instanceof Error ? error.message : String(error) });
+ } finally {
+ set({ isSubmitting: false });
+ }
+ },
+
+ clear: async () => {
+ set({ isSubmitting: true, error: null });
+ try {
+ const entitlement = await clearProEntitlement();
+ set({ entitlement });
+ } catch (error) {
+ set({ error: error instanceof Error ? error.message : String(error) });
+ } finally {
+ set({ isSubmitting: false });
+ }
+ },
+ }),
+);
diff --git a/src/utils/proEntitlement.ts b/src/utils/proEntitlement.ts
new file mode 100644
index 0000000..20ee27b
--- /dev/null
+++ b/src/utils/proEntitlement.ts
@@ -0,0 +1,32 @@
+import { invoke } from "@tauri-apps/api/core";
+
+export interface ProEntitlement {
+ active: boolean;
+ email?: string | null;
+ checkout_id?: string | null;
+ activated_at?: number | null;
+ last_verified_at?: number | null;
+ verification_error?: string | null;
+}
+
+export const getProEntitlement = async (): Promise => {
+ return await invoke("get_pro_entitlement");
+};
+
+export const activateProEntitlement = async (
+ checkoutId: string,
+ email: string,
+): Promise => {
+ return await invoke("activate_pro_entitlement", {
+ checkoutId,
+ email,
+ });
+};
+
+export const refreshProEntitlement = async (): Promise => {
+ return await invoke("refresh_pro_entitlement");
+};
+
+export const clearProEntitlement = async (): Promise => {
+ return await invoke("clear_pro_entitlement");
+};