1use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19
20use concinnity_cook::authoring::world::WorldJsonlAsset;
21use concinnity_cook::build_from_path;
22use concinnity_cook::build_only::prepare_world;
23use concinnity_cook::paths::StateTree;
24
25use crate::command::resolve_world_path;
26
27struct AppMeta {
29 display_name: String,
31 identifier: String,
33 version: String,
35 icon: Option<PathBuf>,
37}
38
39pub fn export(
43 json_path: Option<&str>,
44 name: Option<&str>,
45 version: Option<&str>,
46 platform: Option<&str>,
47 out: &str,
48 format: &str,
49 dmg: bool,
50) -> io::Result<()> {
51 let make_zip = match format {
52 "zip" => true,
53 "dir" => false,
54 other => {
55 return Err(io::Error::new(
56 io::ErrorKind::InvalidInput,
57 format!("unknown --format '{other}' (expected 'zip' or 'dir')"),
58 ));
59 }
60 };
61 if dmg && !cfg!(target_os = "macos") {
62 return Err(io::Error::new(
63 io::ErrorKind::Unsupported,
64 "--dmg is only available when exporting on macOS",
65 ));
66 }
67
68 check_target_platform(platform)?;
72 let runtime = runtime_binary_path()?;
73 let runtime_platform = read_runtime_platform(&runtime)?;
74 verify_runtime_backend(runtime_platform.as_deref(), crate::cook_platform())?;
75
76 let world_path = resolve_world_path(json_path)?;
79 let tree = crate::project::require()?;
80 build_from_path(&tree, &world_path, crate::cook_platform())?;
81
82 let content = fs::read_to_string(&world_path)?;
85 let loaded = prepare_world(
86 &content,
87 crate::project::assets_dir().as_deref(),
88 crate::cook_platform(),
89 )
90 .map_err(|errs| io::Error::new(io::ErrorKind::InvalidData, errs.join("\n")))?;
91 let meta = read_app_meta(name, version, &loaded.assets);
92
93 let out_dir = Path::new(out);
94 fs::create_dir_all(out_dir)?;
95 let data_dir = crate::project::data_dir().ok_or_else(|| {
96 io::Error::new(
97 io::ErrorKind::NotFound,
98 "no project state directory to read the built blobs from",
99 )
100 })?;
101
102 if cfg!(target_os = "macos") {
103 export_macos(&meta, &runtime, out_dir, &data_dir, make_zip, dmg)
104 } else {
105 export_portable(
106 &meta,
107 &runtime,
108 runtime_platform.as_deref(),
109 out_dir,
110 &data_dir,
111 make_zip,
112 )
113 }
114}
115
116fn export_portable(
121 meta: &AppMeta,
122 runtime: &Path,
123 runtime_platform: Option<&str>,
124 out_dir: &Path,
125 data_dir: &Path,
126 make_zip: bool,
127) -> io::Result<()> {
128 let slug = slug(&meta.display_name);
129 let bundle_dir = out_dir.join(&slug);
130 reset_dir(&bundle_dir)?;
131
132 let exe_name = exe_file_name(&slug);
133 let exe_dst = bundle_dir.join(&exe_name);
134 fs::copy(runtime, &exe_dst)?;
135 make_executable(&exe_dst)?;
136 copy_runtime_sidecars(runtime, runtime_platform, &bundle_dir)?;
137
138 let blobs = copy_blobs(data_dir, &StateTree::at(&bundle_dir).data_dir())?;
139 precompile_shaders(&bundle_dir);
143 report_export(&meta.display_name, &bundle_dir, blobs);
144
145 if make_zip {
146 let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
147 let zip_path = out_dir.join(format!("{stem}.zip"));
148 zip_tree(&bundle_dir, &slug, &exe_name, &zip_path)?;
149 println!("Wrote {}", zip_path.display());
150 }
151 Ok(())
152}
153
154fn export_macos(
159 meta: &AppMeta,
160 runtime: &Path,
161 out_dir: &Path,
162 data_dir: &Path,
163 make_zip: bool,
164 make_dmg: bool,
165) -> io::Result<()> {
166 let slug = slug(&meta.display_name);
167 let app_dir = out_dir.join(format!("{slug}.app"));
168 reset_dir(&app_dir)?;
169
170 let contents = app_dir.join("Contents");
171 let macos_dir = contents.join("MacOS");
172 let resources = contents.join("Resources");
173 fs::create_dir_all(&macos_dir)?;
174 fs::create_dir_all(&resources)?;
175
176 let exe_dst = macos_dir.join(&slug);
177 fs::copy(runtime, &exe_dst)?;
178 make_executable(&exe_dst)?;
179
180 let blobs = copy_blobs(data_dir, &StateTree::at(&resources).data_dir())?;
181 precompile_shaders(&resources);
184
185 let icon_file = Some(match &meta.icon {
188 Some(src) => build_icns(src, &resources, &slug)?,
189 None => build_default_icns(&resources, &slug)?,
190 });
191
192 fs::write(
193 contents.join("Info.plist"),
194 info_plist(meta, &slug, icon_file.as_deref()),
195 )?;
196
197 report_export(&meta.display_name, &app_dir, blobs);
198
199 let stem = artifact_stem(meta, platform_tag(std::env::consts::OS));
200 if make_zip {
201 let zip_path = out_dir.join(format!("{stem}.zip"));
202 let exe_rel = format!("Contents/MacOS/{slug}");
203 zip_tree(&app_dir, &format!("{slug}.app"), &exe_rel, &zip_path)?;
204 println!("Wrote {}", zip_path.display());
205 }
206 if make_dmg {
207 let dmg_path = out_dir.join(format!("{stem}.dmg"));
208 build_dmg(&app_dir, &slug, &meta.display_name, &dmg_path)?;
209 println!("Wrote {}", dmg_path.display());
210 }
211 Ok(())
212}
213
214#[cfg(any(backend_dx, backend_vk))]
227fn precompile_shaders(state_dir: &Path) {
228 if cfg!(test) {
234 return;
235 }
236 println!("Compiling built-in shaders...");
237 let report = concinnity_engine::precompile_builtin_shaders(state_dir);
238 println!(
239 " cached {} shader binaries ({} compiled, {} reused)",
240 report.cached(),
241 report.compiled,
242 report.reused
243 );
244 for failure in &report.failed {
245 eprintln!(
246 "warning: shader precompile failed ({failure}); it will compile on \
247 the bundle's first launch"
248 );
249 }
250}
251
252#[cfg(not(any(backend_dx, backend_vk)))]
254fn precompile_shaders(_state_dir: &Path) {}
255
256fn report_export(display_name: &str, bundle: &Path, blobs: usize) {
259 println!(
260 "Exported \"{}\" -> {} ({} blob{})",
261 display_name,
262 bundle.display(),
263 blobs,
264 if blobs == 1 { "" } else { "s" },
265 );
266}
267
268fn reset_dir(dir: &Path) -> io::Result<()> {
270 if dir.exists() {
271 fs::remove_dir_all(dir)?;
272 }
273 fs::create_dir_all(dir)
274}
275
276fn copy_blobs(data_src: &Path, data_dst: &Path) -> io::Result<usize> {
288 let blobs = blobs_in(data_src)?;
289 if blobs.is_empty() {
290 return Err(io::Error::new(
291 io::ErrorKind::NotFound,
292 format!("no compiled blobs in {}", data_src.display()),
293 ));
294 }
295 let _ = fs::remove_file(data_dst);
298 let _ = fs::remove_dir_all(data_dst);
299
300 if let [(_, only)] = blobs.as_slice() {
301 if let Some(parent) = data_dst.parent() {
302 fs::create_dir_all(parent)?;
303 }
304 fs::copy(only, data_dst)?;
305 return Ok(1);
306 }
307
308 fs::create_dir_all(data_dst)?;
309 for (index, path) in &blobs {
310 fs::copy(path, data_dst.join(index.to_string()))?;
311 }
312 Ok(blobs.len())
313}
314
315fn blobs_in(dir: &Path) -> io::Result<Vec<(u32, PathBuf)>> {
317 let mut blobs = Vec::new();
318 for entry in fs::read_dir(dir)? {
319 let entry = entry?;
320 let file_name = entry.file_name();
321 let name = file_name.to_string_lossy();
322 if let Some(index) = blob_index(&name) {
323 blobs.push((index, entry.path()));
324 }
325 }
326 blobs.sort_by_key(|(index, _)| *index);
327 Ok(blobs)
328}
329
330fn blob_index(name: &str) -> Option<u32> {
333 if name.is_empty() || !name.bytes().all(|b| b.is_ascii_digit()) {
334 return None;
335 }
336 name.parse().ok()
337}
338
339fn check_target_platform(platform: Option<&str>) -> io::Result<()> {
344 let Some(requested) = platform else {
345 return Ok(());
346 };
347 let host = std::env::consts::OS;
348 if normalize_platform(requested) == host {
349 return Ok(());
350 }
351 Err(io::Error::new(
352 io::ErrorKind::Unsupported,
353 format!(
354 "cross-platform export is not supported yet: this `cn` targets '{host}'. \
355 Run `cn export` on a {requested} machine to produce a {requested} build."
356 ),
357 ))
358}
359
360fn normalize_platform(p: &str) -> &str {
361 match p.to_lowercase().as_str() {
362 "mac" | "macos" | "osx" | "darwin" => "macos",
363 "win" | "windows" => "windows",
364 "linux" => "linux",
365 _ => "",
367 }
368}
369
370fn runtime_binary_path() -> io::Result<PathBuf> {
372 let cn = std::env::current_exe()?;
373 let dir = cn
374 .parent()
375 .ok_or_else(|| io::Error::other("cannot locate the cn executable's directory"))?;
376 let path = dir.join(exe_file_name("concinnity-run"));
377 if path.exists() {
378 Ok(path)
379 } else {
380 Err(io::Error::new(
381 io::ErrorKind::NotFound,
382 format!(
383 "runtime player not found at {} -- the `concinnity-run` binary must sit \
384 beside the `cn` executable (in a dev checkout, build it with \
385 `cargo build --features player`)",
386 path.display()
387 ),
388 ))
389 }
390}
391
392const RUNTIME_PLATFORM_MARKER: &[u8] = b"cn-runtime-platform:";
395
396fn read_runtime_platform(runtime: &Path) -> io::Result<Option<String>> {
400 let bytes = fs::read(runtime)?;
401 let found = find_platform_stamp(&bytes);
402 if found.is_none() {
403 eprintln!(
404 "warning: no backend stamp found in {}; skipping the runtime/cook \
405 backend check (rebuild `concinnity-run` to enable it)",
406 runtime.display()
407 );
408 }
409 Ok(found)
410}
411
412fn verify_runtime_backend(
420 found: Option<&str>,
421 cooked: concinnity_cook::platform::Platform,
422) -> io::Result<()> {
423 let expected = cooked.key();
424 match found {
425 None => Ok(()),
428 Some(found) if found == expected => Ok(()),
429 Some(found) => Err(io::Error::new(
430 io::ErrorKind::InvalidData,
431 format!(
432 "runtime/cook backend mismatch: this `cn` cooks {} blobs, but the \
433 `concinnity-run` player beside it was built for {}. Rebuild the \
434 runtime for the same backend (`cargo build --features player{}`) \
435 before exporting.",
436 backend_label(expected),
437 backend_label(found),
438 feature_hint(expected),
439 ),
440 )),
441 }
442}
443
444fn find_platform_stamp(bytes: &[u8]) -> Option<String> {
448 let start = find_subslice(bytes, RUNTIME_PLATFORM_MARKER)? + RUNTIME_PLATFORM_MARKER.len();
449 let rest = &bytes[start..];
450 let end = rest
451 .iter()
452 .position(|b| !b.is_ascii_lowercase())
453 .unwrap_or(rest.len());
454 let token = &rest[..end];
455 if token.is_empty() {
456 None
457 } else {
458 std::str::from_utf8(token).ok().map(str::to_string)
459 }
460}
461
462fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
464 if needle.is_empty() || haystack.len() < needle.len() {
465 return None;
466 }
467 haystack.windows(needle.len()).position(|w| w == needle)
468}
469
470fn backend_label(platform_key: &str) -> &str {
472 match platform_key {
473 "metal" => "Metal (metallib)",
474 "hlsl" => "DirectX (DXBC)",
475 "glsl" => "Vulkan (SPIR-V)",
476 other => other,
477 }
478}
479
480fn feature_hint(platform_key: &str) -> &str {
485 match platform_key {
486 "glsl" => ",vulkan",
487 _ => "",
488 }
489}
490
491fn exe_file_name(stem: &str) -> String {
493 if cfg!(windows) {
494 format!("{stem}.exe")
495 } else {
496 stem.to_string()
497 }
498}
499
500#[cfg(unix)]
501fn make_executable(path: &Path) -> io::Result<()> {
502 use std::os::unix::fs::PermissionsExt;
503 let mut perms = fs::metadata(path)?.permissions();
504 perms.set_mode(0o755);
505 fs::set_permissions(path, perms)
506}
507
508#[cfg(not(unix))]
509fn make_executable(_path: &Path) -> io::Result<()> {
510 Ok(())
511}
512
513fn read_app_meta(
516 cli_name: Option<&str>,
517 cli_version: Option<&str>,
518 assets: &[WorldJsonlAsset],
519) -> AppMeta {
520 let display_name = resolve_display_name(cli_name, assets);
521 let identifier =
522 string_arg(assets, "appconfig", "id").unwrap_or_else(|| derive_identifier(&display_name));
523 let version = cli_version
524 .map(str::trim)
525 .filter(|s| !s.is_empty())
526 .map(str::to_string)
527 .or_else(|| string_arg(assets, "appconfig", "version"))
528 .unwrap_or_else(|| "0.1.0".to_string());
529 let icon = string_arg(assets, "appconfig", "icon").map(PathBuf::from);
530 AppMeta {
531 display_name,
532 identifier,
533 version,
534 icon,
535 }
536}
537
538fn resolve_display_name(cli_name: Option<&str>, assets: &[WorldJsonlAsset]) -> String {
541 if let Some(n) = cli_name.map(str::trim).filter(|s| !s.is_empty()) {
542 return n.to_string();
543 }
544 if let Some(n) = string_arg(assets, "appconfig", "name") {
545 return n;
546 }
547 if let Some(n) = string_arg(assets, "mainmenu", "title") {
548 return n;
549 }
550 "Concinnity".to_string()
551}
552
553fn derive_identifier(name: &str) -> String {
557 let mut comp = String::new();
558 let mut pending_dash = false;
559 for c in name.chars() {
560 if c.is_ascii_alphanumeric() {
561 if pending_dash && !comp.is_empty() {
562 comp.push('-');
563 }
564 pending_dash = false;
565 comp.push(c.to_ascii_lowercase());
566 } else {
567 pending_dash = true;
568 }
569 }
570 let comp = comp.trim_matches('-');
571 if comp.is_empty() {
572 "gg.concinnity.app".to_string()
573 } else {
574 format!("gg.concinnity.{comp}")
575 }
576}
577
578fn string_arg(assets: &[WorldJsonlAsset], type_norm: &str, key: &str) -> Option<String> {
581 assets
582 .iter()
583 .find(|a| normalize_type(&a.asset_type) == type_norm)
584 .and_then(|a| a.args.get(key))
585 .and_then(|v| v.as_str())
586 .map(str::trim)
587 .filter(|s| !s.is_empty())
588 .map(str::to_string)
589}
590
591fn normalize_type(t: &str) -> String {
592 t.to_lowercase().replace('_', "")
593}
594
595fn slug(name: &str) -> String {
599 let mut out = String::new();
600 let mut pending_dash = false;
601 for c in name.chars() {
602 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
603 if pending_dash && !out.is_empty() {
604 out.push('-');
605 }
606 pending_dash = false;
607 out.push(c);
608 } else {
609 pending_dash = true;
610 }
611 }
612 let trimmed = out.trim_matches('-');
613 if trimmed.is_empty() {
614 "app".to_string()
615 } else {
616 trimmed.to_string()
617 }
618}
619
620fn platform_tag(os: &str) -> &str {
624 match os {
625 "macos" => "mac",
626 "windows" => "win",
627 "linux" => "linux",
628 other => other,
629 }
630}
631
632fn artifact_stem(meta: &AppMeta, platform: &str) -> String {
637 format!(
638 "{}-{}-{}",
639 slug(&meta.display_name),
640 slug(&meta.version),
641 platform
642 )
643}
644
645fn info_plist(meta: &AppMeta, exe_name: &str, icon_file: Option<&str>) -> String {
649 let icon_entry = match icon_file {
650 Some(icon) => format!(
651 "\t<key>CFBundleIconFile</key>\n\t<string>{}</string>\n",
652 xml_escape(icon)
653 ),
654 None => String::new(),
655 };
656 format!(
657 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
658 <!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
659 \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
660 <plist version=\"1.0\">\n\
661 <dict>\n\
662 \t<key>CFBundleName</key>\n\t<string>{name}</string>\n\
663 \t<key>CFBundleDisplayName</key>\n\t<string>{name}</string>\n\
664 \t<key>CFBundleExecutable</key>\n\t<string>{exe}</string>\n\
665 \t<key>CFBundleIdentifier</key>\n\t<string>{id}</string>\n\
666 \t<key>CFBundleVersion</key>\n\t<string>{ver}</string>\n\
667 \t<key>CFBundleShortVersionString</key>\n\t<string>{ver}</string>\n\
668 \t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\
669 \t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\
670 {icon}\
671 \t<key>LSMinimumSystemVersion</key>\n\t<string>11.0</string>\n\
672 \t<key>NSHighResolutionCapable</key>\n\t<true/>\n\
673 \t<key>NSPrincipalClass</key>\n\t<string>NSApplication</string>\n\
674 </dict>\n\
675 </plist>\n",
676 name = xml_escape(&meta.display_name),
677 exe = xml_escape(exe_name),
678 id = xml_escape(&meta.identifier),
679 ver = xml_escape(&meta.version),
680 icon = icon_entry,
681 )
682}
683
684fn xml_escape(s: &str) -> String {
685 s.replace('&', "&")
686 .replace('<', "<")
687 .replace('>', ">")
688 .replace('"', """)
689 .replace('\'', "'")
690}
691
692fn build_icns(src: &Path, resources: &Path, slug: &str) -> io::Result<String> {
696 if !src.exists() {
697 return Err(io::Error::new(
698 io::ErrorKind::NotFound,
699 format!("AppConfig icon not found: {}", src.display()),
700 ));
701 }
702 let iconset = std::env::temp_dir().join(format!("cn-export-{slug}.iconset"));
703 reset_dir(&iconset)?;
704
705 for size in [16u32, 32, 128, 256, 512] {
707 for scale in [1u32, 2] {
708 let px = size * scale;
709 let suffix = if scale == 2 { "@2x" } else { "" };
710 let dst = iconset.join(format!("icon_{size}x{size}{suffix}.png"));
711 run_tool(
712 "sips",
713 &[
714 "-z",
715 &px.to_string(),
716 &px.to_string(),
717 &src.to_string_lossy(),
718 "--out",
719 &dst.to_string_lossy(),
720 ],
721 )?;
722 }
723 }
724
725 let icns_name = format!("{slug}.icns");
726 let icns_path = resources.join(&icns_name);
727 run_tool(
728 "iconutil",
729 &[
730 "-c",
731 "icns",
732 &iconset.to_string_lossy(),
733 "-o",
734 &icns_path.to_string_lossy(),
735 ],
736 )?;
737 let _ = fs::remove_dir_all(&iconset);
738 Ok(icns_name)
739}
740
741const DEFAULT_ICON_PNG: &[u8] = include_bytes!("../assets/default-icon.png");
745
746fn build_default_icns(resources: &Path, slug: &str) -> io::Result<String> {
750 let tmp = std::env::temp_dir().join(format!("cn-default-icon-{slug}.png"));
751 fs::write(&tmp, DEFAULT_ICON_PNG)?;
752 let result = build_icns(&tmp, resources, slug);
753 let _ = fs::remove_file(&tmp);
754 result
755}
756
757fn build_dmg(app_dir: &Path, slug: &str, volume_name: &str, dmg_path: &Path) -> io::Result<()> {
761 let staging = std::env::temp_dir().join(format!("cn-export-{slug}-dmg"));
762 reset_dir(&staging)?;
763 copy_tree(app_dir, &staging.join(format!("{slug}.app")))?;
764
765 if dmg_path.exists() {
766 fs::remove_file(dmg_path)?;
767 }
768 run_tool(
769 "hdiutil",
770 &[
771 "create",
772 "-volname",
773 volume_name,
774 "-srcfolder",
775 &staging.to_string_lossy(),
776 "-ov",
777 "-format",
778 "UDZO",
779 &dmg_path.to_string_lossy(),
780 ],
781 )?;
782 let _ = fs::remove_dir_all(&staging);
783 Ok(())
784}
785
786fn run_tool(program: &str, args: &[&str]) -> io::Result<()> {
789 let output = std::process::Command::new(program)
790 .args(args)
791 .output()
792 .map_err(|e| io::Error::new(e.kind(), format!("failed to run `{program}`: {e}")))?;
793 if output.status.success() {
794 Ok(())
795 } else {
796 Err(io::Error::other(format!(
797 "`{program}` failed: {}",
798 String::from_utf8_lossy(&output.stderr).trim()
799 )))
800 }
801}
802
803fn copy_runtime_sidecars(
818 runtime: &Path,
819 runtime_platform: Option<&str>,
820 dest_dir: &Path,
821) -> io::Result<()> {
822 let Some(src_dir) = runtime.parent() else {
823 return Ok(());
824 };
825 for entry in fs::read_dir(src_dir)? {
828 let path = entry?.path();
829 let is_dll = path
830 .extension()
831 .and_then(|e| e.to_str())
832 .is_some_and(|e| e.eq_ignore_ascii_case("dll"));
833 if is_dll
834 && path.is_file()
835 && let Some(name) = path.file_name()
836 {
837 fs::copy(&path, dest_dir.join(name))?;
838 }
839 }
840 if runtime_wants_d3d12(runtime_platform) {
843 let d3d12 = src_dir.join("D3D12");
844 if d3d12.is_dir() {
845 copy_tree(&d3d12, &dest_dir.join("D3D12"))?;
846 }
847 }
848 Ok(())
849}
850
851fn runtime_wants_d3d12(runtime_platform: Option<&str>) -> bool {
856 !matches!(runtime_platform, Some("glsl") | Some("metal"))
857}
858
859fn copy_tree(src: &Path, dst: &Path) -> io::Result<()> {
861 fs::create_dir_all(dst)?;
862 for entry in fs::read_dir(src)? {
863 let entry = entry?;
864 let from = entry.path();
865 let to = dst.join(entry.file_name());
866 if from.is_dir() {
867 copy_tree(&from, &to)?;
868 } else {
869 fs::copy(&from, &to)?;
870 }
871 }
872 Ok(())
873}
874
875fn zip_tree(src_dir: &Path, top: &str, exe_rel: &str, zip_path: &Path) -> io::Result<()> {
880 use std::io::Write;
881 use zip::write::SimpleFileOptions;
882
883 let mut files = Vec::new();
884 collect_files(src_dir, &mut files)?;
885 files.sort();
886
887 let file = fs::File::create(zip_path)?;
888 let mut zw = zip::ZipWriter::new(file);
889 for path in files {
890 let rel = path
891 .strip_prefix(src_dir)
892 .map_err(io::Error::other)?
893 .to_string_lossy()
894 .replace('\\', "/");
895 let mode = if rel == exe_rel { 0o755 } else { 0o644 };
896 let options = SimpleFileOptions::default()
897 .compression_method(zip::CompressionMethod::Deflated)
898 .unix_permissions(mode);
899 zw.start_file(format!("{top}/{rel}"), options)
900 .map_err(io::Error::other)?;
901 let bytes = fs::read(&path)?;
902 zw.write_all(&bytes)?;
903 }
904 zw.finish().map_err(io::Error::other)?;
905 Ok(())
906}
907
908fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
910 for entry in fs::read_dir(dir)? {
911 let path = entry?.path();
912 if path.is_dir() {
913 collect_files(&path, out)?;
914 } else {
915 out.push(path);
916 }
917 }
918 Ok(())
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 fn asset(name: &str, ty: &str, args: serde_json::Value) -> WorldJsonlAsset {
926 WorldJsonlAsset {
927 name: name.to_string(),
928 asset_type: ty.to_string(),
929 args,
930 }
931 }
932
933 #[test]
934 fn slug_is_filesystem_safe() {
935 assert_eq!(slug("My Game"), "My-Game");
936 assert_eq!(slug(" Spaced Out "), "Spaced-Out");
937 assert_eq!(slug("weird:/name*?"), "weird-name");
938 assert_eq!(slug("keep_dots.and-dashes"), "keep_dots.and-dashes");
939 assert_eq!(slug("***"), "app");
940 assert_eq!(slug(""), "app");
941 }
942
943 #[test]
944 fn blob_index_matches_only_integer_files() {
945 assert_eq!(blob_index("0"), Some(0));
946 assert_eq!(blob_index("42"), Some(42));
947 assert_eq!(blob_index(""), None);
948 assert_eq!(blob_index("0.metallib"), None);
949 assert_eq!(blob_index("default_vertex_shader.air"), None);
950 assert_eq!(blob_index("settings"), None);
951 }
952
953 #[test]
954 fn name_precedence_is_cli_then_app_config_then_menu_then_default() {
955 let app = asset("app", "AppConfig", serde_json::json!({"name": "App Name"}));
956 let menu = asset("m", "MainMenu", serde_json::json!({"title": "Menu Title"}));
957
958 assert_eq!(
959 resolve_display_name(Some("CLI Name"), &[app.clone(), menu.clone()]),
960 "CLI Name"
961 );
962 assert_eq!(
963 resolve_display_name(None, &[app.clone(), menu.clone()]),
964 "App Name"
965 );
966 assert_eq!(
967 resolve_display_name(None, std::slice::from_ref(&menu)),
968 "Menu Title"
969 );
970 assert_eq!(resolve_display_name(None, &[]), "Concinnity");
971 assert_eq!(resolve_display_name(Some(" "), &[app]), "App Name");
972 }
973
974 #[test]
975 fn normalize_platform_accepts_aliases() {
976 assert_eq!(normalize_platform("macOS"), "macos");
977 assert_eq!(normalize_platform("Darwin"), "macos");
978 assert_eq!(normalize_platform("win"), "windows");
979 assert_eq!(normalize_platform("Linux"), "linux");
980 assert_eq!(normalize_platform("solaris"), "");
981 }
982
983 #[test]
984 fn host_platform_is_accepted_and_others_rejected() {
985 check_target_platform(None).unwrap();
986 check_target_platform(Some(std::env::consts::OS)).unwrap();
987 let foreign = if std::env::consts::OS == "windows" {
988 "linux"
989 } else {
990 "windows"
991 };
992 assert!(check_target_platform(Some(foreign)).is_err());
993 }
994
995 #[test]
996 fn app_meta_derives_id_and_version_defaults() {
997 let meta = read_app_meta(Some("My Cool App"), None, &[]);
999 assert_eq!(meta.display_name, "My Cool App");
1000 assert_eq!(meta.identifier, "gg.concinnity.my-cool-app");
1001 assert_eq!(meta.version, "0.1.0");
1002 assert!(meta.icon.is_none());
1003
1004 let app = asset(
1006 "app",
1007 "AppConfig",
1008 serde_json::json!({
1009 "name": "Named", "id": "gg.studio.thing", "version": "2.3.4", "icon": "art/i.png"
1010 }),
1011 );
1012 let meta = read_app_meta(None, None, std::slice::from_ref(&app));
1013 assert_eq!(meta.display_name, "Named");
1014 assert_eq!(meta.identifier, "gg.studio.thing");
1015 assert_eq!(meta.version, "2.3.4");
1016 assert_eq!(meta.icon.as_deref(), Some(Path::new("art/i.png")));
1017 }
1018
1019 #[test]
1020 fn version_precedence_is_cli_then_application_then_default() {
1021 let app = asset("app", "AppConfig", serde_json::json!({"version": "2.3.4"}));
1022
1023 assert_eq!(
1025 read_app_meta(None, Some("9.9.9"), std::slice::from_ref(&app)).version,
1026 "9.9.9"
1027 );
1028 assert_eq!(
1030 read_app_meta(None, Some(" "), std::slice::from_ref(&app)).version,
1031 "2.3.4"
1032 );
1033 assert_eq!(read_app_meta(None, Some("3.0"), &[]).version, "3.0");
1035 assert_eq!(read_app_meta(None, None, &[]).version, "0.1.0");
1037 }
1038
1039 #[test]
1040 fn platform_tag_maps_host_os() {
1041 assert_eq!(platform_tag("macos"), "mac");
1042 assert_eq!(platform_tag("windows"), "win");
1043 assert_eq!(platform_tag("linux"), "linux");
1044 assert_eq!(platform_tag("freebsd"), "freebsd");
1046 }
1047
1048 #[test]
1049 fn artifact_stem_is_slug_version_platform() {
1050 let meta = AppMeta {
1051 display_name: "My Game".to_string(),
1052 identifier: "gg.studio.mg".to_string(),
1053 version: "1.0.0".to_string(),
1054 icon: None,
1055 };
1056 assert_eq!(artifact_stem(&meta, "mac"), "My-Game-1.0.0-mac");
1057 let meta = AppMeta {
1059 version: "1.0.0+build 7".to_string(),
1060 ..meta
1061 };
1062 assert_eq!(artifact_stem(&meta, "win"), "My-Game-1.0.0-build-7-win");
1063 }
1064
1065 #[test]
1066 fn info_plist_has_required_keys_and_escapes() {
1067 let meta = AppMeta {
1068 display_name: "Tom & Jerry".to_string(),
1069 identifier: "gg.studio.tj".to_string(),
1070 version: "1.0".to_string(),
1071 icon: None,
1072 };
1073 let plist = info_plist(&meta, "tj", Some("tj.icns"));
1074 assert!(plist.contains("<key>CFBundleExecutable</key>\n\t<string>tj</string>"));
1075 assert!(plist.contains("<key>CFBundleIdentifier</key>\n\t<string>gg.studio.tj</string>"));
1076 assert!(plist.contains("<key>CFBundleShortVersionString</key>\n\t<string>1.0</string>"));
1077 assert!(plist.contains("<key>CFBundleIconFile</key>\n\t<string>tj.icns</string>"));
1078 assert!(plist.contains("Tom & Jerry"));
1080 assert!(!plist.contains("Tom & Jerry"));
1081
1082 let plist = info_plist(&meta, "tj", None);
1084 assert!(!plist.contains("CFBundleIconFile"));
1085 }
1086
1087 #[test]
1088 fn copy_runtime_sidecars_copies_dlls_and_the_d3d12_dir() {
1089 let tmp = tempfile::tempdir().unwrap();
1092 let src = tmp.path().join("src");
1093 fs::create_dir_all(src.join("D3D12")).unwrap();
1094 fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
1095 fs::write(src.join("amd_fidelityfx_dx12.dll"), b"x").unwrap();
1096 fs::write(src.join("libconcinnity_ffi.dll"), b"x").unwrap();
1097 fs::write(src.join("notes.txt"), b"x").unwrap();
1098 fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
1099
1100 let dest = tmp.path().join("dest");
1101 fs::create_dir_all(&dest).unwrap();
1102 copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("hlsl"), &dest).unwrap();
1104
1105 assert!(dest.join("amd_fidelityfx_dx12.dll").exists());
1106 assert!(dest.join("libconcinnity_ffi.dll").exists());
1107 assert!(dest.join("D3D12").join("D3D12Core.dll").exists());
1108 assert!(!dest.join("notes.txt").exists());
1110 assert!(!dest.join("concinnity-run.exe").exists());
1111 }
1112
1113 #[test]
1114 fn copy_runtime_sidecars_skips_d3d12_for_a_non_dx_runtime() {
1115 let tmp = tempfile::tempdir().unwrap();
1119 let src = tmp.path().join("src");
1120 fs::create_dir_all(src.join("D3D12")).unwrap();
1121 fs::write(src.join("concinnity-run.exe"), b"exe").unwrap();
1122 fs::write(src.join("amd_fidelityfx_vk.dll"), b"x").unwrap();
1123 fs::write(src.join("D3D12").join("D3D12Core.dll"), b"x").unwrap();
1124
1125 let dest = tmp.path().join("dest");
1126 fs::create_dir_all(&dest).unwrap();
1127 copy_runtime_sidecars(&src.join("concinnity-run.exe"), Some("glsl"), &dest).unwrap();
1128
1129 assert!(dest.join("amd_fidelityfx_vk.dll").exists());
1130 assert!(!dest.join("D3D12").exists());
1131 }
1132
1133 #[test]
1134 fn runtime_wants_d3d12_only_for_dx_or_unknown() {
1135 assert!(runtime_wants_d3d12(Some("hlsl")));
1136 assert!(runtime_wants_d3d12(None));
1138 assert!(!runtime_wants_d3d12(Some("glsl")));
1139 assert!(!runtime_wants_d3d12(Some("metal")));
1140 }
1141
1142 #[test]
1143 fn find_platform_stamp_reads_the_token_after_the_marker() {
1144 let mut buf = vec![0xAAu8, 0x00, 0xFF, b'x'];
1147 buf.extend_from_slice(b"cn-runtime-platform:hlsl\0");
1148 buf.extend_from_slice(&[0x01, 0x02, 0x03]);
1149 assert_eq!(find_platform_stamp(&buf).as_deref(), Some("hlsl"));
1150
1151 for token in ["metal", "hlsl", "glsl"] {
1153 let stamp = format!("cn-runtime-platform:{token}\0");
1154 assert_eq!(
1155 find_platform_stamp(stamp.as_bytes()).as_deref(),
1156 Some(token)
1157 );
1158 }
1159 }
1160
1161 #[test]
1162 fn find_platform_stamp_is_none_without_the_marker() {
1163 assert_eq!(find_platform_stamp(b"no stamp here"), None);
1164 assert_eq!(find_platform_stamp(b""), None);
1165 assert_eq!(find_platform_stamp(b"cn-runtime-platform:\0"), None);
1167 }
1168
1169 #[test]
1170 fn find_subslice_locates_and_reports_absence() {
1171 assert_eq!(find_subslice(b"abcdef", b"cd"), Some(2));
1172 assert_eq!(find_subslice(b"abcdef", b"abc"), Some(0));
1173 assert_eq!(find_subslice(b"abcdef", b"xy"), None);
1174 assert_eq!(find_subslice(b"ab", b"abc"), None);
1175 assert_eq!(find_subslice(b"abc", b""), None);
1176 }
1177
1178 #[test]
1179 fn backend_label_and_feature_hint_cover_each_platform() {
1180 assert_eq!(backend_label("metal"), "Metal (metallib)");
1181 assert_eq!(backend_label("hlsl"), "DirectX (DXBC)");
1182 assert_eq!(backend_label("glsl"), "Vulkan (SPIR-V)");
1183 assert_eq!(feature_hint("glsl"), ",vulkan");
1184 assert_eq!(feature_hint("hlsl"), "");
1185 assert_eq!(feature_hint("metal"), "");
1186 }
1187
1188 #[test]
1189 fn default_icon_is_a_nonempty_png() {
1190 assert!(DEFAULT_ICON_PNG.len() > 1024);
1194 assert_eq!(&DEFAULT_ICON_PNG[..8], b"\x89PNG\r\n\x1a\n");
1195 }
1196
1197 #[test]
1198 fn copy_runtime_sidecars_is_a_noop_without_dlls() {
1199 let tmp = tempfile::tempdir().unwrap();
1201 let src = tmp.path().join("src");
1202 fs::create_dir_all(&src).unwrap();
1203 fs::write(src.join("concinnity-run"), b"exe").unwrap();
1204 fs::write(src.join("libconcinnity_ffi.dylib"), b"x").unwrap();
1205
1206 let dest = tmp.path().join("dest");
1207 fs::create_dir_all(&dest).unwrap();
1208 copy_runtime_sidecars(&src.join("concinnity-run"), Some("metal"), &dest).unwrap();
1209 assert_eq!(fs::read_dir(&dest).unwrap().count(), 0);
1210 }
1211
1212 #[test]
1213 fn export_rejects_an_unknown_format_up_front() {
1214 let err = export(None, None, None, None, "out", "tarball", false).unwrap_err();
1217 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1218 assert!(err.to_string().contains("tarball"), "got: {err}");
1219 }
1220
1221 #[cfg(not(target_os = "macos"))]
1222 #[test]
1223 fn export_rejects_dmg_off_macos() {
1224 let err = export(None, None, None, None, "out", "zip", true).unwrap_err();
1225 assert_eq!(err.kind(), io::ErrorKind::Unsupported);
1226 }
1227
1228 #[test]
1229 fn derive_identifier_reduces_names_to_bundle_id_form() {
1230 assert_eq!(derive_identifier("My Game"), "gg.concinnity.my-game");
1231 assert_eq!(
1232 derive_identifier("Space: Above!"),
1233 "gg.concinnity.space-above"
1234 );
1235 assert_eq!(derive_identifier("***"), "gg.concinnity.app");
1236 assert_eq!(derive_identifier(""), "gg.concinnity.app");
1237 }
1238
1239 #[test]
1240 fn xml_escape_escapes_every_entity() {
1241 assert_eq!(
1242 xml_escape(r#"<a href="x">Tom & Jerry's</a>"#),
1243 "<a href="x">Tom & Jerry's</a>"
1244 );
1245 assert_eq!(xml_escape("plain"), "plain");
1246 }
1247
1248 #[test]
1249 fn verify_runtime_backend_accepts_matching_or_unstamped() {
1250 let cooked = concinnity_cook::platform::Platform::Metal;
1251 verify_runtime_backend(None, cooked).unwrap();
1252 verify_runtime_backend(Some(cooked.key()), cooked).unwrap();
1253
1254 let err = verify_runtime_backend(Some("hlsl"), cooked).unwrap_err();
1255 assert_eq!(err.kind(), io::ErrorKind::InvalidData);
1256 assert!(err.to_string().contains("mismatch"), "got: {err}");
1257 }
1258
1259 #[test]
1260 fn read_runtime_platform_reads_a_stamp_or_warns_none() {
1261 let tmp = tempfile::tempdir().unwrap();
1262 let stamped = tmp.path().join("stamped");
1263 fs::write(&stamped, b"junk cn-runtime-platform:metal\0 junk").unwrap();
1264 assert_eq!(
1265 read_runtime_platform(&stamped).unwrap().as_deref(),
1266 Some("metal")
1267 );
1268
1269 let unstamped = tmp.path().join("unstamped");
1270 fs::write(&unstamped, b"no marker in here").unwrap();
1271 assert_eq!(read_runtime_platform(&unstamped).unwrap(), None);
1272 }
1273
1274 #[test]
1275 fn copy_blobs_takes_only_integer_named_files() {
1276 let tmp = tempfile::tempdir().unwrap();
1277 let src = tmp.path().join("data");
1278 fs::create_dir_all(&src).unwrap();
1279 fs::write(src.join("0"), b"blob0").unwrap();
1280 fs::write(src.join("12"), b"blob12").unwrap();
1281 fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1282 fs::write(src.join("settings"), b"state").unwrap();
1283
1284 let dst = tmp.path().join("out");
1285 let count = copy_blobs(&src, &dst).unwrap();
1286 assert_eq!(count, 2);
1287 assert!(dst.is_dir(), "an overflowing world ships a directory");
1288 assert_eq!(fs::read(dst.join("0")).unwrap(), b"blob0");
1289 assert_eq!(fs::read(dst.join("12")).unwrap(), b"blob12");
1290 assert!(!dst.join("default_vert.air").exists());
1291 assert!(!dst.join("settings").exists());
1292 }
1293
1294 #[test]
1297 fn a_single_blob_world_ships_as_one_file() {
1298 let tmp = tempfile::tempdir().unwrap();
1299 let src = tmp.path().join("data");
1300 fs::create_dir_all(&src).unwrap();
1301 fs::write(src.join("0"), b"blob0").unwrap();
1302 fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1303
1304 let dst = tmp.path().join("bundle").join("data");
1305 assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
1306 assert!(dst.is_file(), "one blob ships as the `data` file itself");
1307 assert_eq!(fs::read(&dst).unwrap(), b"blob0");
1308 }
1309
1310 #[test]
1314 fn re_exporting_across_the_form_boundary_replaces_the_previous_shape() {
1315 let tmp = tempfile::tempdir().unwrap();
1316 let src = tmp.path().join("data");
1317 fs::create_dir_all(&src).unwrap();
1318 fs::write(src.join("0"), b"blob0").unwrap();
1319 fs::write(src.join("1"), b"blob1").unwrap();
1320 let dst = tmp.path().join("bundle").join("data");
1321
1322 assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
1323 assert!(dst.is_dir());
1324
1325 fs::remove_file(src.join("1")).unwrap();
1327 assert_eq!(copy_blobs(&src, &dst).unwrap(), 1);
1328 assert!(dst.is_file());
1329 assert_eq!(fs::read(&dst).unwrap(), b"blob0");
1330
1331 fs::write(src.join("1"), b"blob1").unwrap();
1333 assert_eq!(copy_blobs(&src, &dst).unwrap(), 2);
1334 assert!(dst.is_dir());
1335 assert_eq!(fs::read(dst.join("1")).unwrap(), b"blob1");
1336 }
1337
1338 #[test]
1341 fn copy_blobs_refuses_a_data_dir_with_no_blobs() {
1342 let tmp = tempfile::tempdir().unwrap();
1343 let src = tmp.path().join("data");
1344 fs::create_dir_all(&src).unwrap();
1345 fs::write(src.join("default_vert.air"), b"scratch").unwrap();
1346
1347 let err = copy_blobs(&src, &tmp.path().join("out")).unwrap_err();
1348 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1349 }
1350
1351 #[test]
1352 fn reset_dir_clears_previous_content() {
1353 let tmp = tempfile::tempdir().unwrap();
1354 let dir = tmp.path().join("bundle");
1355 fs::create_dir_all(dir.join("nested")).unwrap();
1356 fs::write(dir.join("nested").join("stale"), b"old").unwrap();
1357
1358 reset_dir(&dir).unwrap();
1359 assert!(dir.exists());
1360 assert_eq!(fs::read_dir(&dir).unwrap().count(), 0);
1361 }
1362
1363 #[test]
1364 fn copy_tree_copies_nested_directories() {
1365 let tmp = tempfile::tempdir().unwrap();
1366 let src = tmp.path().join("src");
1367 fs::create_dir_all(src.join("a").join("b")).unwrap();
1368 fs::write(src.join("top.txt"), b"1").unwrap();
1369 fs::write(src.join("a").join("b").join("deep.txt"), b"2").unwrap();
1370
1371 let dst = tmp.path().join("dst");
1372 copy_tree(&src, &dst).unwrap();
1373 assert_eq!(fs::read(dst.join("top.txt")).unwrap(), b"1");
1374 assert_eq!(
1375 fs::read(dst.join("a").join("b").join("deep.txt")).unwrap(),
1376 b"2"
1377 );
1378 }
1379
1380 #[test]
1381 fn collect_files_walks_the_whole_tree() {
1382 let tmp = tempfile::tempdir().unwrap();
1383 let dir = tmp.path().join("tree");
1384 fs::create_dir_all(dir.join("sub")).unwrap();
1385 fs::write(dir.join("a"), b"1").unwrap();
1386 fs::write(dir.join("sub").join("b"), b"2").unwrap();
1387
1388 let mut files = Vec::new();
1389 collect_files(&dir, &mut files).unwrap();
1390 files.sort();
1391 assert_eq!(files, vec![dir.join("a"), dir.join("sub").join("b")]);
1392 }
1393
1394 #[test]
1395 fn zip_tree_archives_under_a_top_folder() {
1396 let tmp = tempfile::tempdir().unwrap();
1397 let bundle = tmp.path().join("My-Game");
1398 fs::create_dir_all(bundle.join("data")).unwrap();
1399 fs::write(bundle.join("My-Game"), b"player").unwrap();
1400 fs::write(bundle.join("data").join("0"), b"blob").unwrap();
1401
1402 let zip_path = tmp.path().join("My-Game-1.0.0-mac.zip");
1403 zip_tree(&bundle, "My-Game", "My-Game", &zip_path).unwrap();
1404
1405 let file = fs::File::open(&zip_path).unwrap();
1406 let mut archive = zip::ZipArchive::new(file).unwrap();
1407 let names: Vec<String> = (0..archive.len())
1408 .map(|i| archive.by_index(i).unwrap().name().to_string())
1409 .collect();
1410 assert_eq!(archive.len(), 2);
1411 assert!(
1412 names.contains(&"My-Game/My-Game".to_string()),
1413 "got: {names:?}"
1414 );
1415 assert!(
1416 names.contains(&"My-Game/data/0".to_string()),
1417 "got: {names:?}"
1418 );
1419 let exe = archive.by_name("My-Game/My-Game").unwrap();
1421 assert_eq!(exe.unix_mode().map(|m| m & 0o777), Some(0o755));
1422 }
1423
1424 #[test]
1425 fn build_icns_rejects_a_missing_source_before_running_tools() {
1426 let tmp = tempfile::tempdir().unwrap();
1427 let err = build_icns(&tmp.path().join("missing.png"), tmp.path(), "slug").unwrap_err();
1428 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1429 }
1430
1431 #[test]
1432 fn run_tool_reports_success_failure_and_missing() {
1433 #[cfg(windows)]
1438 {
1439 run_tool("cmd", &["/C", "exit 0"]).unwrap();
1440 let err = run_tool("cmd", &["/C", "exit 1"]).unwrap_err();
1441 assert!(err.to_string().contains("cmd"), "got: {err}");
1442 }
1443 #[cfg(not(windows))]
1444 {
1445 run_tool("true", &[]).unwrap();
1446 let err = run_tool("false", &[]).unwrap_err();
1447 assert!(err.to_string().contains("false"), "got: {err}");
1448 }
1449 let err = run_tool("cn-nonexistent-tool-xyz", &[]).unwrap_err();
1454 assert!(
1455 err.to_string().contains("cn-nonexistent-tool-xyz"),
1456 "got: {err}"
1457 );
1458 }
1459
1460 #[cfg(unix)]
1461 #[test]
1462 fn make_executable_sets_the_exec_bit() {
1463 use std::os::unix::fs::PermissionsExt;
1464 let tmp = tempfile::tempdir().unwrap();
1465 let f = tmp.path().join("player");
1466 fs::write(&f, b"bin").unwrap();
1467 assert_eq!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
1469 make_executable(&f).unwrap();
1470 assert_ne!(fs::metadata(&f).unwrap().permissions().mode() & 0o111, 0);
1471 }
1472
1473 #[test]
1477 fn export_portable_assembles_folder_and_zip() {
1478 let tmp = tempfile::tempdir().unwrap();
1479 let data = tmp.path().join("data");
1480 fs::create_dir_all(&data).unwrap();
1481 fs::write(data.join("0"), b"blob0").unwrap();
1482 fs::write(data.join("1"), b"blob1").unwrap();
1483 fs::write(data.join("scratch.air"), b"ignored").unwrap();
1484
1485 let runtime = tmp.path().join(exe_file_name("concinnity-run"));
1486 fs::write(&runtime, b"runtime-bin").unwrap();
1487
1488 let out = tmp.path().join("out");
1489 fs::create_dir_all(&out).unwrap();
1490
1491 let meta = AppMeta {
1492 display_name: "My Game".to_string(),
1493 identifier: "gg.studio.mg".to_string(),
1494 version: "1.0.0".to_string(),
1495 icon: None,
1496 };
1497 export_portable(&meta, &runtime, Some("metal"), &out, &data, true).unwrap();
1498
1499 let bundle = out.join("My-Game");
1500 let exe = bundle.join(exe_file_name("My-Game"));
1501 assert!(exe.exists(), "renamed player missing");
1502 assert_eq!(fs::read(&exe).unwrap(), b"runtime-bin");
1503 assert!(bundle.join("data").join("0").exists());
1504 assert!(bundle.join("data").join("1").exists());
1505 assert!(!bundle.join("data").join("scratch.air").exists());
1507
1508 let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
1510 assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
1511 }
1512
1513 #[cfg(target_os = "macos")]
1517 #[test]
1518 fn export_macos_builds_app_bundle_with_default_icon() {
1519 let tmp = tempfile::tempdir().unwrap();
1520 let data = tmp.path().join("data");
1521 fs::create_dir_all(&data).unwrap();
1522 fs::write(data.join("0"), b"blob0").unwrap();
1523
1524 let runtime = tmp.path().join("concinnity-run");
1525 fs::write(&runtime, b"runtime-bin").unwrap();
1526
1527 let out = tmp.path().join("out");
1528 fs::create_dir_all(&out).unwrap();
1529
1530 let meta = AppMeta {
1531 display_name: "My Game".to_string(),
1532 identifier: "gg.studio.mg".to_string(),
1533 version: "1.0.0".to_string(),
1534 icon: None,
1535 };
1536 export_macos(&meta, &runtime, &out, &data, true, false).unwrap();
1539
1540 let app = out.join("My-Game.app");
1541 assert!(app.join("Contents/MacOS/My-Game").exists(), "exe missing");
1542 let data_entry = app.join("Contents/Resources/data");
1545 assert!(data_entry.is_file(), "blob missing");
1546 assert_eq!(fs::read(&data_entry).unwrap(), b"blob0");
1547 assert!(
1548 app.join("Contents/Resources/My-Game.icns").exists(),
1549 "icns missing"
1550 );
1551
1552 let plist = fs::read_to_string(app.join("Contents/Info.plist")).unwrap();
1553 assert!(plist.contains("<string>My-Game.icns</string>"));
1554 assert!(plist.contains("gg.studio.mg"));
1555
1556 let stem = artifact_stem(&meta, platform_tag(std::env::consts::OS));
1557 assert!(out.join(format!("{stem}.zip")).exists(), "zip missing");
1558 }
1559
1560 #[cfg(target_os = "macos")]
1563 #[test]
1564 fn build_icns_produces_an_icns_from_a_valid_png() {
1565 let tmp = tempfile::tempdir().unwrap();
1566 let src = tmp.path().join("icon.png");
1567 fs::write(&src, DEFAULT_ICON_PNG).unwrap();
1568 let resources = tmp.path().join("Resources");
1569 fs::create_dir_all(&resources).unwrap();
1570
1571 let name = build_icns(&src, &resources, "app").unwrap();
1572 assert_eq!(name, "app.icns");
1573 assert!(resources.join("app.icns").exists());
1574 }
1575
1576 #[test]
1577 fn runtime_binary_path_errors_when_the_player_is_absent() {
1578 let err = runtime_binary_path().unwrap_err();
1583 assert_eq!(err.kind(), io::ErrorKind::NotFound);
1584 assert!(
1585 err.to_string().contains("runtime player not found"),
1586 "got: {err}"
1587 );
1588 }
1589
1590 #[test]
1591 fn backend_label_passes_through_an_unknown_key() {
1592 assert_eq!(backend_label("wgpu"), "wgpu");
1595 }
1596}