diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index afa8eacd1..79f493819 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -47,6 +47,33 @@ jobs: # wasm-bindgen output is served verbatim. - run: touch dist/.nojekyll + # The web app can't call the Patreon API directly (CORS + the token would + # be exposed in the bundle), so generate the supporters list server-side + # here — token stays in the CI secret — and serve it next to the app. + # The web build fetches `supporters.json` on the same origin. + - name: Generate supporters.json + env: + OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} + run: | + if [ -z "$OCS_PATREON_TOKEN" ]; then + echo '[]' > dist/supporters.json; exit 0 + fi + CID=$(curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \ + "https://www.patreon.com/api/oauth2/v2/campaigns" \ + | jq -r '.data[0].id // empty' || true) + if [ -z "$CID" ]; then + echo '[]' > dist/supporters.json; exit 0 + fi + curl -sf -H "Authorization: Bearer $OCS_PATREON_TOKEN" \ + "https://www.patreon.com/api/oauth2/v2/campaigns/$CID/members?fields%5Bmember%5D=full_name,patron_status,currently_entitled_amount_cents&page%5Bcount%5D=200" \ + | jq -c '[.data[] + | select(.attributes.patron_status=="active_patron") + | select(.attributes.currently_entitled_amount_cents>0) + | {name: .attributes.full_name, cents: .attributes.currently_entitled_amount_cents}] + | sort_by(-.cents)' \ + > dist/supporters.json || echo '[]' > dist/supporters.json + echo "supporters: $(jq 'length' dist/supporters.json)" + - uses: actions/upload-pages-artifact@v3 with: path: dist diff --git a/Cargo.toml b/Cargo.toml index ac42c2d7c..1070ad744 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ default = ["solid3d"] solid3d = ["dep:truck-meshalgo", "dep:truck-shapeops", "dep:lzma-sys"] [dependencies] +# JSON: plugin marketplace / Patreon fetch (native) + web supporters.json parse. +serde_json = "1" # Stable, dependency-free add-on contract (manifest + ribbon/CadModule types). # Plugin authors target this crate's semver, not OpenCADStudio internals. # The `host` feature (out-of-process plugin runtime: interprocess, libloading, @@ -89,8 +91,6 @@ open = "5" # `memchr` (1.0.2) via lopdf → nom_locate, and the web build has no filesystem. printpdf = { version = "0.9.1", default-features = false } ureq = { version = "3", default-features = false, features = ["rustls"] } -# Parse the GitHub Releases API response for the plugin marketplace. -serde_json = "1" [target.'cfg(target_arch = "wasm32")'.dependencies] # Web gets the dependency-free manifest/ribbon contract only (no plugin host). diff --git a/src/app/mod.rs b/src/app/mod.rs index 589227e85..50078d511 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2329,7 +2329,13 @@ impl OpenCADStudio { #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut s = Self::new(); let focus = s.focus_cmd_input(); - (s, focus) + // Web can't reach the Patreon API directly (CORS); fetch the CI-built + // supporters.json served on the same origin instead. + let patrons = Task::perform( + crate::patreon::fetch_patrons_web(), + Message::PatronsFetched, + ); + (s, Task::batch([focus, patrons])) } } diff --git a/src/patreon.rs b/src/patreon.rs index 436ac531c..d2d425d6e 100644 --- a/src/patreon.rs +++ b/src/patreon.rs @@ -71,6 +71,42 @@ pub fn fetch_patrons() -> Result, String> { Ok(patrons) } +/// Web build: the browser can't call the Patreon API directly (CORS + the +/// token would be exposed in the bundle), so it fetches a pre-generated +/// `supporters.json` published next to the app on the same origin (produced by +/// CI with the token held server-side). Shape: `[{ "name": .., "cents": .. }]`. +#[cfg(target_arch = "wasm32")] +pub async fn fetch_patrons_web() -> Result, String> { + use wasm_bindgen::JsCast; + use wasm_bindgen_futures::JsFuture; + + let window = web_sys::window().ok_or("no window")?; + let resp_val = JsFuture::from(window.fetch_with_str("supporters.json")) + .await + .map_err(|_| "fetch failed")?; + let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| "not a Response")?; + if !resp.ok() { + return Err(format!("HTTP {}", resp.status())); + } + let text = JsFuture::from(resp.text().map_err(|_| "text() unavailable")?) + .await + .map_err(|_| "body read failed")?; + let body = text.as_string().ok_or("body is not a string")?; + + let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?; + let arr = json.as_array().ok_or("supporters.json is not an array")?; + Ok(arr + .iter() + .filter_map(|e| { + let name = e["name"].as_str()?.trim().to_string(); + if name.is_empty() { + return None; + } + Some((name, e["cents"].as_i64().unwrap_or(0))) + }) + .collect()) +} + #[cfg(not(target_arch = "wasm32"))] fn get_json( agent: &ureq::Agent,