diff --git a/src/builds.rs b/src/builds.rs index fc48f99..ea5a062 100644 --- a/src/builds.rs +++ b/src/builds.rs @@ -1,5 +1,6 @@ use crate::auth::AuthManager; -use crate::config::{self, WavedashConfig}; +use crate::config::{self, EngineKind, WavedashConfig}; +use crate::dev::entrypoint::{fetch_entrypoint_params, resolve_defold_entrypoint}; use crate::file_staging::FileStaging; use anyhow::Result; use serde::{Deserialize, Serialize}; @@ -148,6 +149,15 @@ pub async fn handle_build_push(config_path: PathBuf, verbose: bool, message: Opt anyhow::bail!("Source must be a directory: {}", upload_dir.display()); } + let engine_kind = wavedash_config.engine_type()?; + let defold_entrypoint = match engine_kind { + Some(EngineKind::Defold) => Some(resolve_defold_entrypoint( + &upload_dir, + wavedash_config.entrypoint.as_deref(), + )?), + _ => None, + }; + // Validate required files exist in upload directory FileStaging::prepare(&upload_dir, &wavedash_config)?; @@ -157,14 +167,46 @@ pub async fn handle_build_push(config_path: PathBuf, verbose: bool, message: Opt anyhow::bail!("No files found in {}", upload_dir.display()); } + let entrypoint = match (&engine_kind, &defold_entrypoint) { + (Some(EngineKind::Defold), Some((_, html_relative_path))) => { + Some(html_relative_path.as_str()) + } + _ => wavedash_config.entrypoint(), + }; + let engine_version = wavedash_config.engine_version(); + let entrypoint_params = match engine_kind { + Some(EngineKind::Defold) => { + let (html_path, html_relative_path) = defold_entrypoint + .as_ref() + .expect("defold entrypoint resolved"); + let ver = engine_version + .ok_or_else(|| anyhow::anyhow!("DEFOLD engine requires a version"))?; + Some( + fetch_entrypoint_params( + EngineKind::Defold.as_label(), + ver, + html_path, + Some(html_relative_path.as_str()), + ) + .await?, + ) + } + Some(EngineKind::JsDos | EngineKind::Ruffle | EngineKind::RenPy) => { + wavedash_config.executable_entrypoint_params() + } + // Explicit (not `_`) so a new EngineKind forces a decision. Godot/Unity + // params are computed server-side. + Some(EngineKind::Godot | EngineKind::Unity) => None, + None => None, + }; + // Get temporary R2 credentials (includes build size) - let engine_kind = wavedash_config.engine_type()?; let creds = get_temp_credentials( &wavedash_config.game_id, engine_kind.map(|e| e.as_label()), - wavedash_config.engine_version(), - wavedash_config.entrypoint(), - wavedash_config.executable_entrypoint_params(), + engine_version, + entrypoint, + entrypoint_params, message.as_deref(), total_bytes, &api_key, diff --git a/src/config.rs b/src/config.rs index ef5f4cf..76e02ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -147,6 +147,11 @@ pub struct UnitySection { pub version: String, } +#[derive(Debug, Deserialize)] +pub struct DefoldSection { + pub version: String, +} + /// Shape for engines whose runtime is fetched as a single executable file /// (plus an optional loader script). Used by JSDOS, Ruffle, and Ren'Py. #[derive(Debug, Deserialize)] @@ -168,6 +173,9 @@ pub struct WavedashConfig { #[serde(rename = "unity")] pub unity: Option, + #[serde(rename = "defold")] + pub defold: Option, + #[serde(rename = "jsdos")] pub jsdos: Option, @@ -182,6 +190,7 @@ pub struct WavedashConfig { pub enum EngineKind { Godot, Unity, + Defold, JsDos, Ruffle, RenPy, @@ -192,6 +201,7 @@ impl EngineKind { match self { EngineKind::Godot => "GODOT", EngineKind::Unity => "UNITY", + EngineKind::Defold => "DEFOLD", EngineKind::JsDos => "JSDOS", EngineKind::Ruffle => "RUFFLE", EngineKind::RenPy => "RENPY", @@ -236,6 +246,7 @@ impl WavedashConfig { let engines: Vec = [ self.godot.is_some().then_some(EngineKind::Godot), self.unity.is_some().then_some(EngineKind::Unity), + self.defold.is_some().then_some(EngineKind::Defold), self.jsdos.is_some().then_some(EngineKind::JsDos), self.ruffle.is_some().then_some(EngineKind::Ruffle), self.renpy.is_some().then_some(EngineKind::RenPy), @@ -248,7 +259,7 @@ impl WavedashConfig { 0 => Ok(None), 1 => Ok(Some(engines[0])), _ => anyhow::bail!( - "Config must have at most one engine section: [godot], [unity], [jsdos], [ruffle], or [renpy]" + "Config must have at most one engine section: [godot], [unity], [defold], [jsdos], [ruffle], or [renpy]" ), } } @@ -270,6 +281,9 @@ impl WavedashConfig { if let Some(unity) = &self.unity { return Some(&unity.version); } + if let Some(defold) = &self.defold { + return Some(&defold.version); + } self.executable_section().map(|s| s.version.as_str()) } diff --git a/src/dev/entrypoint.rs b/src/dev/entrypoint.rs index 5266f9c..2225084 100644 --- a/src/dev/entrypoint.rs +++ b/src/dev/entrypoint.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use anyhow::{Context, Result}; use serde::Deserialize; @@ -14,6 +14,11 @@ struct EntrypointParamsResponse { entrypoint_params: Value, } +/// Locate the HTML entrypoint for engines that ship a single root export +/// (Godot, Unity): a root `index.html`, falling back to the first `.html` found. +/// Defold doesn't use this — it parses the explicit `entrypoint` from +/// wavedash.toml, since its bundles can nest the HTML under `wasm-web`/`js-web` +/// and ship more than one. No "which export is newest" guessing lives here. pub fn locate_html_entrypoint(upload_dir: &Path) -> Option { let default_index = upload_dir.join("index.html"); if default_index.is_file() { @@ -37,23 +42,103 @@ pub fn locate_html_entrypoint(upload_dir: &Path) -> Option { None } -pub async fn fetch_entrypoint_params(engine: &str, engine_version: &str, html_path: &Path) -> Result { +/// Resolve the developer-named Defold entrypoint HTML to its absolute path plus +/// normalized build-relative path. Defold names its entrypoint explicitly (a +/// bundle can ship both `wasm-web/` and `js-web/`), so there's no inference here +/// — a missing or wrong path is a clear error, not a guess. +pub fn resolve_defold_entrypoint( + upload_dir: &Path, + entrypoint: Option<&str>, +) -> Result<(PathBuf, String)> { + let entrypoint = entrypoint.ok_or_else(|| { + anyhow::anyhow!( + "Defold builds need an `entrypoint` in wavedash.toml pointing to your HTML5 export, e.g.\n entrypoint = \"wasm-web//index.html\"\n(a Defold bundle can contain both wasm-web/ and js-web/ — pick one)" + ) + })?; + let entrypoint_input = entrypoint.replace('\\', "/"); + let entrypoint_path = Path::new(&entrypoint_input); + if entrypoint_path.is_absolute() + || entrypoint_path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + anyhow::bail!( + "Defold entrypoint `{}` must be a relative path inside upload_dir ({}).", + entrypoint, + upload_dir.display() + ); + } + let relative_path = entrypoint_path + .components() + .filter_map(|component| match component { + Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()), + Component::CurDir => None, + _ => None, + }) + .collect::>() + .join("/"); + + let lower = relative_path.to_ascii_lowercase(); + if !lower.ends_with(".html") && !lower.ends_with(".htm") { + anyhow::bail!( + "Defold entrypoint `{}` must be an HTML file (.html/.htm).", + entrypoint + ); + } + + let html_path = upload_dir.join(&relative_path); + if !html_path.is_file() { + anyhow::bail!( + "Defold entrypoint `{}` not found under {}. Point `entrypoint` in wavedash.toml at your export's index.html.", + entrypoint, + upload_dir.display() + ); + } + + let canonical_upload_dir = upload_dir + .canonicalize() + .with_context(|| format!("Failed to canonicalize {}", upload_dir.display()))?; + let canonical_html_path = html_path + .canonicalize() + .with_context(|| format!("Failed to canonicalize {}", html_path.display()))?; + if !canonical_html_path.starts_with(&canonical_upload_dir) { + anyhow::bail!( + "Defold entrypoint `{}` resolves outside upload_dir ({}).", + entrypoint, + upload_dir.display() + ); + } + + Ok((html_path, relative_path)) +} + +pub async fn fetch_entrypoint_params( + engine: &str, + engine_version: &str, + html_path: &Path, + html_relative_path: Option<&str>, +) -> Result { let html_content = fs::read_to_string(html_path) .with_context(|| format!("Failed to read {}", html_path.display()))?; let api_host = config::get("api_host")?; - let endpoint = format!( - "{}/cli/entrypoint-params", - api_host.trim_end_matches('/') - ); + let endpoint = format!("{}/cli/entrypoint-params", api_host.trim_end_matches('/')); + + let mut body = serde_json::json!({ + "engine": engine, + "engineVersion": engine_version, + "htmlContent": html_content, + }); + if let Some(html_path) = html_relative_path { + body["htmlPath"] = serde_json::json!(html_path); + } let client = config::create_http_client()?; let response = client .post(&endpoint) - .json(&serde_json::json!({ - "engine": engine, - "engineVersion": engine_version, - "htmlContent": html_content, - })) + .json(&body) .send() .await .with_context(|| "Failed to call CLI entrypoint params endpoint")?; @@ -70,3 +155,99 @@ pub async fn fetch_entrypoint_params(engine: &str, engine_version: &str, html_pa Ok(parsed.entrypoint_params) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_upload_dir(name: &str) -> PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock before unix epoch") + .as_nanos(); + std::env::temp_dir().join(format!( + "wavedash-cli-entrypoint-{name}-{}-{unique}", + std::process::id() + )) + } + + #[test] + fn resolves_defold_entrypoint_inside_upload_dir() { + let upload_dir = temp_upload_dir("inside"); + let html_path = upload_dir.join("wasm-web/example/index.html"); + fs::create_dir_all(html_path.parent().expect("html parent")).expect("create dirs"); + fs::write(&html_path, "").expect("write html"); + + let (resolved, relative) = + resolve_defold_entrypoint(&upload_dir, Some("wasm-web/example/index.html")) + .expect("resolve entrypoint"); + + assert_eq!(resolved, html_path); + assert_eq!(relative, "wasm-web/example/index.html"); + + fs::remove_dir_all(upload_dir).expect("cleanup"); + } + + #[test] + fn normalizes_defold_entrypoint_backslashes() { + let upload_dir = temp_upload_dir("backslashes"); + let html_path = upload_dir.join("wasm-web/example/index.html"); + fs::create_dir_all(html_path.parent().expect("html parent")).expect("create dirs"); + fs::write(&html_path, "").expect("write html"); + + let (resolved, relative) = + resolve_defold_entrypoint(&upload_dir, Some("wasm-web\\example\\index.html")) + .expect("resolve entrypoint"); + + assert_eq!(resolved, html_path); + assert_eq!(relative, "wasm-web/example/index.html"); + + fs::remove_dir_all(upload_dir).expect("cleanup"); + } + + #[test] + fn normalizes_defold_entrypoint_dot_segments() { + let upload_dir = temp_upload_dir("dot-segments"); + let html_path = upload_dir.join("wasm-web/example/index.html"); + fs::create_dir_all(html_path.parent().expect("html parent")).expect("create dirs"); + fs::write(&html_path, "").expect("write html"); + + let (resolved, relative) = + resolve_defold_entrypoint(&upload_dir, Some("./wasm-web/./example/index.html")) + .expect("resolve entrypoint"); + + assert_eq!(resolved, html_path); + assert_eq!(relative, "wasm-web/example/index.html"); + + fs::remove_dir_all(upload_dir).expect("cleanup"); + } + + #[test] + fn rejects_defold_entrypoint_escape() { + let upload_dir = temp_upload_dir("escape"); + fs::create_dir_all(&upload_dir).expect("create upload dir"); + + let err = resolve_defold_entrypoint(&upload_dir, Some("../index.html")) + .expect_err("escape should fail"); + + assert!(err.to_string().contains("relative path inside upload_dir")); + + fs::remove_dir_all(upload_dir).expect("cleanup"); + } + + #[test] + fn rejects_defold_entrypoint_non_html() { + let upload_dir = temp_upload_dir("non-html"); + let asset_path = upload_dir.join("wasm-web/example/something.png"); + fs::create_dir_all(asset_path.parent().expect("asset parent")).expect("create dirs"); + fs::write(&asset_path, "not html").expect("write asset"); + + let err = resolve_defold_entrypoint(&upload_dir, Some("wasm-web/example/something.png")) + .expect_err("non-html entrypoint should fail"); + + assert!(err.to_string().contains("HTML file")); + + fs::remove_dir_all(upload_dir).expect("cleanup"); + } +} diff --git a/src/dev/mod.rs b/src/dev/mod.rs index 664a5d5..ea1cbee 100644 --- a/src/dev/mod.rs +++ b/src/dev/mod.rs @@ -9,11 +9,11 @@ use crate::config::{self, EngineKind, WavedashConfig}; use crate::file_staging::FileStaging; mod dev_app; -mod entrypoint; +pub(crate) mod entrypoint; mod launcher; use dev_app::{ensure_dev_app, user_data_dir}; -use entrypoint::{fetch_entrypoint_params, locate_html_entrypoint}; +use entrypoint::{fetch_entrypoint_params, locate_html_entrypoint, resolve_defold_entrypoint}; use launcher::{run_dev_app, DevAppConfig}; #[derive(Debug, Deserialize)] @@ -103,17 +103,28 @@ pub async fn handle_dev(config_path: Option, verbose: bool) -> Result<( })?; let engine_kind = wavedash_config.engine_type()?; + let defold_entrypoint = match engine_kind { + Some(EngineKind::Defold) => Some(resolve_defold_entrypoint( + &upload_dir, + wavedash_config.entrypoint.as_deref(), + )?), + _ => None, + }; - let entrypoint = wavedash_config.entrypoint().map(|s| s.to_string()); + let entrypoint = match (&engine_kind, &defold_entrypoint) { + (Some(EngineKind::Defold), Some((_, html_relative_path))) => { + Some(html_relative_path.to_string()) + } + _ => wavedash_config.entrypoint().map(str::to_string), + }; FileStaging::prepare(&upload_dir, &wavedash_config)?; - let html_entrypoint = locate_html_entrypoint(&upload_dir); let engine_version = wavedash_config.engine_version(); let entrypoint_params = match engine_kind { - Some(EngineKind::Godot | EngineKind::Unity) => { - let engine_label = engine_kind.unwrap().as_label(); - let html_path = html_entrypoint.as_deref().ok_or_else(|| { + Some(kind @ (EngineKind::Godot | EngineKind::Unity)) => { + let engine_label = kind.as_label(); + let html_path = locate_html_entrypoint(&upload_dir).ok_or_else(|| { anyhow::anyhow!( "No HTML file found in upload_dir; required for {} builds", engine_label @@ -121,7 +132,23 @@ pub async fn handle_dev(config_path: Option, verbose: bool) -> Result<( })?; let ver = engine_version .ok_or_else(|| anyhow::anyhow!("{} engine requires a version", engine_label))?; - Some(fetch_entrypoint_params(engine_label, ver, html_path).await?) + Some(fetch_entrypoint_params(engine_label, ver, &html_path, None).await?) + } + Some(EngineKind::Defold) => { + let (html_path, html_relative_path) = defold_entrypoint + .as_ref() + .expect("defold entrypoint resolved"); + let ver = engine_version + .ok_or_else(|| anyhow::anyhow!("DEFOLD engine requires a version"))?; + Some( + fetch_entrypoint_params( + EngineKind::Defold.as_label(), + ver, + html_path, + Some(html_relative_path.as_str()), + ) + .await?, + ) } Some(EngineKind::JsDos | EngineKind::Ruffle | EngineKind::RenPy) => { wavedash_config.executable_entrypoint_params() diff --git a/src/init.rs b/src/init.rs index 6a5ba4a..2315eea 100644 --- a/src/init.rs +++ b/src/init.rs @@ -39,6 +39,7 @@ struct GamesResponse { enum EngineType { Godot, Unity, + Defold, Custom, } @@ -47,6 +48,8 @@ impl EngineType { match self { EngineType::Godot => "build", EngineType::Unity => "build", + // Defold reserves top-level build/ for its build cache, so default elsewhere. + EngineType::Defold => "dist", EngineType::Custom => "dist", } } @@ -130,10 +133,25 @@ fn detect_unity(dir: &Path) -> Option { None } +/// Look for a Defold project marker. +fn detect_defold(dir: &Path) -> Option { + if dir.join("game.project").is_file() { + return Some(DetectedEngine { + engine_type: EngineType::Defold, + version_hint: None, + }); + } + + None +} + fn detect_engine(dir: &Path) -> DetectedEngine { if let Some(engine) = detect_godot(dir) { return engine; } + if let Some(engine) = detect_defold(dir) { + return engine; + } if let Some(engine) = detect_unity(dir) { return engine; } @@ -233,6 +251,16 @@ fn generate_toml( let version = engine_version.unwrap_or("2022.3"); toml.push_str(&format!("\n[unity]\nversion = \"{}\"\n", version)); } + EngineType::Defold => { + let version = engine_version.unwrap_or("1.12.4"); + // `entrypoint` is a top-level key, so the hint must precede the [defold] + // table. Left commented — the dev fills in the export they want to ship; + // `wavedash dev` / `build push` errors clearly until they do. + toml.push_str( + "\n# Defold HTML5 bundles can contain both wasm-web/ and js-web/ folders.\n# Set `entrypoint` (top-level) to the index.html you want to ship, e.g.:\n# entrypoint = \"wasm-web//index.html\"\n", + ); + toml.push_str(&format!("\n[defold]\nversion = \"{}\"\n", version)); + } EngineType::Custom => { toml.push_str("\nentrypoint = \"index.html\"\n"); } @@ -279,7 +307,7 @@ pub async fn handle_init() -> Result<()> { let current_dir = std::env::current_dir()?; let detected = detect_engine(¤t_dir); - // Only prompt for version when we detect Godot/Unity. + // Only prompt for version when we detect a first-class engine. // For web builds (threejs, phaser, custom, etc.) no engine config is needed. let engine_version: Option = match &detected.engine_type { EngineType::Godot => { @@ -302,6 +330,13 @@ pub async fn handle_init() -> Result<()> { Some(version) } } + EngineType::Defold => { + let version: String = cliclack::input("Defold version") + .placeholder("1.12.4") + .default_input("1.12.4") + .interact()?; + Some(version) + } EngineType::Custom => None, }; @@ -434,9 +469,14 @@ pub async fn handle_init() -> Result<()> { std::fs::write(&config_path, &toml_content)?; let website_host = config::get("open_browser_website_host")?; + let next_steps = if detected.engine_type == EngineType::Defold { + "Created wavedash.toml! Next steps:\n → Set `entrypoint` in wavedash.toml to your Defold HTML export\n → Run `wavedash dev` to test locally\n → Run `wavedash build push` to upload a build" + } else { + "Created wavedash.toml! Next steps:\n → Run `wavedash dev` to test locally\n → Run `wavedash build push` to upload a build" + }; cliclack::outro(format!( - "Created wavedash.toml! Next steps:\n → Run `wavedash dev` to test locally\n → Run `wavedash build push` to upload a build\n → Manage your game at {}/dev-portal/{}/{}", - website_host, selected_org.slug, selected_game.slug + "{}\n → Manage your game at {}/dev-portal/{}/{}", + next_steps, website_host, selected_org.slug, selected_game.slug ))?; Ok(())