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