feat(start): show paying Patreon supporters on the Start page
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 <noreply@anthropic.com>
This commit is contained in:
parent
80ad27ec67
commit
608fe5ec3e
8 changed files with 208 additions and 3 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -27,6 +27,8 @@ jobs:
|
||||||
librsvg2-bin fuse libfuse2
|
librsvg2-bin fuse libfuse2
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|
||||||
- name: Prepare AppDir
|
- name: Prepare AppDir
|
||||||
|
|
@ -92,6 +94,8 @@ jobs:
|
||||||
packaging/windows/AppIcon.ico
|
packaging/windows/AppIcon.ico
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
|
||||||
run: cargo build --release
|
run: cargo build --release
|
||||||
|
|
||||||
- name: Sign executable (Azure Trusted Signing)
|
- name: Sign executable (Azure Trusted Signing)
|
||||||
|
|
@ -215,6 +219,8 @@ jobs:
|
||||||
run: brew install librsvg
|
run: brew install librsvg
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }}
|
||||||
run: cargo build --release --target aarch64-apple-darwin
|
run: cargo build --release --target aarch64-apple-darwin
|
||||||
|
|
||||||
- name: Build .icns from SVG
|
- name: Build .icns from SVG
|
||||||
|
|
|
||||||
6
build.rs
6
build.rs
|
|
@ -7,6 +7,12 @@
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
fn main() {
|
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
|
// Windows: embed AppIcon.ico into the .exe so the executable carries its
|
||||||
// own icon (Explorer, taskbar, Start-menu tile, file associations). The
|
// own icon (Explorer, taskbar, Start-menu tile, file associations). The
|
||||||
// .ico is produced from assets/logo.svg by the release workflow before the
|
// .ico is produced from assets/logo.svg by the release workflow before the
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,9 @@ pub(super) struct OpenCADStudio {
|
||||||
ribbon: Ribbon,
|
ribbon: Ribbon,
|
||||||
app_menu: AppMenu,
|
app_menu: AppMenu,
|
||||||
command_line: CommandLine,
|
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
|
/// Read-only editor buffer backing the command-line history dropdown, so
|
||||||
/// the log can be drag-selected across lines and copied (issue #232).
|
/// the log can be drag-selected across lines and copied (issue #232).
|
||||||
/// Rebuilt from the history each time the dropdown is opened.
|
/// Rebuilt from the history each time the dropdown is opened.
|
||||||
|
|
@ -1537,6 +1540,8 @@ pub enum Message {
|
||||||
PluginRepoRemove(String),
|
PluginRepoRemove(String),
|
||||||
/// The curated registry was fetched.
|
/// The curated registry was fetched.
|
||||||
PluginRegistryFetched(Result<Vec<crate::plugin::external::RegistryEntry>, String>),
|
PluginRegistryFetched(Result<Vec<crate::plugin::external::RegistryEntry>, String>),
|
||||||
|
/// Patreon supporters fetched at boot for the Start page (name, pledge cents).
|
||||||
|
PatronsFetched(Result<Vec<(String, i64)>, String>),
|
||||||
/// Installable release tags fetched for `owner/repo`.
|
/// Installable release tags fetched for `owner/repo`.
|
||||||
PluginReleasesFetched(String, Result<Vec<String>, String>),
|
PluginReleasesFetched(String, Result<Vec<String>, String>),
|
||||||
/// Choose a release tag for a repo (`repo`, `tag`).
|
/// Choose a release tag for a repo (`repo`, `tag`).
|
||||||
|
|
@ -1924,6 +1929,7 @@ impl OpenCADStudio {
|
||||||
ribbon: Ribbon::new(),
|
ribbon: Ribbon::new(),
|
||||||
app_menu,
|
app_menu,
|
||||||
command_line: CommandLine::new(),
|
command_line: CommandLine::new(),
|
||||||
|
patrons: Vec::new(),
|
||||||
history_content: iced::widget::text_editor::Content::new(),
|
history_content: iced::widget::text_editor::Content::new(),
|
||||||
status_bar: StatusBar::new(),
|
status_bar: StatusBar::new(),
|
||||||
cursor_pos: Point::ORIGIN,
|
cursor_pos: Point::ORIGIN,
|
||||||
|
|
@ -2293,6 +2299,14 @@ impl OpenCADStudio {
|
||||||
if !s.default_assoc_prompted {
|
if !s.default_assoc_prompted {
|
||||||
s.active_modal = Some(ModalKind::AssocPrompt);
|
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,
|
s,
|
||||||
Task::batch([
|
Task::batch([
|
||||||
|
|
@ -2302,6 +2316,7 @@ impl OpenCADStudio {
|
||||||
cli_open,
|
cli_open,
|
||||||
script,
|
script,
|
||||||
assoc_prompt,
|
assoc_prompt,
|
||||||
|
patrons_fetch,
|
||||||
]),
|
]),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2592,6 +2592,13 @@ impl OpenCADStudio {
|
||||||
self.marketplace_status = format!("Registry: {e}");
|
self.marketplace_status = format!("Registry: {e}");
|
||||||
Task::none()
|
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)) => {
|
Message::PluginReleasesFetched(repo, Ok(tags)) => {
|
||||||
if let Some(first) = tags.first() {
|
if let Some(first) = tags.first() {
|
||||||
self.repo_selected_tag
|
self.repo_selected_tag
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ impl OpenCADStudio {
|
||||||
// viewport draws the layout's own geometry (white sheet + entities +
|
// viewport draws the layout's own geometry (white sheet + entities +
|
||||||
// borders) and the floating content viewports blit on top.
|
// borders) and the floating content viewports blit on top.
|
||||||
let viewport_3d: Element<'_, Message> = if tab.is_start {
|
let viewport_3d: Element<'_, Message> = if tab.is_start {
|
||||||
start_page_view()
|
start_page_view(&self.patrons)
|
||||||
} else if is_paper {
|
} else if is_paper {
|
||||||
shader(ViewportPane::model(
|
shader(ViewportPane::model(
|
||||||
&tab.scene,
|
&tab.scene,
|
||||||
|
|
@ -1871,7 +1871,7 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> {
|
||||||
.into()
|
.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 {
|
const TEXT: Color = Color {
|
||||||
r: 0.94,
|
r: 0.94,
|
||||||
g: 0.93,
|
g: 0.93,
|
||||||
|
|
@ -2199,7 +2199,86 @@ pub(super) fn start_page_view<'a>() -> Element<'a, Message> {
|
||||||
b: 0.085,
|
b: 0.085,
|
||||||
a: 1.0,
|
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 {
|
.style(|_: &Theme| container::Style {
|
||||||
background: Some(Background::Color(PAGE_BG)),
|
background: Some(Background::Color(PAGE_BG)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod command;
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
|
pub mod patreon;
|
||||||
pub mod plugin;
|
pub mod plugin;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod snap;
|
pub mod snap;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ mod command;
|
||||||
mod entities;
|
mod entities;
|
||||||
mod io;
|
mod io;
|
||||||
mod modules;
|
mod modules;
|
||||||
|
mod patreon;
|
||||||
mod plugin;
|
mod plugin;
|
||||||
mod scene;
|
mod scene;
|
||||||
mod snap;
|
mod snap;
|
||||||
|
|
|
||||||
90
src/patreon.rs
Normal file
90
src/patreon.rs
Normal file
|
|
@ -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<Vec<(String, i64)>, 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<serde_json::Value, String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue