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