From 608fe5ec3e3e10b6eb03e53e7ce36dad27b9cfd0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 2 Jul 2026 01:00:22 +0300 Subject: [PATCH] feat(start): show paying Patreon supporters on the Start page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a right-hand rail on the Start page listing active paying patrons (name + pledge amount, highest first) with a "Support on Patreon" button. The list is fetched once at boot from the Patreon API in the background; free followers, $0 tiers, declined and former patrons are excluded. The creator access token is read at build time from OCS_PATREON_TOKEN (option_env!), so it never lives in source or git — the release workflow passes it from a repo secret, and build.rs re-bakes when it changes. Without a token the rail just shows the support button. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 6 +++ build.rs | 6 +++ src/app/mod.rs | 15 ++++++ src/app/update/mod.rs | 7 +++ src/app/view/mod.rs | 85 +++++++++++++++++++++++++++++++-- src/lib.rs | 1 + src/main.rs | 1 + src/patreon.rs | 90 +++++++++++++++++++++++++++++++++++ 8 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 src/patreon.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e1fce3c8..86ce62b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,8 @@ jobs: librsvg2-bin fuse libfuse2 - name: Build + env: + OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} run: cargo build --release - name: Prepare AppDir @@ -92,6 +94,8 @@ jobs: packaging/windows/AppIcon.ico - name: Build + env: + OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} run: cargo build --release - name: Sign executable (Azure Trusted Signing) @@ -215,6 +219,8 @@ jobs: run: brew install librsvg - name: Build + env: + OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} run: cargo build --release --target aarch64-apple-darwin - name: Build .icns from SVG diff --git a/build.rs b/build.rs index c7ed92a0..80230dcb 100644 --- a/build.rs +++ b/build.rs @@ -7,6 +7,12 @@ use std::path::Path; fn main() { + // The Patreon token is baked in at compile time via `option_env!` in + // src/patreon.rs. `option_env!` is not tracked by Cargo, so without this a + // token change wouldn't trigger a rebuild — declare the dependency so an + // updated OCS_PATREON_TOKEN re-bakes the binary. (#229-adjacent) + println!("cargo:rerun-if-env-changed=OCS_PATREON_TOKEN"); + // Windows: embed AppIcon.ico into the .exe so the executable carries its // own icon (Explorer, taskbar, Start-menu tile, file associations). The // .ico is produced from assets/logo.svg by the release workflow before the diff --git a/src/app/mod.rs b/src/app/mod.rs index 31fc33a1..589227e8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -194,6 +194,9 @@ pub(super) struct OpenCADStudio { ribbon: Ribbon, app_menu: AppMenu, command_line: CommandLine, + /// Paying Patreon supporters shown on the Start page (name, pledge cents), + /// fetched once at boot, highest pledge first. + patrons: Vec<(String, i64)>, /// Read-only editor buffer backing the command-line history dropdown, so /// the log can be drag-selected across lines and copied (issue #232). /// Rebuilt from the history each time the dropdown is opened. @@ -1537,6 +1540,8 @@ pub enum Message { PluginRepoRemove(String), /// The curated registry was fetched. PluginRegistryFetched(Result, String>), + /// Patreon supporters fetched at boot for the Start page (name, pledge cents). + PatronsFetched(Result, String>), /// Installable release tags fetched for `owner/repo`. PluginReleasesFetched(String, Result, String>), /// Choose a release tag for a repo (`repo`, `tag`). @@ -1924,6 +1929,7 @@ impl OpenCADStudio { ribbon: Ribbon::new(), app_menu, command_line: CommandLine::new(), + patrons: Vec::new(), history_content: iced::widget::text_editor::Content::new(), status_bar: StatusBar::new(), cursor_pos: Point::ORIGIN, @@ -2293,6 +2299,14 @@ impl OpenCADStudio { if !s.default_assoc_prompted { s.active_modal = Some(ModalKind::AssocPrompt); } + // Fetch the Patreon supporters list once at boot for the Start page. + #[cfg(not(target_arch = "wasm32"))] + let patrons_fetch = Task::perform( + async { crate::patreon::fetch_patrons() }, + Message::PatronsFetched, + ); + #[cfg(target_arch = "wasm32")] + let patrons_fetch = Task::none(); ( s, Task::batch([ @@ -2302,6 +2316,7 @@ impl OpenCADStudio { cli_open, script, assoc_prompt, + patrons_fetch, ]), ) } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 5ba9ac5c..aebfc1c3 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2592,6 +2592,13 @@ impl OpenCADStudio { self.marketplace_status = format!("Registry: {e}"); Task::none() } + Message::PatronsFetched(Ok(names)) => { + self.patrons = names; + Task::none() + } + // No token / offline: leave the list empty (Start page shows a + // "Support on Patreon" prompt instead). + Message::PatronsFetched(Err(_)) => Task::none(), Message::PluginReleasesFetched(repo, Ok(tags)) => { if let Some(first) = tags.first() { self.repo_selected_tag diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1961b37a..1509e150 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -77,7 +77,7 @@ impl OpenCADStudio { // viewport draws the layout's own geometry (white sheet + entities + // borders) and the floating content viewports blit on top. let viewport_3d: Element<'_, Message> = if tab.is_start { - start_page_view() + start_page_view(&self.patrons) } else if is_paper { shader(ViewportPane::model( &tab.scene, @@ -1871,7 +1871,7 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> { .into() } -pub(super) fn start_page_view<'a>() -> Element<'a, Message> { +pub(super) fn start_page_view<'a>(patrons: &'a [(String, i64)]) -> Element<'a, Message> { const TEXT: Color = Color { r: 0.94, g: 0.93, @@ -2199,7 +2199,86 @@ pub(super) fn start_page_view<'a>() -> Element<'a, Message> { b: 0.085, a: 1.0, }; - container(content) + // Right rail: Patreon supporters, fetched at boot. When the list is empty + // (no token configured / offline) only the "Support on Patreon" button + // shows, so the rail always invites support. + let supporters: Element<'a, Message> = { + const NAME_COLOR: Color = Color { + r: 0.78, + g: 0.78, + b: 0.80, + a: 1.0, + }; + let mut list = column![ + text("Supporters").size(15).color(TEXT), + Space::new().height(iced::Length::Fixed(12.0)), + ] + .spacing(6) + .width(Fill); + for (name, cents) in patrons { + // Cents → the campaign currency's main unit. Symbol assumed "$"; + // adjust if the campaign bills in another currency. + let amount = format!("${:.2}", *cents as f64 / 100.0); + list = list.push( + iced::widget::row![ + text(name).size(12).color(NAME_COLOR).width(Fill), + text(amount).size(12).color(NAME_COLOR), + ] + .spacing(6), + ); + } + let support_btn = mouse_area( + container( + text("♥ Support on Patreon") + .size(12) + .color(Color::WHITE), + ) + .padding([6, 10]) + .width(Fill) + .center_x(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { + r: 0.90, + g: 0.28, + b: 0.30, + a: 1.0, + })), + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 6.0.into(), + }, + ..Default::default() + }), + ) + .interaction(iced::mouse::Interaction::Pointer) + .on_press(Message::OpenUrl( + "https://patreon.com/HakanSeven12".to_string(), + )); + container(column![ + iced::widget::scrollable(list).height(Fill), + Space::new().height(iced::Length::Fixed(12.0)), + support_btn, + ]) + .width(iced::Length::Fixed(240.0)) + .height(Fill) + .padding(20) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(CARD_BG)), + border: Border { + color: CARD_BORDER, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .into() + }; + + container(iced::widget::row![ + container(content).width(Fill).height(Fill), + supporters, + ]) .style(|_: &Theme| container::Style { background: Some(Background::Color(PAGE_BG)), ..Default::default() diff --git a/src/lib.rs b/src/lib.rs index 8320c41d..1641b7de 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod command; pub mod entities; pub mod io; pub mod modules; +pub mod patreon; pub mod plugin; pub mod scene; pub mod snap; diff --git a/src/main.rs b/src/main.rs index fae0a347..40f6b5fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod command; mod entities; mod io; mod modules; +mod patreon; mod plugin; mod scene; mod snap; diff --git a/src/patreon.rs b/src/patreon.rs new file mode 100644 index 00000000..436ac531 --- /dev/null +++ b/src/patreon.rs @@ -0,0 +1,90 @@ +//! Live Patreon supporters fetch for the Start page. +//! +//! The creator access token is injected at BUILD time via the +//! `OCS_PATREON_TOKEN` environment variable (`option_env!`), so it never lives +//! in the source tree or git history — official release builds set it from a CI +//! secret; other builds simply get an empty list. Note that an embedded token +//! can still be extracted from a shipped binary, so it should be a +//! campaign-scoped token with the minimum needed access. + +#[cfg(not(target_arch = "wasm32"))] +const UA: &str = concat!("OpenCADStudio/", env!("CARGO_PKG_VERSION")); + +/// Fetch the paying patrons from the Patreon API as `(display name, amount in +/// cents)`, highest pledge first. `Err` when no token is configured or the API +/// call fails. +#[cfg(not(target_arch = "wasm32"))] +pub fn fetch_patrons() -> Result, String> { + let token = option_env!("OCS_PATREON_TOKEN") + .filter(|t| !t.is_empty()) + .ok_or("no Patreon token configured")?; + + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(std::time::Duration::from_secs(15))) + .build() + .into(); + + // The token is creator-scoped, so its first campaign is the one to list. + let campaigns = get_json(&agent, token, "https://www.patreon.com/api/oauth2/v2/campaigns")?; + let campaign_id = campaigns["data"][0]["id"] + .as_str() + .ok_or("no Patreon campaign found for this token")? + .to_string(); + + // Page through the campaign members, keeping only paying patrons. + let mut patrons: Vec<(String, i64)> = Vec::new(); + let mut url = format!( + "https://www.patreon.com/api/oauth2/v2/campaigns/{campaign_id}/members\ + ?fields%5Bmember%5D=full_name,patron_status,currently_entitled_amount_cents\ + &page%5Bcount%5D=200" + ); + // Bound the loop so a malformed `next` link can never spin forever. + for _ in 0..50 { + let page = get_json(&agent, token, &url)?; + if let Some(arr) = page["data"].as_array() { + for m in arr { + let attrs = &m["attributes"]; + // Paying supporters only: an active patron currently entitled to + // a non-zero amount (excludes free followers, $0 tiers, declined + // and former patrons). + if attrs["patron_status"].as_str() != Some("active_patron") { + continue; + } + let cents = attrs["currently_entitled_amount_cents"].as_i64().unwrap_or(0); + if cents <= 0 { + continue; + } + let name = attrs["full_name"].as_str().unwrap_or("").trim(); + if !name.is_empty() { + patrons.push((name.to_string(), cents)); + } + } + } + match page["links"]["next"].as_str() { + Some(next) if !next.is_empty() => url = next.to_string(), + _ => break, + } + } + + // Highest pledge first, then alphabetical. + patrons.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + Ok(patrons) +} + +#[cfg(not(target_arch = "wasm32"))] +fn get_json( + agent: &ureq::Agent, + token: &str, + url: &str, +) -> Result { + let body = agent + .get(url) + .header("Authorization", &format!("Bearer {token}")) + .header("User-Agent", UA) + .call() + .map_err(|e| e.to_string())? + .body_mut() + .read_to_string() + .map_err(|e| e.to_string())?; + serde_json::from_str(&body).map_err(|e| e.to_string()) +}