From 550291ff1c61066df74985b532e3225ab85b046c Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 19:16:44 -0800 Subject: [PATCH 01/10] Pro turns on by itself after checkout, coupon code in the app Upgrading no longer asks for an email. The app keeps a one-time secret and sends only its hash with the checkout; once paid, stripe-webhook records the claim and the new license-claim function activates that computer, no code. An address that already had a license still confirms with the emailed code, so paying with someone else's email cannot take over their account. The upgrade card also takes a coupon, checked against Stripe promotion codes before checkout opens. --- apps/studio/src/agent_panel.rs | 181 +++++++++++++----- backend/README.md | 2 +- backend/setup.sh | 2 +- .../functions/create-checkout/index.ts | 49 +++-- .../supabase/functions/license-claim/index.ts | 42 ++++ .../functions/stripe-webhook/index.ts | 18 +- .../20261109000000_checkout_claims.sql | 16 ++ crates/sf-license/src/lib.rs | 79 ++++++++ 8 files changed, 330 insertions(+), 59 deletions(-) create mode 100644 backend/supabase/functions/license-claim/index.ts create mode 100644 backend/supabase/migrations/20261109000000_checkout_claims.sql diff --git a/apps/studio/src/agent_panel.rs b/apps/studio/src/agent_panel.rs index ba886f1..c5de71f 100644 --- a/apps/studio/src/agent_panel.rs +++ b/apps/studio/src/agent_panel.rs @@ -313,8 +313,19 @@ pub struct AgentPanel { credits_rx: Option>>, /// The plan could not be checked (offline): shown with Try again. credits_error: Option, - /// Email for checkout when the computer has no license email saved. + /// Email for top-up checkout when the computer has no license email saved. email: String, + /// Coupon typed on the upgrade card (applied to the Pro checkout), and whether its field + /// is open. + promo: String, + promo_open: bool, + /// A Pro checkout started here: until when it is polled (`license-claim`) to activate this + /// computer by itself once paid, the next poll, and the poll in flight. + claim_until: Option, + next_claim: Option, + claim_rx: Option>>, + /// A checkout left pending by an earlier session was looked for. + claim_resumed: bool, /// A checkout being opened: its URL, or why it failed. checkout_rx: Option>>, /// The sign-in form, while it is open. @@ -433,8 +444,18 @@ impl AgentPanel { self.credits_rx = Some(rx); } - /// Open Stripe Checkout for `plan` (`pro`, `topup_10` …) in the browser. + /// Open Stripe Checkout for `plan` (`pro`, `topup_10` …) in the browser. Pro needs no email + /// here: Checkout asks for it, and this computer is activated once paid (see `poll_claim`). fn buy(&mut self, plan: &str) { + if plan == "pro" { + let (tx, rx) = mpsc::channel(); + let (email, promo) = (sf_license::load_email(), self.promo.trim().to_string()); + std::thread::spawn(move || { + let _ = tx.send(sf_license::start_checkout("pro", email.as_deref(), Some(&promo))); + }); + self.checkout_rx = Some(rx); + return; + } let email = sf_license::load_email().unwrap_or_else(|| self.email.trim().to_string()); if !email.contains('@') { self.log.push(Entry::Error("Enter your email in the credits menu first.".into())); @@ -560,7 +581,13 @@ impl AgentPanel { match rx.try_recv() { Ok(Ok(url)) => { crate::os::open(&url); - self.log.push(Entry::Note("Opened checkout in your browser. Credits arrive here a moment after you pay.".into())); + let note = if sf_license::pending_claim() && self.credits.as_ref().is_some_and(|c| c["pro"].as_bool() != Some(true)) { + self.watch_claim(30 * 60); + "Opened checkout in your browser. Pro turns on here by itself once you pay." + } else { + "Opened checkout in your browser. Credits arrive here a moment after you pay." + }; + self.log.push(Entry::Note(note.into())); self.checkout_rx = None; } Ok(Err(e)) => { @@ -573,6 +600,77 @@ impl AgentPanel { } } + /// Poll the checkout started here for `secs` (it activates this computer once paid). + fn watch_claim(&mut self, secs: u64) { + let now = std::time::Instant::now(); + self.claim_until = Some(now + std::time::Duration::from_secs(secs)); + self.next_claim = Some(now + std::time::Duration::from_secs(3)); + } + + /// Ask whether the checkout was paid; activate here when it was. + fn poll_claim(&mut self) { + // a checkout from an earlier session (paid after quitting, or still open) + if !self.claim_resumed && self.credits.as_ref().is_some_and(|c| c["allowed"].as_bool() == Some(false)) { + self.claim_resumed = true; + if sf_license::pending_claim() && self.claim_until.is_none() { + self.watch_claim(10 * 60); + self.next_claim = Some(std::time::Instant::now()); + } + } + if let Some(rx) = &self.claim_rx { + let r = match rx.try_recv() { + Ok(r) => r, + Err(mpsc::TryRecvError::Empty) => return, + Err(mpsc::TryRecvError::Disconnected) => Err("no checkout".into()), + }; + self.claim_rx = None; + let now = std::time::Instant::now(); + match r { + Ok(sf_license::Claim::Done(st)) if st.is_valid() => { + self.claim_until = None; + self.promo.clear(); + self.promo_open = false; + self.after_account_change(Some("Welcome to Pro. The agent is ready on this computer.".into())); + } + Ok(sf_license::Claim::Done(st)) => { + self.claim_until = None; + self.log.push(Entry::Error(format!("That license is not valid here: {st:?}"))); + } + Ok(sf_license::Claim::Pending) => self.next_claim = Some(now + std::time::Duration::from_secs(3)), + Ok(sf_license::Claim::Verify(email)) => { + // the address had an account before: confirm it is yours with the code + self.claim_until = None; + self.email = email.clone(); + self.sign_in = Some(SignIn { email, ..Default::default() }); + self.sign_in_step(); + self.notice = Some("Payment received. That email already has a ScreenForge account, so confirm it with the code we email you.".into()); + } + Err(e) if e == "no checkout" => self.claim_until = None, + Err(e) if e.contains("already used") => { + self.claim_until = None; + self.log.push(Entry::Error(e)); + } + // offline or a server hiccup: keep trying, slower + Err(e) => { + tracing::info!("checking the checkout: {e}"); + self.next_claim = Some(now + std::time::Duration::from_secs(10)); + } + } + } + let now = std::time::Instant::now(); + if self.claim_until.is_some_and(|t| now > t) { + self.claim_until = None; + } + if self.claim_until.is_some() && self.claim_rx.is_none() && self.next_claim.is_some_and(|t| now >= t) { + self.next_claim = None; + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(sf_license::claim_checkout()); + }); + self.claim_rx = Some(rx); + } + } + /// The license on this computer changed: check the plan again and tell the export dialog. fn after_account_change(&mut self, note: Option) { self.credits = None; @@ -694,7 +792,8 @@ impl AgentPanel { let detail = if pro { format!("{} this month · {} top-up", thousands(num("monthly")), thousands(num("topup"))) } else { "About 1¢ of Claude each".to_string() }; ui.label(RichText::new(detail).size(12.0).color(pal.text_faint)); - if sf_license::load_email().is_none() { + // top-ups go to the license's address (Pro's checkout asks for its own) + if licensed && sf_license::load_email().is_none() { ui.add_space(8.0); ui.label(RichText::new("Email for checkout").size(12.0).color(pal.text_dim)); widgets::text_field(ui, &mut self.email, "you@example.com"); @@ -746,51 +845,41 @@ impl AgentPanel { ui.add(egui::Label::new(RichText::new(line).size(13.0).color(pal.text_dim)).wrap()); }); } - ui.add_space(12.0); - // one email for both, as in Export: Upgrade opens checkout, Activate signs in - ui.label(RichText::new("Email used at checkout").size(12.5).color(pal.text_dim)); - ui.add_space(2.0); - widgets::text_field(ui, &mut self.email, "you@example.com"); - let typed = self.email.trim().to_string(); - let awaiting = self.sign_in.as_ref().is_some_and(|s| s.code_sent && s.email.trim().eq_ignore_ascii_case(&typed)); - if awaiting { - ui.add_space(6.0); - ui.label(RichText::new("Code from the email").size(12.5).color(pal.text_dim)); - ui.add_space(2.0); - if let Some(s) = self.sign_in.as_mut() { - widgets::text_field(ui, &mut s.code, "123456"); - } + ui.add_space(14.0); + if self.sign_in.is_some() { + // Already subscribed: the email, then the emailed code + self.sign_in_ui(ui); + return; + } + let opening = self.checkout_rx.is_some(); + let waiting = self.claim_until.is_some(); + let label = if opening { "Opening checkout…" } else { "Upgrade to Pro · $20/month" }; + if ui.add_enabled_ui(!opening, |ui| widgets::accent_button(ui, label)).inner.clicked() { + self.buy("pro"); } ui.add_space(8.0); - let busy = self.sign_in.as_ref().is_some_and(|s| s.rx.is_some()); - let (mut activate, mut confirm) = (false, false); - ui.horizontal_wrapped(|ui| { - if awaiting { - confirm = ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, if busy { "Checking…" } else { "Confirm" })).inner.clicked(); - activate = ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, "Send again")).inner.clicked(); - } else { - activate = ui.add_enabled_ui(!busy && typed.contains('@'), |ui| widgets::secondary_button(ui, if busy { "Activating…" } else { "Activate" })).inner.clicked(); - } - if widgets::accent_button(ui, "Upgrade to Pro · $20/month").clicked() { - self.buy("pro"); - } - }); - if activate { - // a fresh sign-in for the typed email (asks for a code unless already activated) - self.sign_in = Some(SignIn { email: typed.clone(), ..Default::default() }); - self.sign_in_step(); - } else if confirm { - self.sign_in_step(); + if self.promo_open { + ui.label(RichText::new("Coupon code").size(12.5).color(pal.text_dim)); + ui.add_space(2.0); + widgets::text_field(ui, &mut self.promo, "CODE"); + ui.add_space(2.0); + widgets::hint(ui, "Applied when checkout opens."); + } else if quiet_link(ui, "Have a coupon code?").clicked() { + self.promo_open = true; } - if let Some(e) = self.sign_in.as_ref().and_then(|s| s.error.clone()) { - ui.add_space(4.0); - ui.add(egui::Label::new(RichText::new(e).size(12.5).color(pal.danger)).wrap()); - } else if awaiting { - ui.add_space(4.0); - ui.add(egui::Label::new(RichText::new(format!("We emailed a 6-digit code to {typed}.")).size(12.5).color(pal.accent_hover)).wrap()); + ui.add_space(10.0); + if waiting { + ui.horizontal(|ui| { + working_dots(ui); + ui.add(egui::Label::new(RichText::new("Waiting for your payment. Pro turns on here by itself.").size(12.5).color(pal.text_dim)).wrap()); + }); + } else { + widgets::hint(ui, "No key or code to type: once you pay, Pro turns on here by itself."); } ui.add_space(6.0); - widgets::hint(ui, "Already subscribed? Press Activate: we email you a code to confirm this computer. After paying, do the same to start using Pro here."); + if quiet_link(ui, "Already subscribed? Sign in").clicked() { + self.open_sign_in(None); + } }); if let Some(n) = &self.notice { ui.add_space(8.0); @@ -950,12 +1039,16 @@ impl AgentPanel { pub fn ui(&mut self, ed: &mut Editor, ui: &mut egui::Ui) { let pal = theme::p(); self.poll_billing(); + self.poll_claim(); if std::mem::take(&mut self.license_changed) { ed.export.license = sf_license::load_saved().map(|k| sf_license::verify(&k)).unwrap_or_else(|| sf_license::Status::Invalid("no key".into())); } if self.credits.is_none() && self.credits_error.is_none() { self.fetch_credits(); } + if self.claim_until.is_some() { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } if self.credits_rx.is_some() || self.restore_rx.is_some() || self.checkout_rx.is_some() || self.account_rx.is_some() || self.sign_in.as_ref().is_some_and(|s| s.rx.is_some()) { ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); } diff --git a/backend/README.md b/backend/README.md index 1d197fc..f3d5b57 100644 --- a/backend/README.md +++ b/backend/README.md @@ -26,7 +26,7 @@ manual steps, for reference: 2. Stripe: create a product with a one-time US$49 price (`setup.sh` does this; `update-price.sh ` changes the price later, Stripe and Supabase in one go); set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_LIFETIME`. 3. Resend: `RESEND_API_KEY`, `LICENSE_FROM_EMAIL`. -4. `supabase db push && supabase functions deploy create-checkout stripe-webhook license-refresh license-activate free-export unsplash jamendo agent voice` +4. `supabase db push && supabase functions deploy create-checkout stripe-webhook license-refresh license-activate license-claim free-export unsplash jamendo agent voice` 5. Point `PURCHASE_URL` in `crates/sf-license` at your checkout page (or the `create-checkout` URL). ## Activation from the app diff --git a/backend/setup.sh b/backend/setup.sh index 04212fb..8bbe309 100755 --- a/backend/setup.sh +++ b/backend/setup.sh @@ -65,7 +65,7 @@ fi supabase db push echo "→ Supabase: functions" -supabase functions deploy create-checkout license-refresh license-activate license-deactivate billing-portal free-export unsplash jamendo agent credits voice --project-ref "$REF" +supabase functions deploy create-checkout license-refresh license-activate license-claim license-deactivate billing-portal free-export unsplash jamendo agent credits voice --project-ref "$REF" supabase functions deploy stripe-webhook --no-verify-jwt --project-ref "$REF" echo "→ Supabase: secrets" diff --git a/backend/supabase/functions/create-checkout/index.ts b/backend/supabase/functions/create-checkout/index.ts index f3bd093..2130781 100644 --- a/backend/supabase/functions/create-checkout/index.ts +++ b/backend/supabase/functions/create-checkout/index.ts @@ -1,6 +1,6 @@ import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; import Stripe from "https://esm.sh/stripe@18.5.0"; -import { cleanEmail, db } from "../_shared/activation.ts"; +import { cleanEmail, db, isDevice } from "../_shared/activation.ts"; import { PACKS, proOrStaff } from "../_shared/credits.ts"; const cors = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type" }; @@ -13,12 +13,18 @@ const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), // topup_NN a pack of agent credits (_shared/credits.ts PACKS), for a Pro subscriber's email: // the agent is a Pro feature, and computers spend the credits of the license they // are activated for. +// License plans also take, all optional: +// email prefills Checkout (Stripe asks for it otherwise) +// promo a promotion code typed in the app, applied to the session (an unknown or expired +// code is a 400 with the reason, so the app shows it before opening the browser) +// device + claim the computer and the SHA-256 of a secret it keeps: stripe-webhook records +// the license for them, and `license-claim` activates that computer (no email code) // Older apps still ask for "monthly" (the retired US$10 plan): refused, and they fall back to // opening the pricing page; existing subscriptions keep renewing through stripe-webhook. serve(async (req) => { if (req.method === "OPTIONS") return new Response(null, { headers: cors }); const stripe = new Stripe(Deno.env.get("STRIPE_SECRET_KEY") || "", { apiVersion: "2025-08-27.basil" }); - const { plan = "lifetime", email } = await req.json().catch(() => ({})); + const { plan = "lifetime", email, promo, device, claim } = await req.json().catch(() => ({})); const urls = { success_url: Deno.env.get("CHECKOUT_SUCCESS_URL") ?? "https://screenforge.dev/thanks", cancel_url: Deno.env.get("CHECKOUT_CANCEL_URL") ?? "https://screenforge.dev/buy", @@ -31,32 +37,51 @@ serve(async (req) => { return { ...urls, success_url: u.toString() }; }; + // the app's computer and claim, carried to stripe-webhook in the session's metadata + const claimMeta = isDevice(device) && isDevice(claim) ? { device, claim } : {}; + // a typed promotion code, or Stripe's own code box on the page + let discount: Partial> = { allow_promotion_codes: true }; + if (typeof promo === "string" && promo.trim()) { + const found = await stripe.promotionCodes.list({ code: promo.trim(), active: true, limit: 1 }); + const code = found.data[0]; + if (!code) return json({ error: `The code ${promo.trim()} is not valid or has expired.` }, 400); + discount = { discounts: [{ promotion_code: code.id }] }; + } + // Stripe refuses a code the session cannot take (first purchase only, minimum amount …) + const create = async (params: Stripe.Checkout.SessionCreateParams) => { + try { + return json({ url: (await stripe.checkout.sessions.create(params)).url }); + } catch (e) { + if (discount.discounts && e instanceof Stripe.errors.StripeInvalidRequestError) return json({ error: `That code cannot be used here: ${(e as Error).message}` }, 400); + throw e; + } + }; + const customer_email = cleanEmail(email) ?? undefined; + if (plan === "lifetime") { - const session = await stripe.checkout.sessions.create({ + return await create({ mode: "payment", line_items: [{ price: Deno.env.get("STRIPE_PRICE_LIFETIME"), quantity: 1 }], - customer_email: email, - allow_promotion_codes: true, + customer_email, + ...discount, ...thanks("creator"), - metadata: { plan: "lifetime" }, + metadata: { plan: "lifetime", ...claimMeta }, }); - return json({ url: session.url }); } if (plan === "pro") { const price = Deno.env.get("STRIPE_PRICE_PRO"); if (!price) return json({ error: "Pro is not on sale yet." }, 503); - const session = await stripe.checkout.sessions.create({ + return await create({ mode: "subscription", line_items: [{ price, quantity: 1 }], - customer_email: email, - allow_promotion_codes: true, + customer_email, + ...discount, ...thanks("pro"), // stripe-webhook issues a monthly key for plan "monthly" - metadata: { plan: "monthly", kind: "pro" }, + metadata: { plan: "monthly", kind: "pro", ...claimMeta }, subscription_data: { metadata: { kind: "pro" } }, }); - return json({ url: session.url }); } const pack = PACKS[plan]; diff --git a/backend/supabase/functions/license-claim/index.ts b/backend/supabase/functions/license-claim/index.ts new file mode 100644 index 0000000..acab5aa --- /dev/null +++ b/backend/supabase/functions/license-claim/index.ts @@ -0,0 +1,42 @@ +// Activate the computer that started a checkout, without an email code. The app sends +// { device, claim } (claim = the secret it kept; create-checkout only ever saw its SHA-256): +// - not paid yet (or unknown) → { status: "pending" }: the app asks again in a few seconds +// - paid, new address → { key, plan, expires, email }: the license is moved to this +// computer, as confirming an emailed code does +// - paid, address that already had a license → { status: "verify", email }: the app asks for +// the emailed code (paying is no proof of owning an address typed at checkout) +// A claim works once, from the computer that started the checkout, within a week. +import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; +import { cors, db, isDevice, json, keyFor, moveLicenseTo, newestLicense } from "../_shared/activation.ts"; + +const CLAIM_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +async function sha256(s: string): Promise { + const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); + return Array.from(new Uint8Array(d), (b) => b.toString(16).padStart(2, "0")).join(""); +} + +serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: cors }); + const body = await req.json().catch(() => ({})); + if (!isDevice(body.device) || !isDevice(body.claim)) return json({ error: "This ScreenForge build cannot identify the computer." }, 400); + try { + const client = db(); + const { data: row, error } = await client.from("checkout_claims").select("device_id, email, fresh, created_at, claimed_at").eq("claim_hash", await sha256(body.claim)).maybeSingle(); + if (error) throw new Error(error.message); + if (!row || row.device_id !== body.device) return json({ status: "pending" }); + if (row.claimed_at || Date.now() - new Date(row.created_at).getTime() > CLAIM_TTL_MS) return json({ error: "This checkout was already used to activate ScreenForge. Sign in with your email instead." }, 410); + if (!row.fresh) return json({ status: "verify", email: row.email }); + const lic = await newestLicense(client, row.email); + if (!lic) return json({ status: "pending" }); + // used up first: a second request racing this one finds it claimed + const used = await client.from("checkout_claims").update({ claimed_at: new Date().toISOString() }).eq("claim_hash", await sha256(body.claim)).is("claimed_at", null).select("claim_hash"); + if (used.error) throw new Error(used.error.message); + if (!used.data?.length) return json({ error: "This checkout was already used to activate ScreenForge." }, 410); + await moveLicenseTo(client, lic.email.toLowerCase(), body.device); + return json({ ...(await keyFor(lic, body.device)), email: lic.email }); + } catch (e) { + console.error("license-claim", e); + return json({ error: "The license server had a problem. Try again in a moment." }, 500); + } +}); diff --git a/backend/supabase/functions/stripe-webhook/index.ts b/backend/supabase/functions/stripe-webhook/index.ts index a36452f..a6f4ec4 100644 --- a/backend/supabase/functions/stripe-webhook/index.ts +++ b/backend/supabase/functions/stripe-webhook/index.ts @@ -7,7 +7,8 @@ import { MONTHLY_CREDITS } from "../_shared/credits.ts"; // Stripe events (endpoint set up by setup.sh / setup-credits.sh): // checkout.session.completed / .async_payment_succeeded // license checkout: sign and store the key (lifetime, or monthly for Pro) and email how -// to activate with the address; +// to activate with the address; when the app started the checkout (metadata device + +// claim), record the claim that `license-claim` activates that computer with; // top-up pack: add its credits (only once paid: delayed methods arrive later as // async_payment_succeeded). // invoice.paid Pro's agent credits for the paid period (the first invoice and @@ -52,7 +53,22 @@ serve(async (req) => { await rpc("credits_topup", { p_email: s.metadata.email ?? email, p_credits: Number(s.metadata.credits ?? 0), p_ref: s.id }); return; } + const { device, claim } = s.metadata ?? {}; + const claimed = /^[0-9a-f]{64}$/.test(device ?? "") && /^[0-9a-f]{64}$/.test(claim ?? ""); + // an address with a license before this purchase may not be the payer's: its claim asks + // for the emailed code (a redelivered event finds this purchase's own license: also not + // fresh, which only costs the code) + let fresh = false; + if (claimed) { + const { count, error } = await db.from("licenses").select("email", { count: "exact", head: true }).ilike("email", email.replace(/[\\%_]/g, (c: string) => `\\${c}`)); + if (error) throw new Error(error.message); + fresh = (count ?? 0) === 0; + } await issue(email, (s.metadata?.plan as "monthly" | "lifetime") ?? "monthly", (s.customer as string) ?? null, (s.subscription as string) ?? null, true); + if (claimed) { + const { error } = await db.from("checkout_claims").upsert({ claim_hash: claim, device_id: device, email, fresh }, { onConflict: "claim_hash", ignoreDuplicates: true }); + if (error) throw new Error(error.message); + } }; try { diff --git a/backend/supabase/migrations/20261109000000_checkout_claims.sql b/backend/supabase/migrations/20261109000000_checkout_claims.sql new file mode 100644 index 0000000..6cfe465 --- /dev/null +++ b/backend/supabase/migrations/20261109000000_checkout_claims.sql @@ -0,0 +1,16 @@ +-- Activation without typing an email. Upgrading in the app sends a one-time secret made on the +-- computer (only its SHA-256 travels: in the Checkout Session's metadata) with the computer's +-- device id. Once paid, stripe-webhook records the license here; the app polls +-- `license-claim` with the secret and is activated on that computer, no code needed. +-- `fresh` is false when the checkout email already had a license before this purchase: then +-- paying is no proof of owning the address (anyone can type someone else's email at checkout), +-- so the claim asks for the emailed code as before instead of moving that account's activation. +create table if not exists public.checkout_claims ( + claim_hash text primary key check (claim_hash ~ '^[0-9a-f]{64}$'), + device_id text not null check (device_id ~ '^[0-9a-f]{64}$'), + email text not null check (email = lower(email)), + fresh boolean not null, + created_at timestamptz not null default now(), + claimed_at timestamptz +); +alter table public.checkout_claims enable row level security; -- service role only diff --git a/crates/sf-license/src/lib.rs b/crates/sf-license/src/lib.rs index 4f2a4f7..f9fcf46 100644 --- a/crates/sf-license/src/lib.rs +++ b/crates/sf-license/src/lib.rs @@ -161,6 +161,85 @@ pub fn checkout_url(email: &str, plan: &str) -> Result { v.get("url").and_then(|u| u.as_str()).map(String::from).ok_or_else(|| "no checkout url".to_string()) } +/// Start a checkout for a license `plan` ("pro", "lifetime") that activates this computer by +/// itself once paid: a fresh secret is saved here (see [`pending_claim`]) and only its SHA-256 +/// goes to the backend; [`claim_checkout`] then fetches the key. `email` (optional) prefills +/// Checkout, `promo` (optional) is a promotion code; an invalid one is an `Err` with the reason. +pub fn start_checkout(plan: &str, email: Option<&str>, promo: Option<&str>) -> Result { + use rand::RngCore; + let device = device_id().ok_or_else(|| "ScreenForge cannot identify this computer.".to_string())?; + let mut secret = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut secret); + let secret = hex(&secret); + let mut body = serde_json::json!({ "plan": plan, "device": device, "claim": hex(&Sha256::digest(secret.as_bytes())) }); + if let Some(e) = email.map(str::trim).filter(|e| e.contains('@')) { + body["email"] = e.into(); + } + if let Some(p) = promo.map(str::trim).filter(|p| !p.is_empty()) { + body["promo"] = p.into(); + } + let v = call_function("create-checkout", body)?; + let url = v.get("url").and_then(|u| u.as_str()).map(String::from).ok_or_else(|| "no checkout url".to_string())?; + // kept on disk, so paying after a restart still activates + if let Some(p) = claim_file() { + let _ = std::fs::create_dir_all(p.parent().unwrap()); + let _ = std::fs::write(p, &secret); + } + Ok(url) +} + +fn claim_file() -> Option { + key_file().map(|p| p.with_file_name("checkout-claim")) +} + +/// A checkout started here that has not activated this computer yet. +pub fn pending_claim() -> bool { + claim_file().and_then(|p| std::fs::read_to_string(p).ok()).is_some_and(|s| s.trim().len() == 64) +} + +fn drop_claim() { + if let Some(p) = claim_file() { + let _ = std::fs::remove_file(p); + } +} + +/// Outcome of [`claim_checkout`]. +#[derive(Debug, Clone, PartialEq)] +pub enum Claim { + /// Paid: this computer is activated (key and email saved). + Done(Status), + /// Not paid yet: ask again in a moment. + Pending, + /// Paid with an address that had a license already: sign in with the emailed code. + Verify(String), +} + +/// Ask whether the checkout from [`start_checkout`] was paid, activating this computer when it +/// was. The saved secret is removed once it is used up (or refused). +pub fn claim_checkout() -> Result { + let secret = claim_file().and_then(|p| std::fs::read_to_string(p).ok()).map(|s| s.trim().to_string()).filter(|s| s.len() == 64).ok_or("no checkout")?; + let device = device_id().ok_or_else(|| "ScreenForge cannot identify this computer.".to_string())?; + let v = request("license-claim", serde_json::json!({ "device": device, "claim": secret })).map_err(|(code, msg)| { + if code == 410 { + drop_claim(); + } + msg + })?; + match v.get("status").and_then(|s| s.as_str()) { + Some("pending") => Ok(Claim::Pending), + Some("verify") => { + drop_claim(); + Ok(Claim::Verify(v.get("email").and_then(|e| e.as_str()).unwrap_or_default().to_string())) + } + _ => { + let email = v.get("email").and_then(|e| e.as_str()).unwrap_or_default().to_string(); + let status = store_key(&v, &email)?; + drop_claim(); + Ok(Claim::Done(status)) + } + } +} + fn email_file() -> Option { key_file().map(|p| p.with_file_name("email")) } From 8e1f0ce14c079d66cf0d899ac23c78e478f06612 Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 19:18:08 -0800 Subject: [PATCH 02/10] Upgrade card: check for the payment silently, stop once signed in --- apps/studio/src/agent_panel.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/apps/studio/src/agent_panel.rs b/apps/studio/src/agent_panel.rs index c5de71f..ee337f3 100644 --- a/apps/studio/src/agent_panel.rs +++ b/apps/studio/src/agent_panel.rs @@ -583,7 +583,7 @@ impl AgentPanel { crate::os::open(&url); let note = if sf_license::pending_claim() && self.credits.as_ref().is_some_and(|c| c["pro"].as_bool() != Some(true)) { self.watch_claim(30 * 60); - "Opened checkout in your browser. Pro turns on here by itself once you pay." + "Opened checkout in your browser." } else { "Opened checkout in your browser. Credits arrive here a moment after you pay." }; @@ -658,7 +658,10 @@ impl AgentPanel { } } let now = std::time::Instant::now(); - if self.claim_until.is_some_and(|t| now > t) { + // the watch is silent and ends by itself: on time, or once this computer has the agent + // another way (signed in, or the sign-in form is open) + let unlocked = self.credits.as_ref().is_some_and(|c| c["allowed"].as_bool() == Some(true)); + if self.claim_until.is_some_and(|t| now > t) || unlocked || self.sign_in.is_some() { self.claim_until = None; } if self.claim_until.is_some() && self.claim_rx.is_none() && self.next_claim.is_some_and(|t| now >= t) { @@ -852,7 +855,6 @@ impl AgentPanel { return; } let opening = self.checkout_rx.is_some(); - let waiting = self.claim_until.is_some(); let label = if opening { "Opening checkout…" } else { "Upgrade to Pro · $20/month" }; if ui.add_enabled_ui(!opening, |ui| widgets::accent_button(ui, label)).inner.clicked() { self.buy("pro"); @@ -868,14 +870,7 @@ impl AgentPanel { self.promo_open = true; } ui.add_space(10.0); - if waiting { - ui.horizontal(|ui| { - working_dots(ui); - ui.add(egui::Label::new(RichText::new("Waiting for your payment. Pro turns on here by itself.").size(12.5).color(pal.text_dim)).wrap()); - }); - } else { - widgets::hint(ui, "No key or code to type: once you pay, Pro turns on here by itself."); - } + widgets::hint(ui, "No key or code to type: once you pay, Pro turns on here by itself."); ui.add_space(6.0); if quiet_link(ui, "Already subscribed? Sign in").clicked() { self.open_sign_in(None); From 7ac22da504d565039632d816755a3d98b8cd6f6d Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 19:24:57 -0800 Subject: [PATCH 03/10] Export: no email to buy, turns on by itself once paid, coupon field Pro and Creator open checkout straight away and the dialog checks silently for the payment, as the agent panel does. The email and code form moves behind "Already bought? Sign in". When both watch the same checkout, the second to ask finds it claimed and takes the key already saved. --- apps/studio/src/export.rs | 233 +++++++++++++++++++++++++++-------- crates/sf-license/src/lib.rs | 12 +- 2 files changed, 188 insertions(+), 57 deletions(-) diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index e56c37a..61411ee 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -49,6 +49,18 @@ pub struct ExportDialog { /// A code was emailed for this address; the dialog asks for it. code_sent_to: Option, code: String, + /// "Already bought? Sign in": the email and code form is open. + signing_in: bool, + /// Coupon for the checkout, and whether its field is open. + promo: String, + promo_open: bool, + /// A checkout being opened: its URL, or why it failed (an unknown coupon). + checkout: Option>>, + /// A checkout started here, polled silently until then: once paid it activates this + /// computer by itself (see `sf_license::claim_checkout`). + claim_until: Option, + next_claim: Option, + claim: Option>>, progress: Option, result: Option>, started: Option, @@ -59,7 +71,7 @@ pub struct ExportDialog { /// that loads the license and talks to the backend. impl Default for ExportDialog { fn default() -> Self { - ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } + ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), signing_in: false, promo: String::new(), promo_open: false, checkout: None, claim_until: None, next_claim: None, claim: None, format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } } } @@ -82,7 +94,13 @@ impl ExportDialog { } else { None }; - ExportDialog { license, email, check_in, ..Default::default() } + // a checkout from an earlier session, paid after quitting: look for it a while + let mut d = ExportDialog { license, email, check_in, ..Default::default() }; + if !d.license.is_valid() && sf_license::pending_claim() { + d.watch_claim(10 * 60); + d.next_claim = Some(std::time::Instant::now()); + } + d } } @@ -97,8 +115,101 @@ impl ExportDialog { self.job.is_some() || self.result.is_some() } + fn watch_claim(&mut self, secs: u64) { + let now = std::time::Instant::now(); + self.claim_until = Some(now + std::time::Duration::from_secs(secs)); + self.next_claim = Some(now + std::time::Duration::from_secs(3)); + } + + /// Open checkout for `plan`; once paid this computer is activated by itself. + fn buy(&mut self, plan: &str) { + let (tx, rx) = channel(); + let (plan, promo) = (plan.to_string(), self.promo.trim().to_string()); + let email = sf_license::load_email(); + std::thread::spawn(move || { + let _ = tx.send(sf_license::start_checkout(&plan, email.as_deref(), Some(&promo))); + }); + self.checkout = Some(rx); + self.activation_msg = None; + } + + /// Checkout links and the silent check for a paid checkout. + fn poll_checkout(&mut self, ctx: &egui::Context) { + let now = std::time::Instant::now(); + if let Some(rx) = &self.checkout { + match rx.try_recv() { + Ok(Ok(url)) => { + crate::os::open(url); + self.checkout = None; + self.watch_claim(30 * 60); + } + Ok(Err(e)) => { + self.checkout = None; + self.activation_msg = Some((false, e)); + } + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.checkout = None, + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(200)), + } + } + if let Some(rx) = &self.claim { + let r = match rx.try_recv() { + Ok(r) => Some(r), + Err(std::sync::mpsc::TryRecvError::Empty) => None, + Err(std::sync::mpsc::TryRecvError::Disconnected) => Some(Err("no checkout".into())), + }; + if let Some(r) = r { + self.claim = None; + match r { + Ok(sf_license::Claim::Done(status)) => { + self.claim_until = None; + self.promo.clear(); + self.promo_open = false; + self.signing_in = false; + self.activation_msg = Some(if status.is_valid() { (true, "Payment received. Export is unlocked on this computer.".into()) } else { (false, format!("That license is not valid here: {status:?}")) }); + self.license = status; + } + Ok(sf_license::Claim::Pending) => self.next_claim = Some(now + std::time::Duration::from_secs(3)), + Ok(sf_license::Claim::Verify(email)) => { + // the address had a license before: confirm it with the emailed code + self.claim_until = None; + self.signing_in = true; + self.email = email.clone(); + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(sf_license::activate(&email, true)); + }); + self.activation = Some(rx); + } + Err(e) if e == "no checkout" => self.claim_until = None, + Err(e) if e.contains("already used") => { + self.claim_until = None; + self.activation_msg = Some((false, e)); + } + // offline or a server hiccup: keep trying, slower + Err(_) => self.next_claim = Some(now + std::time::Duration::from_secs(10)), + } + } + } + // the agent panel's sign-in or its own claim may have unlocked export meanwhile + if self.claim_until.is_some_and(|t| now > t) || self.license.is_valid() || self.signing_in { + self.claim_until = None; + } + if self.claim_until.is_some() { + if self.claim.is_none() && self.next_claim.is_some_and(|t| now >= t) { + self.next_claim = None; + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(sf_license::claim_checkout()); + }); + self.claim = Some(rx); + } + ctx.request_repaint_after(std::time::Duration::from_millis(500)); + } + } + /// Drain background messages (export progress, activation) and handle Escape. pub fn poll(&mut self, ctx: &egui::Context) { + self.poll_checkout(ctx); if let Some(rx) = &self.check_in { match rx.try_recv() { Ok(r) => { @@ -108,7 +219,8 @@ impl ExportDialog { self.license = sf_license::load_saved().map(|k| sf_license::verify(&k)).unwrap_or_else(|| sf_license::Status::Invalid("no key".into())); if matches!(r, Ok(Some(false))) && had_valid && !self.license.is_valid() { // the license form shows whenever there is no valid license - self.activation_msg = Some((false, "This license was moved to another computer. Press Activate to move it back here.".into())); + self.signing_in = true; + self.activation_msg = Some((false, "This license was moved to another computer. Sign in to move it back here.".into())); } } Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(500)), @@ -134,7 +246,7 @@ impl ExportDialog { sf_license::Status::Invalid(e) => (false, format!("The server sent a key this build cannot verify ({e}).")), }); } - Err(e) if e == "no license" => self.activation_msg = Some((false, "No license for that email yet. Buy one below, then activate again.".into())), + Err(e) if e == "no license" => self.activation_msg = Some((false, "No license for that email. Buy Pro or Creator above, or use the email you paid with.".into())), Err(e) => self.activation_msg = Some((false, e)), } self.activation = None; @@ -258,53 +370,79 @@ impl ExportDialog { widgets::title(ui, "License"); widgets::hint(ui, "Recording and editing are free; exporting needs Pro or Creator."); ui.add_space(4.0); - ui.label("Email used at checkout"); - ui.add_space(2.0); - widgets::text_field(ui, &mut self.email, "you@example.com"); - // a code belongs to the address it was sent to - let awaiting_code = self.code_sent_to.as_deref() == Some(self.email.trim()); - if awaiting_code { - ui.add_space(6.0); - ui.label("Code from the email"); + let opening = self.checkout.is_some(); + ui.horizontal_wrapped(|ui| { + // both ways to export: Pro with the agent, or Creator for export alone + if ui.add_enabled_ui(!opening, |ui| widgets::accent_button(ui, &format!("Pro · {}", sf_license::PRICE_PRO))).inner.on_hover_text("Unlimited export and the AI agent with 1,000 credits a month").clicked() { + self.buy("pro"); + } + if ui.add_enabled_ui(!opening, |ui| widgets::secondary_button(ui, &format!("Creator · {}", sf_license::PRICE_LIFETIME))).inner.on_hover_text("Unlimited export, without the AI agent").clicked() { + self.buy("lifetime"); + } + }); + ui.add_space(6.0); + if self.promo_open { + ui.label("Coupon code"); ui.add_space(2.0); - widgets::text_field(ui, &mut self.code, "123456"); + widgets::text_field(ui, &mut self.promo, "CODE"); + } else if ui.link(RichText::new("Have a coupon code?").color(theme::p().text_dim)).clicked() { + self.promo_open = true; } - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - let busy = self.activation.is_some(); - let start = |this: &mut Self, f: Box Result + Send>| { - let (tx, rx) = channel(); - std::thread::spawn(move || { - let _ = tx.send(f()); - }); - this.activation = Some(rx); - this.activation_msg = None; - }; + ui.add_space(4.0); + widgets::hint(ui, "No key or code to type: once you pay, export turns on here by itself. It works on one computer at a time."); + ui.add_space(6.0); + if !self.signing_in && self.code_sent_to.is_none() { + if ui.link(RichText::new("Already bought? Sign in").color(theme::p().text_dim)).clicked() { + self.signing_in = true; + } + } else { + ui.label("Email used at checkout"); + ui.add_space(2.0); + widgets::text_field(ui, &mut self.email, "you@example.com"); + // a code belongs to the address it was sent to + let awaiting_code = self.code_sent_to.as_deref() == Some(self.email.trim()); if awaiting_code { - if ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, if busy { "Checking…" } else { "Confirm" })).inner.clicked() { - let (email, code) = (self.email.clone(), self.code.clone()); - start(self, Box::new(move || sf_license::confirm_code(&email, &code).map(sf_license::Activation::Done))); - } - if ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, "Send again")).inner.clicked() { + ui.add_space(6.0); + ui.label("Code from the email"); + ui.add_space(2.0); + widgets::text_field(ui, &mut self.code, "123456"); + } + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + let busy = self.activation.is_some(); + let start = |this: &mut Self, f: Box Result + Send>| { + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + this.activation = Some(rx); + this.activation_msg = None; + }; + if awaiting_code { + if ui.add_enabled_ui(!busy, |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Confirm" })).inner.clicked() { + let (email, code) = (self.email.clone(), self.code.clone()); + start(self, Box::new(move || sf_license::confirm_code(&email, &code).map(sf_license::Activation::Done))); + } + if ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, "Send again")).inner.clicked() { + let email = self.email.clone(); + start(self, Box::new(move || sf_license::activate(&email, true))); + } + } else if ui.add_enabled_ui(!busy && self.email.contains('@'), |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Email me a code" })).inner.clicked() { let email = self.email.clone(); start(self, Box::new(move || sf_license::activate(&email, true))); } - } else if ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, if busy { "Activating…" } else { "Activate" })).inner.clicked() { - let email = self.email.clone(); - start(self, Box::new(move || sf_license::activate(&email, true))); - } - // both ways to export: Pro with the agent, or Creator for export alone - if widgets::accent_button(ui, &format!("Pro · {}", sf_license::PRICE_PRO)).on_hover_text("Unlimited export and the AI agent with 1,000 credits a month").clicked() { - open_checkout(&self.email, "pro"); - } - if widgets::secondary_button(ui, &format!("Creator · {}", sf_license::PRICE_LIFETIME)).on_hover_text("Unlimited export, without the AI agent").clicked() { - open_checkout(&self.email, "lifetime"); - } - }); + if widgets::secondary_button(ui, "Cancel").clicked() { + self.signing_in = false; + self.code_sent_to = None; + self.code.clear(); + self.activation_msg = None; + } + }); + } if let Some((ok, msg)) = &self.activation_msg { + ui.add_space(4.0); ui.colored_label(if *ok { theme::p().accent_hover } else { theme::p().danger }, msg); } - widgets::hint(ui, "After paying, press Activate: we email you a code to confirm this computer, then the key is stored on it. Nothing to copy. It works on one computer at a time; activating another computer moves it there."); ui.add_space(6.0); ui.separator(); } @@ -417,14 +555,3 @@ impl ExportDialog { self.open = false; } } - -/// Open Stripe checkout in the browser; falls back to the pricing page when the backend is not -/// configured or unreachable. -fn open_checkout(email: &str, plan: &str) { - let email = email.to_string(); - let plan = plan.to_string(); - std::thread::spawn(move || { - let url = sf_license::checkout_url(&email, &plan).unwrap_or_else(|_| sf_license::PURCHASE_URL.to_string()); - crate::os::open(url); - }); -} diff --git a/crates/sf-license/src/lib.rs b/crates/sf-license/src/lib.rs index f9fcf46..57daa9f 100644 --- a/crates/sf-license/src/lib.rs +++ b/crates/sf-license/src/lib.rs @@ -219,12 +219,16 @@ pub enum Claim { pub fn claim_checkout() -> Result { let secret = claim_file().and_then(|p| std::fs::read_to_string(p).ok()).map(|s| s.trim().to_string()).filter(|s| s.len() == 64).ok_or("no checkout")?; let device = device_id().ok_or_else(|| "ScreenForge cannot identify this computer.".to_string())?; - let v = request("license-claim", serde_json::json!({ "device": device, "claim": secret })).map_err(|(code, msg)| { - if code == 410 { + let v = match request("license-claim", serde_json::json!({ "device": device, "claim": secret })) { + Ok(v) => v, + Err((410, msg)) => { drop_claim(); + // claimed already, by the agent panel or Export watching the same checkout + let saved = load_saved().map(|k| verify(&k)).filter(Status::is_valid); + return saved.map(Claim::Done).ok_or(msg); } - msg - })?; + Err((_, msg)) => return Err(msg), + }; match v.get("status").and_then(|s| s.as_str()) { Some("pending") => Ok(Claim::Pending), Some("verify") => { From 07a53169af8354ae8fcdd50f3b573da19e3d1ece Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 19:34:23 -0800 Subject: [PATCH 04/10] Enter submits the sign-in and coupon fields Email: sends the code. Code: signs in. Coupon: opens Pro's checkout with it. In the agent panel and in Export. --- apps/studio/src/agent_panel.rs | 15 ++++++++++----- apps/studio/src/export.rs | 16 +++++++++++----- apps/studio/src/widgets.rs | 5 +++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/studio/src/agent_panel.rs b/apps/studio/src/agent_panel.rs index ee337f3..a6865ed 100644 --- a/apps/studio/src/agent_panel.rs +++ b/apps/studio/src/agent_panel.rs @@ -707,14 +707,16 @@ impl AgentPanel { let pal = theme::p(); let Some(s) = self.sign_in.as_mut() else { return }; let busy = s.rx.is_some(); - let mut step = false; + let mut step; let mut cancel = false; if !s.code_sent { ui.label(RichText::new(if s.previous.is_some() { "Switch to the account with this email" } else { "Sign in with the email you subscribed with" }).size(12.5).color(pal.text_dim)); - widgets::text_field(ui, &mut s.email, "you@example.com"); + let f = widgets::text_field(ui, &mut s.email, "you@example.com"); + step = widgets::submitted(ui, &f) && !busy && s.email.contains('@'); } else { ui.label(RichText::new(format!("Enter the 6-digit code we emailed to {}", s.email.trim())).size(12.5).color(pal.text_dim)); - widgets::text_field(ui, &mut s.code, "123456"); + let f = widgets::text_field(ui, &mut s.code, "123456"); + step = widgets::submitted(ui, &f) && !busy; } ui.add_space(6.0); ui.horizontal(|ui| { @@ -863,9 +865,12 @@ impl AgentPanel { if self.promo_open { ui.label(RichText::new("Coupon code").size(12.5).color(pal.text_dim)); ui.add_space(2.0); - widgets::text_field(ui, &mut self.promo, "CODE"); + let f = widgets::text_field(ui, &mut self.promo, "CODE"); + if widgets::submitted(ui, &f) && !opening && !self.promo.trim().is_empty() { + self.buy("pro"); + } ui.add_space(2.0); - widgets::hint(ui, "Applied when checkout opens."); + widgets::hint(ui, "Press Enter or Upgrade to open checkout with it."); } else if quiet_link(ui, "Have a coupon code?").clicked() { self.promo_open = true; } diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index 61411ee..e514885 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -384,7 +384,11 @@ impl ExportDialog { if self.promo_open { ui.label("Coupon code"); ui.add_space(2.0); - widgets::text_field(ui, &mut self.promo, "CODE"); + let f = widgets::text_field(ui, &mut self.promo, "CODE"); + // Enter: Pro's checkout with the code (Creator by its button) + if widgets::submitted(ui, &f) && !opening && !self.promo.trim().is_empty() { + self.buy("pro"); + } } else if ui.link(RichText::new("Have a coupon code?").color(theme::p().text_dim)).clicked() { self.promo_open = true; } @@ -398,14 +402,16 @@ impl ExportDialog { } else { ui.label("Email used at checkout"); ui.add_space(2.0); - widgets::text_field(ui, &mut self.email, "you@example.com"); + let f = widgets::text_field(ui, &mut self.email, "you@example.com"); + let mut enter = widgets::submitted(ui, &f); // a code belongs to the address it was sent to let awaiting_code = self.code_sent_to.as_deref() == Some(self.email.trim()); if awaiting_code { ui.add_space(6.0); ui.label("Code from the email"); ui.add_space(2.0); - widgets::text_field(ui, &mut self.code, "123456"); + let f = widgets::text_field(ui, &mut self.code, "123456"); + enter |= widgets::submitted(ui, &f); } ui.add_space(8.0); ui.horizontal_wrapped(|ui| { @@ -419,7 +425,7 @@ impl ExportDialog { this.activation_msg = None; }; if awaiting_code { - if ui.add_enabled_ui(!busy, |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Confirm" })).inner.clicked() { + if ui.add_enabled_ui(!busy, |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Confirm" })).inner.clicked() || (enter && !busy) { let (email, code) = (self.email.clone(), self.code.clone()); start(self, Box::new(move || sf_license::confirm_code(&email, &code).map(sf_license::Activation::Done))); } @@ -427,7 +433,7 @@ impl ExportDialog { let email = self.email.clone(); start(self, Box::new(move || sf_license::activate(&email, true))); } - } else if ui.add_enabled_ui(!busy && self.email.contains('@'), |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Email me a code" })).inner.clicked() { + } else if ui.add_enabled_ui(!busy && self.email.contains('@'), |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Email me a code" })).inner.clicked() || (enter && !busy && self.email.contains('@')) { let email = self.email.clone(); start(self, Box::new(move || sf_license::activate(&email, true))); } diff --git a/apps/studio/src/widgets.rs b/apps/studio/src/widgets.rs index 4e2670e..19d1f83 100644 --- a/apps/studio/src/widgets.rs +++ b/apps/studio/src/widgets.rs @@ -253,6 +253,11 @@ pub fn square_icon_button(ui: &mut Ui, icon: Icon, tooltip: &str) -> Response { /// Single-line text field: roomy (38 px) with 4 px corners like the buttons, padded text and a /// quiet border that turns accent while focused. +/// Enter was pressed in this single-line field (it loses focus on Enter): submit the form. +pub fn submitted(ui: &Ui, field: &Response) -> bool { + field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) +} + pub fn text_field(ui: &mut Ui, text: &mut String, hint: &str) -> Response { let pal = theme::p(); let id = ui.next_auto_id().with("text_field"); From 0d4d8fe05e1eac4f211dd956f32ffce9695315f5 Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 19:54:21 -0800 Subject: [PATCH 05/10] Two free exports per computer, counted on the server Without a license the export dialog asks export-allowance how many of the 2 free exports this computer has left and enables Export while some remain. A finished free export is counted (kept on disk until the server can be told); a cancelled or failed one is not. Offline there is no free export, so turning the network off does not unlock export; a license keeps working offline. A new endpoint, so builds up to 0.1.39 (which ask free-export) do not get their old three back. --- apps/studio/src/export.rs | 107 ++++++++++++++++-- backend/README.md | 2 +- backend/setup.sh | 2 +- .../functions/export-allowance/index.ts | 31 +++++ crates/sf-license/src/lib.rs | 48 ++++++++ 5 files changed, 181 insertions(+), 9 deletions(-) create mode 100644 backend/supabase/functions/export-allowance/index.ts diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index e514885..5228f88 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -61,6 +61,14 @@ pub struct ExportDialog { claim_until: Option, next_claim: Option, claim: Option>>, + /// Free exports left on this computer without a license: asked each time the dialog opens + /// (`Err`: the server could not be reached, so there is none). + free_left: Option>, + free_rx: Option>>, + /// The export running now is a free one: counted once it finishes. + free_job: bool, + /// Pro, Creator and sign-in shown while free exports remain ("Remove the limit"). + show_upgrade: bool, progress: Option, result: Option>, started: Option, @@ -71,7 +79,7 @@ pub struct ExportDialog { /// that loads the license and talks to the backend. impl Default for ExportDialog { fn default() -> Self { - ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), signing_in: false, promo: String::new(), promo_open: false, checkout: None, claim_until: None, next_claim: None, claim: None, format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } + ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), signing_in: false, promo: String::new(), promo_open: false, checkout: None, claim_until: None, next_claim: None, claim: None, free_left: None, free_rx: None, free_job: false, show_upgrade: false, format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } } } @@ -207,9 +215,43 @@ impl ExportDialog { } } + /// Free exports left: asked when the dialog opens without a license, forgotten when it + /// closes so the next open asks again. + fn poll_free_exports(&mut self, ctx: &egui::Context) { + if !self.open { + if self.free_rx.is_none() { + self.free_left = None; + } + } else if !self.license.is_valid() && self.free_left.is_none() && self.free_rx.is_none() { + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(sf_license::free_exports_left()); + }); + self.free_rx = Some(rx); + } + if let Some(rx) = &self.free_rx { + match rx.try_recv() { + Ok(r) => { + self.free_rx = None; + if self.open { + self.free_left = Some(r); + } + } + Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(200)), + Err(std::sync::mpsc::TryRecvError::Disconnected) => self.free_rx = None, + } + } + } + + /// Exporting is allowed: a license, or a free export left (known from the server). + fn can_export(&self) -> bool { + self.license.is_valid() || matches!(self.free_left, Some(Ok(n)) if n > 0) + } + /// Drain background messages (export progress, activation) and handle Escape. pub fn poll(&mut self, ctx: &egui::Context) { self.poll_checkout(ctx); + self.poll_free_exports(ctx); if let Some(rx) = &self.check_in { match rx.try_recv() { Ok(r) => { @@ -267,6 +309,12 @@ impl ExportDialog { match m { Msg::Progress(p) => self.progress = Some(p), Msg::Done(r) => { + // a finished free export is counted (kept on disk until online) + if std::mem::take(&mut self.free_job) && r.is_ok() { + std::thread::spawn(|| { + let _ = sf_license::record_free_export(); + }); + } // a cancelled export just returns to the dialog self.result = match r { Err(e) if e.contains("cancelled") => None, @@ -363,13 +411,52 @@ impl ExportDialog { /// Export settings, drawn in the export dialog over the editor. Escape, Cancel or a click /// outside dismisses it. + /// Without a license: the free exports line. Returns true when Pro, Creator and sign-in + /// should show below it (no free export left, offline, or "Remove the limit"). + fn free_upgrade_ui(&mut self, ui: &mut egui::Ui) -> bool { + let pal = theme::p(); + widgets::title(ui, "License"); + let free = sf_license::FREE_EXPORTS; + let show = match &self.free_left { + None => { + widgets::hint(ui, "Checking your free exports…"); + false + } + Some(Ok(n)) if *n > 0 => { + let line = if *n == free { format!("You have {free} free exports. After that, Pro or Creator keeps you exporting.") } else { format!("{n} of {free} free exports left on this computer.") }; + ui.add(egui::Label::new(RichText::new(line).size(13.0).color(pal.text)).wrap()); + ui.add_space(4.0); + if !self.show_upgrade && !self.signing_in && ui.link(RichText::new("Remove the limit").color(pal.text_dim)).clicked() { + self.show_upgrade = true; + } + self.show_upgrade || self.signing_in + } + Some(Ok(_)) => { + widgets::hint(ui, &format!("Your {free} free exports are used. Pro or Creator keeps you exporting.")); + true + } + Some(Err(_)) => { + ui.add(egui::Label::new(RichText::new("Free exports need an internet connection. Connect and try again, or use a license (it works offline).").size(13.0).color(pal.danger)).wrap()); + ui.add_space(4.0); + if ui.link(RichText::new("Try again").color(pal.text_dim)).clicked() { + self.free_left = None; + } + true + } + }; + if show { + ui.add_space(4.0); + } else { + ui.add_space(6.0); + ui.separator(); + } + show + } + pub fn settings_ui(&mut self, ed: &mut Editor, ui: &mut egui::Ui) { ui.label(RichText::new("Export").size(18.0).strong()); ui.add_space(4.0); - if !self.license.is_valid() { - widgets::title(ui, "License"); - widgets::hint(ui, "Recording and editing are free; exporting needs Pro or Creator."); - ui.add_space(4.0); + if !self.license.is_valid() && self.free_upgrade_ui(ui) { let opening = self.checkout.is_some(); ui.horizontal_wrapped(|ui| { // both ways to export: Pro with the agent, or Creator for export alone @@ -502,8 +589,7 @@ impl ExportDialog { ui.add_space(6.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let licensed = self.license.is_valid(); - let btn = ui.add_enabled_ui(licensed, |ui| widgets::accent_button(ui, "Export to file…")).inner; + let btn = ui.add_enabled_ui(self.can_export(), |ui| widgets::accent_button(ui, "Export to file…")).inner; if btn.clicked() { let ext = match (self.format, self.codec) { (Format::Gif, _) => "gif", @@ -554,6 +640,13 @@ impl ExportDialog { }; let _ = tx.send(Msg::Done(run().map_err(|e| format!("{e:#}")))); }); + self.free_job = !self.license.is_valid(); + if self.free_job { + // one used, as far as this dialog knows (the server counts it once it finishes) + if let Some(Ok(n)) = &mut self.free_left { + *n = n.saturating_sub(1); + } + } self.started = Some(std::time::Instant::now()); self.progress = None; self.result = None; diff --git a/backend/README.md b/backend/README.md index f3d5b57..91ad73d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -26,7 +26,7 @@ manual steps, for reference: 2. Stripe: create a product with a one-time US$49 price (`setup.sh` does this; `update-price.sh ` changes the price later, Stripe and Supabase in one go); set `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_LIFETIME`. 3. Resend: `RESEND_API_KEY`, `LICENSE_FROM_EMAIL`. -4. `supabase db push && supabase functions deploy create-checkout stripe-webhook license-refresh license-activate license-claim free-export unsplash jamendo agent voice` +4. `supabase db push && supabase functions deploy create-checkout stripe-webhook license-refresh license-activate license-claim export-allowance free-export unsplash jamendo agent voice` 5. Point `PURCHASE_URL` in `crates/sf-license` at your checkout page (or the `create-checkout` URL). ## Activation from the app diff --git a/backend/setup.sh b/backend/setup.sh index 8bbe309..86189fb 100755 --- a/backend/setup.sh +++ b/backend/setup.sh @@ -65,7 +65,7 @@ fi supabase db push echo "→ Supabase: functions" -supabase functions deploy create-checkout license-refresh license-activate license-claim license-deactivate billing-portal free-export unsplash jamendo agent credits voice --project-ref "$REF" +supabase functions deploy create-checkout license-refresh license-activate license-claim license-deactivate billing-portal free-export export-allowance unsplash jamendo agent credits voice --project-ref "$REF" supabase functions deploy stripe-webhook --no-verify-jwt --project-ref "$REF" echo "→ Supabase: secrets" diff --git a/backend/supabase/functions/export-allowance/index.ts b/backend/supabase/functions/export-allowance/index.ts new file mode 100644 index 0000000..24f23c1 --- /dev/null +++ b/backend/supabase/functions/export-allowance/index.ts @@ -0,0 +1,31 @@ +// Free exports without a license: FREE_EXPORTS per computer, counted here by device (SHA-256 +// of the hardware UUID) so a reinstall cannot reset them. The app asks { device } before +// enabling Export → { used, free }, and sends { device, record: true } after a free export +// finished (exports recorded while offline are sent on the next check). Without an answer +// from here the app offers no free export, so going offline does not unlock export. +// (`free-export` stays for builds up to 0.1.39 and always answers "all used": this is a +// separate endpoint so those builds do not get their old three back.) +import { serve } from "https://deno.land/std@0.190.0/http/server.ts"; +import { cors, db, isDevice, json } from "../_shared/activation.ts"; + +const FREE_EXPORTS = 2; + +serve(async (req) => { + if (req.method === "OPTIONS") return new Response(null, { headers: cors }); + const body = await req.json().catch(() => ({})); + if (!isDevice(body.device)) return json({ error: "This ScreenForge build cannot identify the computer." }, 400); + try { + const client = db(); + if (body.record === true) { + const { data, error } = await client.rpc("record_free_export", { p_device: body.device }); + if (error) throw new Error(error.message); + return json({ used: Number(data ?? 0), free: FREE_EXPORTS }); + } + const { data, error } = await client.from("free_exports").select("used").eq("device_id", body.device).maybeSingle(); + if (error) throw new Error(error.message); + return json({ used: data?.used ?? 0, free: FREE_EXPORTS }); + } catch (e) { + console.error("export-allowance", e); + return json({ error: "The license server had a problem. Try again in a moment." }, 500); + } +}); diff --git a/crates/sf-license/src/lib.rs b/crates/sf-license/src/lib.rs index 57daa9f..339d45f 100644 --- a/crates/sf-license/src/lib.rs +++ b/crates/sf-license/src/lib.rs @@ -244,6 +244,54 @@ pub fn claim_checkout() -> Result { } } +/// Free exports per computer without a license (the backend's `export-allowance` has the +/// final say). +pub const FREE_EXPORTS: u32 = 2; + +/// Free exports finished while the server could not be told, sent on the next check. +fn unsent_exports_file() -> Option { + key_file().map(|p| p.with_file_name("free-exports-unsent")) +} + +fn unsent_exports() -> u32 { + unsent_exports_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|s| s.trim().parse().ok()).unwrap_or(0) +} + +fn set_unsent_exports(n: u32) { + if let Some(p) = unsent_exports_file() { + let _ = std::fs::create_dir_all(p.parent().unwrap()); + let _ = if n == 0 { std::fs::remove_file(p) } else { std::fs::write(p, n.to_string()) }; + } +} + +/// Free exports this computer has left. Needs the server: offline it is an `Err` and there +/// is no free export (licensed export works offline). Free exports recorded while offline are +/// sent first. +pub fn free_exports_left() -> Result { + let device = device_id().ok_or_else(|| "ScreenForge cannot identify this computer.".to_string())?; + let mut unsent = unsent_exports(); + let mut v = None; + while unsent > 0 { + v = Some(call_backend("export-allowance", serde_json::json!({ "device": device, "record": true }))?); + unsent -= 1; + set_unsent_exports(unsent); + } + let v = match v { + Some(v) => v, + None => call_backend("export-allowance", serde_json::json!({ "device": device }))?, + }; + let used = v.get("used").and_then(|u| u.as_u64()).unwrap_or(u64::from(FREE_EXPORTS)) as u32; + let free = v.get("free").and_then(|f| f.as_u64()).map(|f| f as u32).unwrap_or(FREE_EXPORTS); + Ok(free.saturating_sub(used)) +} + +/// A free export finished: count it (kept here until the server can be told). Returns what +/// is left, as far as this computer knows. +pub fn record_free_export() -> Result { + set_unsent_exports(unsent_exports() + 1); + free_exports_left() +} + fn email_file() -> Option { key_file().map(|p| p.with_file_name("email")) } From b5b3592c26778903219278dd717d4b4e8c4f0b27 Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 19:58:59 -0800 Subject: [PATCH 06/10] Export: Remove the limit is a button --- apps/studio/src/export.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index 5228f88..71785d6 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -426,7 +426,8 @@ impl ExportDialog { let line = if *n == free { format!("You have {free} free exports. After that, Pro or Creator keeps you exporting.") } else { format!("{n} of {free} free exports left on this computer.") }; ui.add(egui::Label::new(RichText::new(line).size(13.0).color(pal.text)).wrap()); ui.add_space(4.0); - if !self.show_upgrade && !self.signing_in && ui.link(RichText::new("Remove the limit").color(pal.text_dim)).clicked() { + // secondary: Export stays the dialog's one accent button + if !self.show_upgrade && !self.signing_in && widgets::secondary_button(ui, "Remove the limit").clicked() { self.show_upgrade = true; } self.show_upgrade || self.signing_in From 6ee02fe23f6725307e145b1f454a4bf0e587e229 Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 19:59:42 -0800 Subject: [PATCH 07/10] Export: the Pro and Creator buttons show beside the free exports No Remove the limit step: while free exports remain the dialog shows the same upgrade buttons, coupon and sign-in as when they are used up. --- apps/studio/src/export.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index 71785d6..488ffce 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -67,8 +67,6 @@ pub struct ExportDialog { free_rx: Option>>, /// The export running now is a free one: counted once it finishes. free_job: bool, - /// Pro, Creator and sign-in shown while free exports remain ("Remove the limit"). - show_upgrade: bool, progress: Option, result: Option>, started: Option, @@ -79,7 +77,7 @@ pub struct ExportDialog { /// that loads the license and talks to the backend. impl Default for ExportDialog { fn default() -> Self { - ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), signing_in: false, promo: String::new(), promo_open: false, checkout: None, claim_until: None, next_claim: None, claim: None, free_left: None, free_rx: None, free_job: false, show_upgrade: false, format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } + ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), signing_in: false, promo: String::new(), promo_open: false, checkout: None, claim_until: None, next_claim: None, claim: None, free_left: None, free_rx: None, free_job: false, format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } } } @@ -412,7 +410,7 @@ impl ExportDialog { /// Export settings, drawn in the export dialog over the editor. Escape, Cancel or a click /// outside dismisses it. /// Without a license: the free exports line. Returns true when Pro, Creator and sign-in - /// should show below it (no free export left, offline, or "Remove the limit"). + /// should show below it (always, once the free exports are known). fn free_upgrade_ui(&mut self, ui: &mut egui::Ui) -> bool { let pal = theme::p(); widgets::title(ui, "License"); @@ -423,14 +421,10 @@ impl ExportDialog { false } Some(Ok(n)) if *n > 0 => { - let line = if *n == free { format!("You have {free} free exports. After that, Pro or Creator keeps you exporting.") } else { format!("{n} of {free} free exports left on this computer.") }; + // the upgrade buttons show right under it, as when none are left + let line = if *n == free { format!("You have {free} free exports. Upgrade for unlimited export.") } else { format!("{n} of {free} free exports left on this computer. Upgrade for unlimited export.") }; ui.add(egui::Label::new(RichText::new(line).size(13.0).color(pal.text)).wrap()); - ui.add_space(4.0); - // secondary: Export stays the dialog's one accent button - if !self.show_upgrade && !self.signing_in && widgets::secondary_button(ui, "Remove the limit").clicked() { - self.show_upgrade = true; - } - self.show_upgrade || self.signing_in + true } Some(Ok(_)) => { widgets::hint(ui, &format!("Your {free} free exports are used. Pro or Creator keeps you exporting.")); From 132943fde2f55d6401db47f07c6b92c180ec0727 Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 20:02:07 -0800 Subject: [PATCH 08/10] Export: settings scroll, Export and Cancel always in view The license section (upgrade buttons, coupon, sign-in) made the dialog taller than a short window, pushing Export to file off screen. The settings now scroll within the window's height and the Export row stays below them. --- apps/studio/src/export.rs | 240 +++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 118 deletions(-) diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index 488ffce..cc1921f 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -451,133 +451,137 @@ impl ExportDialog { pub fn settings_ui(&mut self, ed: &mut Editor, ui: &mut egui::Ui) { ui.label(RichText::new("Export").size(18.0).strong()); ui.add_space(4.0); - if !self.license.is_valid() && self.free_upgrade_ui(ui) { - let opening = self.checkout.is_some(); - ui.horizontal_wrapped(|ui| { - // both ways to export: Pro with the agent, or Creator for export alone - if ui.add_enabled_ui(!opening, |ui| widgets::accent_button(ui, &format!("Pro · {}", sf_license::PRICE_PRO))).inner.on_hover_text("Unlimited export and the AI agent with 1,000 credits a month").clicked() { - self.buy("pro"); - } - if ui.add_enabled_ui(!opening, |ui| widgets::secondary_button(ui, &format!("Creator · {}", sf_license::PRICE_LIFETIME))).inner.on_hover_text("Unlimited export, without the AI agent").clicked() { - self.buy("lifetime"); - } - }); - ui.add_space(6.0); - if self.promo_open { - ui.label("Coupon code"); - ui.add_space(2.0); - let f = widgets::text_field(ui, &mut self.promo, "CODE"); - // Enter: Pro's checkout with the code (Creator by its button) - if widgets::submitted(ui, &f) && !opening && !self.promo.trim().is_empty() { - self.buy("pro"); - } - } else if ui.link(RichText::new("Have a coupon code?").color(theme::p().text_dim)).clicked() { - self.promo_open = true; - } - ui.add_space(4.0); - widgets::hint(ui, "No key or code to type: once you pay, export turns on here by itself. It works on one computer at a time."); - ui.add_space(6.0); - if !self.signing_in && self.code_sent_to.is_none() { - if ui.link(RichText::new("Already bought? Sign in").color(theme::p().text_dim)).clicked() { - self.signing_in = true; - } - } else { - ui.label("Email used at checkout"); - ui.add_space(2.0); - let f = widgets::text_field(ui, &mut self.email, "you@example.com"); - let mut enter = widgets::submitted(ui, &f); - // a code belongs to the address it was sent to - let awaiting_code = self.code_sent_to.as_deref() == Some(self.email.trim()); - if awaiting_code { - ui.add_space(6.0); - ui.label("Code from the email"); + // the settings scroll on a short window; Export and Cancel stay in view below + let max_h = (ui.ctx().content_rect().height() - 200.0).max(160.0); + egui::ScrollArea::vertical().max_height(max_h).auto_shrink([false, true]).show(ui, |ui| { + if !self.license.is_valid() && self.free_upgrade_ui(ui) { + let opening = self.checkout.is_some(); + ui.horizontal_wrapped(|ui| { + // both ways to export: Pro with the agent, or Creator for export alone + if ui.add_enabled_ui(!opening, |ui| widgets::accent_button(ui, &format!("Pro · {}", sf_license::PRICE_PRO))).inner.on_hover_text("Unlimited export and the AI agent with 1,000 credits a month").clicked() { + self.buy("pro"); + } + if ui.add_enabled_ui(!opening, |ui| widgets::secondary_button(ui, &format!("Creator · {}", sf_license::PRICE_LIFETIME))).inner.on_hover_text("Unlimited export, without the AI agent").clicked() { + self.buy("lifetime"); + } + }); + ui.add_space(6.0); + if self.promo_open { + ui.label("Coupon code"); ui.add_space(2.0); - let f = widgets::text_field(ui, &mut self.code, "123456"); - enter |= widgets::submitted(ui, &f); + let f = widgets::text_field(ui, &mut self.promo, "CODE"); + // Enter: Pro's checkout with the code (Creator by its button) + if widgets::submitted(ui, &f) && !opening && !self.promo.trim().is_empty() { + self.buy("pro"); + } + } else if ui.link(RichText::new("Have a coupon code?").color(theme::p().text_dim)).clicked() { + self.promo_open = true; } - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - let busy = self.activation.is_some(); - let start = |this: &mut Self, f: Box Result + Send>| { - let (tx, rx) = channel(); - std::thread::spawn(move || { - let _ = tx.send(f()); - }); - this.activation = Some(rx); - this.activation_msg = None; - }; + ui.add_space(4.0); + widgets::hint(ui, "No key or code to type: once you pay, export turns on here by itself. It works on one computer at a time."); + ui.add_space(6.0); + if !self.signing_in && self.code_sent_to.is_none() { + if ui.link(RichText::new("Already bought? Sign in").color(theme::p().text_dim)).clicked() { + self.signing_in = true; + } + } else { + ui.label("Email used at checkout"); + ui.add_space(2.0); + let f = widgets::text_field(ui, &mut self.email, "you@example.com"); + let mut enter = widgets::submitted(ui, &f); + // a code belongs to the address it was sent to + let awaiting_code = self.code_sent_to.as_deref() == Some(self.email.trim()); if awaiting_code { - if ui.add_enabled_ui(!busy, |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Confirm" })).inner.clicked() || (enter && !busy) { - let (email, code) = (self.email.clone(), self.code.clone()); - start(self, Box::new(move || sf_license::confirm_code(&email, &code).map(sf_license::Activation::Done))); - } - if ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, "Send again")).inner.clicked() { + ui.add_space(6.0); + ui.label("Code from the email"); + ui.add_space(2.0); + let f = widgets::text_field(ui, &mut self.code, "123456"); + enter |= widgets::submitted(ui, &f); + } + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + let busy = self.activation.is_some(); + let start = |this: &mut Self, f: Box Result + Send>| { + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(f()); + }); + this.activation = Some(rx); + this.activation_msg = None; + }; + if awaiting_code { + if ui.add_enabled_ui(!busy, |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Confirm" })).inner.clicked() || (enter && !busy) { + let (email, code) = (self.email.clone(), self.code.clone()); + start(self, Box::new(move || sf_license::confirm_code(&email, &code).map(sf_license::Activation::Done))); + } + if ui.add_enabled_ui(!busy, |ui| widgets::secondary_button(ui, "Send again")).inner.clicked() { + let email = self.email.clone(); + start(self, Box::new(move || sf_license::activate(&email, true))); + } + } else if ui.add_enabled_ui(!busy && self.email.contains('@'), |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Email me a code" })).inner.clicked() || (enter && !busy && self.email.contains('@')) { let email = self.email.clone(); start(self, Box::new(move || sf_license::activate(&email, true))); } - } else if ui.add_enabled_ui(!busy && self.email.contains('@'), |ui| widgets::accent_button(ui, if busy { "Checking…" } else { "Email me a code" })).inner.clicked() || (enter && !busy && self.email.contains('@')) { - let email = self.email.clone(); - start(self, Box::new(move || sf_license::activate(&email, true))); - } - if widgets::secondary_button(ui, "Cancel").clicked() { - self.signing_in = false; - self.code_sent_to = None; - self.code.clear(); - self.activation_msg = None; - } - }); + if widgets::secondary_button(ui, "Cancel").clicked() { + self.signing_in = false; + self.code_sent_to = None; + self.code.clear(); + self.activation_msg = None; + } + }); + } + if let Some((ok, msg)) = &self.activation_msg { + ui.add_space(4.0); + ui.colored_label(if *ok { theme::p().accent_hover } else { theme::p().danger }, msg); + } + ui.add_space(6.0); + ui.separator(); } - if let Some((ok, msg)) = &self.activation_msg { - ui.add_space(4.0); - ui.colored_label(if *ok { theme::p().accent_hover } else { theme::p().danger }, msg); + widgets::title(ui, "Format"); + if let Some(i) = widgets::segmented(ui, if self.format == Format::Mp4 { 0 } else { 1 }, &["MP4", "GIF"]) { + self.format = if i == 0 { Format::Mp4 } else { Format::Gif }; + if self.format == Format::Gif { + self.fps = 15.0; + self.height = 480; + self.quality = QualityPreset::Studio; + } else { + self.fps = 60.0; + self.height = 1080; + self.quality = QualityPreset::SocialMedia; + } } - ui.add_space(6.0); - ui.separator(); - } - widgets::title(ui, "Format"); - if let Some(i) = widgets::segmented(ui, if self.format == Format::Mp4 { 0 } else { 1 }, &["MP4", "GIF"]) { - self.format = if i == 0 { Format::Mp4 } else { Format::Gif }; - if self.format == Format::Gif { - self.fps = 15.0; - self.height = 480; - self.quality = QualityPreset::Studio; - } else { - self.fps = 60.0; - self.height = 1080; - self.quality = QualityPreset::SocialMedia; + widgets::title(ui, "Frame rate"); + let fps_list: &[f64] = if self.format == Format::Mp4 { &MP4_FPS } else { &GIF_FPS }; + let labels: Vec = fps_list.iter().map(|f| format!("{f}")).collect(); + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + if let Some(i) = widgets::segmented(ui, fps_list.iter().position(|f| *f == self.fps).unwrap_or(0), &label_refs) { + self.fps = fps_list[i]; } - } - widgets::title(ui, "Frame rate"); - let fps_list: &[f64] = if self.format == Format::Mp4 { &MP4_FPS } else { &GIF_FPS }; - let labels: Vec = fps_list.iter().map(|f| format!("{f}")).collect(); - let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); - if let Some(i) = widgets::segmented(ui, fps_list.iter().position(|f| *f == self.fps).unwrap_or(0), &label_refs) { - self.fps = fps_list[i]; - } - widgets::title(ui, "Resolution"); - let res: &[(u32, &str)] = if self.format == Format::Mp4 { &MP4_RES } else { &GIF_RES }; - let rl: Vec<&str> = res.iter().map(|r| r.1).collect(); - if let Some(i) = widgets::segmented(ui, res.iter().position(|r| r.0 == self.height).unwrap_or(1), &rl) { - self.height = res[i].0; - } - let size = ed.session.output_size(self.height); - widgets::hint(ui, &format!("{} px × {} px", size.width, size.height)); - widgets::title(ui, "Compression"); - let qs: &[QualityPreset] = if self.format == Format::Mp4 { &QualityPreset::ALL } else { &QualityPreset::ALL[..2] }; - let ql: Vec<&str> = qs.iter().map(|q| q.label()).collect(); - if let Some(i) = widgets::segmented(ui, qs.iter().position(|q| *q == self.quality).unwrap_or(1), &ql) { - self.quality = qs[i]; - } - if self.format == Format::Mp4 { - egui::CollapsingHeader::new("Advanced").show(ui, |ui| { - widgets::title(ui, "Codec"); - let codecs = [VideoCodec::H264, VideoCodec::Hevc, VideoCodec::ProRes4444, VideoCodec::Vp9]; - if let Some(i) = widgets::segmented(ui, codecs.iter().position(|c| *c == self.codec).unwrap_or(0), &["H.264", "HEVC", "ProRes", "WebM"]) { - self.codec = codecs[i]; - } - ui.checkbox(&mut self.audio, "Include audio"); - }); - } + widgets::title(ui, "Resolution"); + let res: &[(u32, &str)] = if self.format == Format::Mp4 { &MP4_RES } else { &GIF_RES }; + let rl: Vec<&str> = res.iter().map(|r| r.1).collect(); + if let Some(i) = widgets::segmented(ui, res.iter().position(|r| r.0 == self.height).unwrap_or(1), &rl) { + self.height = res[i].0; + } + let size = ed.session.output_size(self.height); + widgets::hint(ui, &format!("{} px × {} px", size.width, size.height)); + widgets::title(ui, "Compression"); + let qs: &[QualityPreset] = if self.format == Format::Mp4 { &QualityPreset::ALL } else { &QualityPreset::ALL[..2] }; + let ql: Vec<&str> = qs.iter().map(|q| q.label()).collect(); + if let Some(i) = widgets::segmented(ui, qs.iter().position(|q| *q == self.quality).unwrap_or(1), &ql) { + self.quality = qs[i]; + } + if self.format == Format::Mp4 { + egui::CollapsingHeader::new("Advanced").show(ui, |ui| { + widgets::title(ui, "Codec"); + let codecs = [VideoCodec::H264, VideoCodec::Hevc, VideoCodec::ProRes4444, VideoCodec::Vp9]; + if let Some(i) = widgets::segmented(ui, codecs.iter().position(|c| *c == self.codec).unwrap_or(0), &["H.264", "HEVC", "ProRes", "WebM"]) { + self.codec = codecs[i]; + } + ui.checkbox(&mut self.audio, "Include audio"); + }); + } + }); ui.add_space(6.0); let secs = ed.duration_ms() / 1000.0; widgets::hint(ui, &format!("{:.0}s of video at {} fps", secs, self.fps)); From 80e5f7db160ca131eba894d474d63a4586019030 Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 20:03:14 -0800 Subject: [PATCH 09/10] Export: no flicker on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The free exports were forgotten on close and asked again on open, so the dialog appeared short (Checking…) and grew a moment later. They are now asked when the editor opens, refreshed on each open while the last answer stays on screen, and the checking state has the same layout as the answer. --- apps/studio/src/export.rs | 41 ++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index cc1921f..0e8cd38 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -65,6 +65,8 @@ pub struct ExportDialog { /// (`Err`: the server could not be reached, so there is none). free_left: Option>, free_rx: Option>>, + /// The dialog was open last frame (opening it refreshes the free exports). + was_open: bool, /// The export running now is a free one: counted once it finishes. free_job: bool, progress: Option, @@ -77,7 +79,7 @@ pub struct ExportDialog { /// that loads the license and talks to the backend. impl Default for ExportDialog { fn default() -> Self { - ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), signing_in: false, promo: String::new(), promo_open: false, checkout: None, claim_until: None, next_claim: None, claim: None, free_left: None, free_rx: None, free_job: false, format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } + ExportDialog { open: false, license: sf_license::Status::Invalid("no key".into()), email: String::new(), activation: None, check_in: None, activation_msg: None, code_sent_to: None, code: String::new(), signing_in: false, promo: String::new(), promo_open: false, checkout: None, claim_until: None, next_claim: None, claim: None, free_left: None, free_rx: None, was_open: false, free_job: false, format: Format::Mp4, fps: 60.0, height: 1080, quality: QualityPreset::SocialMedia, codec: VideoCodec::H264, audio: true, job: None, progress: None, result: None, started: None } } } @@ -102,6 +104,10 @@ impl ExportDialog { }; // a checkout from an earlier session, paid after quitting: look for it a while let mut d = ExportDialog { license, email, check_in, ..Default::default() }; + // known before the dialog first opens + if !d.license.is_valid() { + d.fetch_free_exports(); + } if !d.license.is_valid() && sf_license::pending_claim() { d.watch_claim(10 * 60); d.next_claim = Some(std::time::Instant::now()); @@ -216,24 +222,18 @@ impl ExportDialog { /// Free exports left: asked when the dialog opens without a license, forgotten when it /// closes so the next open asks again. fn poll_free_exports(&mut self, ctx: &egui::Context) { - if !self.open { - if self.free_rx.is_none() { - self.free_left = None; - } - } else if !self.license.is_valid() && self.free_left.is_none() && self.free_rx.is_none() { - let (tx, rx) = channel(); - std::thread::spawn(move || { - let _ = tx.send(sf_license::free_exports_left()); - }); - self.free_rx = Some(rx); + // asked when the editor opens and again on each open, keeping the last answer + // meanwhile so the dialog does not change size as it appears + let opened = self.open && !self.was_open; + self.was_open = self.open; + if !self.license.is_valid() && self.free_rx.is_none() && (opened || (self.open && self.free_left.is_none())) { + self.fetch_free_exports(); } if let Some(rx) = &self.free_rx { match rx.try_recv() { Ok(r) => { self.free_rx = None; - if self.open { - self.free_left = Some(r); - } + self.free_left = Some(r); } Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(200)), Err(std::sync::mpsc::TryRecvError::Disconnected) => self.free_rx = None, @@ -241,6 +241,14 @@ impl ExportDialog { } } + fn fetch_free_exports(&mut self) { + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(sf_license::free_exports_left()); + }); + self.free_rx = Some(rx); + } + /// Exporting is allowed: a license, or a free export left (known from the server). fn can_export(&self) -> bool { self.license.is_valid() || matches!(self.free_left, Some(Ok(n)) if n > 0) @@ -416,9 +424,10 @@ impl ExportDialog { widgets::title(ui, "License"); let free = sf_license::FREE_EXPORTS; let show = match &self.free_left { + // same layout as the answer (the upgrade buttons below), so nothing jumps None => { - widgets::hint(ui, "Checking your free exports…"); - false + ui.add(egui::Label::new(RichText::new("Checking your free exports…").size(13.0).color(pal.text_dim)).wrap()); + true } Some(Ok(n)) if *n > 0 => { // the upgrade buttons show right under it, as when none are left From 6001874ee84ac00b361ac465d1efd01d1b68c548 Mon Sep 17 00:00:00 2001 From: Mehfuz Hossain Date: Mon, 9 Nov 2026 20:06:32 -0800 Subject: [PATCH 10/10] Free exports: the count after an export comes from the server A finished free export is recorded and the server's answer replaces the dialog's count; a cancelled or failed one asks again, giving back the export taken off when it started. Checks and records run one at a time, so an unsent export reopening the dialog sends is never counted twice. --- apps/studio/src/export.rs | 11 ++++++++--- crates/sf-license/src/lib.rs | 11 ++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/studio/src/export.rs b/apps/studio/src/export.rs index 0e8cd38..fe3fb90 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -316,10 +316,15 @@ impl ExportDialog { Msg::Progress(p) => self.progress = Some(p), Msg::Done(r) => { // a finished free export is counted (kept on disk until online) - if std::mem::take(&mut self.free_job) && r.is_ok() { - std::thread::spawn(|| { - let _ = sf_license::record_free_export(); + // the server's answer replaces the count (a cancelled or failed one + // gives back the export taken off when it started) + if std::mem::take(&mut self.free_job) { + let ok = r.is_ok(); + let (tx, rx) = channel(); + std::thread::spawn(move || { + let _ = tx.send(if ok { sf_license::record_free_export() } else { sf_license::free_exports_left() }); }); + self.free_rx = Some(rx); } // a cancelled export just returns to the dialog self.result = match r { diff --git a/crates/sf-license/src/lib.rs b/crates/sf-license/src/lib.rs index 339d45f..658f15a 100644 --- a/crates/sf-license/src/lib.rs +++ b/crates/sf-license/src/lib.rs @@ -268,6 +268,14 @@ fn set_unsent_exports(n: u32) { /// is no free export (licensed export works offline). Free exports recorded while offline are /// sent first. pub fn free_exports_left() -> Result { + let _one = FREE_EXPORTS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + free_exports_left_locked() +} + +/// One check or record at a time: two sending the same unsent export would count it twice. +static FREE_EXPORTS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn free_exports_left_locked() -> Result { let device = device_id().ok_or_else(|| "ScreenForge cannot identify this computer.".to_string())?; let mut unsent = unsent_exports(); let mut v = None; @@ -288,8 +296,9 @@ pub fn free_exports_left() -> Result { /// A free export finished: count it (kept here until the server can be told). Returns what /// is left, as far as this computer knows. pub fn record_free_export() -> Result { + let _one = FREE_EXPORTS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); set_unsent_exports(unsent_exports() + 1); - free_exports_left() + free_exports_left_locked() } fn email_file() -> Option {