diff --git a/apps/studio/src/agent_panel.rs b/apps/studio/src/agent_panel.rs index ba886f1..a6865ed 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." + } 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,80 @@ 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(); + // 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) { + 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; @@ -606,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| { @@ -694,7 +797,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 +850,36 @@ 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 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() { + if self.promo_open { + ui.label(RichText::new("Coupon code").size(12.5).color(pal.text_dim)); + ui.add_space(2.0); + let f = widgets::text_field(ui, &mut self.promo, "CODE"); + if widgets::submitted(ui, &f) && !opening && !self.promo.trim().is_empty() { 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 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(2.0); + 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; } + ui.add_space(10.0); + 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/apps/studio/src/export.rs b/apps/studio/src/export.rs index e56c37a..fe3fb90 100644 --- a/apps/studio/src/export.rs +++ b/apps/studio/src/export.rs @@ -49,6 +49,26 @@ 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>>, + /// 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 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, result: Option>, started: Option, @@ -59,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(), 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 } } } @@ -82,7 +102,17 @@ 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() }; + // 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()); + } + d } } @@ -97,8 +127,137 @@ 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)); + } + } + + /// 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) { + // 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; + 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, + } + } + } + + 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) + } + /// 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) => { @@ -108,7 +267,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 +294,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; @@ -155,6 +315,17 @@ 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) + // 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 { Err(e) if e.contains("cancelled") => None, @@ -251,115 +422,187 @@ 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 (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"); + 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 => { + 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 + 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()); + true + } + 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); - 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 { + // 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); - 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::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 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; } - 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 { + 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"); + ui.add_space(2.0); + let f = widgets::text_field(ui, &mut self.code, "123456"); + enter |= widgets::submitted(ui, &f); } - } 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))); + 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))); + } + if widgets::secondary_button(ui, "Cancel").clicked() { + self.signing_in = false; + self.code_sent_to = None; + self.code.clear(); + self.activation_msg = None; + } + }); } - // 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 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); } - 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"); + 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; } - }); - if let Some((ok, msg)) = &self.activation_msg { - 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(); - } - 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)); 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", @@ -410,6 +653,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; @@ -417,14 +667,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/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"); diff --git a/backend/README.md b/backend/README.md index 1d197fc..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 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 04212fb..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-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/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/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/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..658f15a 100644 --- a/crates/sf-license/src/lib.rs +++ b/crates/sf-license/src/lib.rs @@ -161,6 +161,146 @@ 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 = 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); + } + Err((_, msg)) => return Err(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)) + } + } +} + +/// 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 _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; + 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 { + let _one = FREE_EXPORTS_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_unsent_exports(unsent_exports() + 1); + free_exports_left_locked() +} + fn email_file() -> Option { key_file().map(|p| p.with_file_name("email")) }