diff --git a/CMakeLists.txt b/CMakeLists.txt index 0b094e7d9..7bb2fdb88 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,7 +22,7 @@ option(DAS_AUDIO_DISABLED "Disable dasAudio (Miniaudio sound library)" OFF) option(DAS_STDDLG_DISABLED "Disable dasStdDlg (File new,open,save etc dialogs)" OFF) option(DAS_STBIMAGE_DISABLED "Disable dasStbImage (StbImage bindings, image loading and saving)" OFF) -option(DAS_PUGIXML_DISABLED "Disable dasPUGIXML (xml parsing library)" ON) +option(DAS_PUGIXML_DISABLED "Disable dasPUGIXML (xml parsing library)" OFF) option(DAS_SQLITE_DISABLED "Disable dasSQLITE (sqlite3 library)" ON) option(DAS_TOOLS_DISABLED "Disable dasTools" OFF) option(DAS_TREE_SITTER_DISABLED "Disable tree-sitter-daslang grammar (no dependencies)" OFF) diff --git a/daslib/daspkg.das b/daslib/daspkg.das index a6df9c0d9..ead1f589b 100644 --- a/daslib/daspkg.das +++ b/daslib/daspkg.das @@ -139,6 +139,7 @@ struct ReleaseSpec { include_globs : array //!< asset globs to ship exclude_globs : array //!< asset globs to skip forced_modules : array //!< force-include shared module by das_name (auto-detection misses media-only or runtime-loaded packages) + bundle_id : string //!< macOS .app CFBundleIdentifier (reverse-DNS); defaults to com.. } var _release_spec : ReleaseSpec @@ -163,3 +164,7 @@ def release_shared_module(name : string) { _release_spec.forced_modules |> push(name) } +def release_bundle_id(id : string) { + _release_spec.bundle_id = id +} + diff --git a/daslib/module_path.das b/daslib/module_path.das index d9b1ead5f..3bf0305fc 100644 --- a/daslib/module_path.das +++ b/daslib/module_path.das @@ -32,8 +32,9 @@ module module_path shared public //! ```` is the substring of the call-site directory starting at //! the last ``/modules/`` segment (e.g. ``modules/dasCards/cards``). //! Tiers 1+2 are skipped when the call site has no ``/modules/`` segment -//! (project-local code outside the package layout); tier 3 is always -//! returned, so the result is never empty. +//! (project-local code outside the package layout); tier 3 then returns +//! the baked dir if it still exists on disk (dev) or ```` if it +//! doesn't (relocated bundle). The result is never empty. //! //! Usage:: //! diff --git a/include/daScript/simulate/aot_builtin.h b/include/daScript/simulate/aot_builtin.h index c6db5c976..f8c0275e0 100644 --- a/include/daScript/simulate/aot_builtin.h +++ b/include/daScript/simulate/aot_builtin.h @@ -20,6 +20,7 @@ namespace das { DAS_API uint64_t get_context_share_counter ( Context * context ); DAS_API char * builtin_das_root ( Context * context, LineInfoArg * at ); + DAS_API char * builtin_resolve_this_module_dir ( const char * baked_path, Context * context ); DAS_API char * builtin_get_das_version ( Context * context, LineInfoArg * at ); DAS_API void builtin_throw ( char * text, Context * context, LineInfoArg * at ); DAS_API void builtin_print ( char * text, Context * context, LineInfoArg * at ); diff --git a/modules/dasLLVM/daslib/llvm_exe.das b/modules/dasLLVM/daslib/llvm_exe.das index 9f02f6cea..63a1b98db 100644 --- a/modules/dasLLVM/daslib/llvm_exe.das +++ b/modules/dasLLVM/daslib/llvm_exe.das @@ -1028,9 +1028,10 @@ def public inject_main(program_context : Context?; ctx : LLVMContextRef; } let main_ret = LLVMBuildCall2(builder, g_fn_types[start_fn_name], LLVMGetNamedFunction(g_mod, start_fn_name), main_params, "") - // Drain debug agents, modules, AOT library, env — mirrors what the interpreter - // driver does at shutdown. Without this the static g_DebugAgents map dtor - // races ref_count_mutex during __cxa_finalize_ranges (issue #2583). + // Drain debug agents, modules, AOT library, env — same teardown the interpreter + // does, minus the handle-leak dump (shipped binaries should be quiet by default). + // Without this the static g_DebugAgents map dtor races ref_count_mutex during + // __cxa_finalize_ranges (issue #2583). let shutdown_fn_type = LLVMFunctionType(types.t_void, array()) var shutdown_fn = LLVMGetNamedFunction(g_mod, "jit_shutdown") if (shutdown_fn == null) { diff --git a/skills/daspkg.md b/skills/daspkg.md index af1ca5275..667667240 100644 --- a/skills/daspkg.md +++ b/skills/daspkg.md @@ -243,7 +243,7 @@ The macro captures the call-site source file path at expansion, then walks the s `` is the suffix starting at the last `/modules/` segment. -**Project-local code (no `/modules/` in path) currently falls straight to tier 3** — `get_this_module_dir()` from user code like `main.das` returns the dev-time baked dir, which doesn't exist in a relocated bundle. Symptom: silent asset-load failures (e.g. fonts) when running the released bundle on a fresh machine, even though library code under `/modules/` works fine. Workaround: expose the asset dir via a `def public` getter from a library module under `/modules/`, then call it from user code. See [skills/filesystem.md](skills/filesystem.md) ("Finding bundled asset files at runtime") for the details. +**Project-local code (no `/modules/` in path)** skips tiers 1+2 and goes straight to tier 3, which returns the baked dir if it still exists (dev) or `` if it doesn't (relocated bundle). So `path_join(get_this_module_dir(), "asset.png")` from a project-local `main.das` works in both dev and shipped bundles, as long as the asset is shipped sitting next to the exe. **Call from inside a function — not a top-level `let` initializer.** daslang's `-exe` JIT path has a known limitation: top-level `let`s that call Context-allocating builtins (like `get_this_module_dir()`, `get_das_root()`, `dir_name(get_module_file_name(...))`) emit JIT-process-baked function pointers that are wrong under ASLR in the standalone exe. The same call works fine inside any function body. Tracked separately; once the daslang fix lands, the top-level form will Just Work. diff --git a/skills/filesystem.md b/skills/filesystem.md index 6937daba6..811dad3f0 100644 --- a/skills/filesystem.md +++ b/skills/filesystem.md @@ -201,10 +201,7 @@ def load_assets() { The macro captures the call-site source-file path at expansion, then a 3-tier resolver walks `/` → `/` → `dir_name(baked)`. `` is the suffix starting at the last `/modules/` segment. -**Caveat for project-local code (no `/modules/` in source path):** `get_this_module_dir()` from a user's `main.das` (or anywhere outside `/modules/...`) currently falls straight to tier 3 — i.e. returns the dev-time baked dir, which doesn't exist on a relocated bundle's machine. This makes `"{get_this_module_dir()}/"` from `main.das` resolve to a non-existent path post-release. Two workarounds until the resolver learns to fall back to `` when baked is gone: - -1. Have a sibling module under `/modules/` expose its dir as a public function (`def public my_module_dir() : string { return get_this_module_dir() }`) and call that from user code. -2. If the user app needs only the exe-relative root, plumb it via a builtin (none exists yet — `getExecutableFileName` is C++-only). +**Project-local code (no `/modules/` in source path)** — e.g. `get_this_module_dir()` called from a user's `main.das` — skips tiers 1+2 (no ``) and goes to tier 3, which returns `dir_name(baked)` if it still exists on disk (dev) or `` if it doesn't (relocated bundle). Either way you get an existing directory. Ship project-local assets next to the exe and `path_join(get_this_module_dir(), "asset.png")` works in both dev and shipped bundles. See [skills/daspkg.md](skills/daspkg.md#L224) for the bundle-shipping side of the same topic. diff --git a/src/builtin/module_builtin_runtime.cpp b/src/builtin/module_builtin_runtime.cpp index 1d3d7f69f..5ae4ee5a5 100644 --- a/src/builtin/module_builtin_runtime.cpp +++ b/src/builtin/module_builtin_runtime.cpp @@ -1542,13 +1542,20 @@ namespace das // 3. dir_name(baked_path) — dev (interpreted from source tree) // is the substring of dir_name(baked) starting at the last // "/modules/" segment. Tiers 1+2 are skipped when baked has no /modules/ - // segment (e.g. project-local code outside the package layout). + // segment (e.g. project-local code outside the package layout); when the + // baked dir has also gone missing on the target machine (relocated bundle), + // tier 3's fallback is rather than the dead dev path. char * builtin_resolve_this_module_dir ( const char * baked_path, Context * context ) { namespace fs = std::filesystem; if ( !baked_path || !*baked_path ) return context->allocateString("", nullptr); + // generic_string() (here and at every other path-to-string conversion + // in this function) emits forward-slash separators on every platform, + // matching getDasRoot() and the rest of daslang's path conventions. + // Without it Windows would return native backslashes that break string + // compares against `/`-formed paths from script-side daslang code. fs::path baked(baked_path); fs::path baked_dir = baked.parent_path(); - std::string baked_dir_str = baked_dir.string(); + std::string baked_dir_str = baked_dir.generic_string(); // Find the last "/modules/" boundary; everything from that segment // onward is the bundle/SDK-relative suffix (e.g. // "modules/das-cards/cards"). rfind, not find — for nested layouts @@ -1572,17 +1579,31 @@ namespace das fs::path candidate = exeDir / rel; std::error_code ec; if ( fs::is_directory(candidate, ec) ) { - return context->allocateString(candidate.string().c_str(), nullptr); + return context->allocateString(candidate.generic_string().c_str(), nullptr); } } // Tier 2 — das_root fs::path candidate = fs::path(das::getDasRoot().c_str()) / rel; std::error_code ec; if ( fs::is_directory(candidate, ec) ) { - return context->allocateString(candidate.string().c_str(), nullptr); + return context->allocateString(candidate.generic_string().c_str(), nullptr); + } + } + // Tier 3 — baked dir as fallback. Special case: project-local code + // (rel empty so tiers 1+2 were skipped) running from a relocated + // bundle where the dev-time baked dir no longer exists — fall back + // to so assets shipped next to the exe are findable. + if ( rel.empty() ) { + std::error_code ec; + if ( !fs::is_directory(baked_dir, ec) ) { + das::string exeFile = das::getExecutableFileName(); + if ( !exeFile.empty() ) { + fs::path exeDir = fs::path(exeFile.c_str()).parent_path(); + if ( exeDir.empty() ) exeDir = "."; + return context->allocateString(exeDir.generic_string().c_str(), nullptr); + } } } - // Tier 3 — baked dir as fallback return context->allocateString(baked_dir_str.c_str(), nullptr); } diff --git a/src/builtin/module_jit.cpp b/src/builtin/module_jit.cpp index 37f8ba7ef..bc84bab6c 100644 --- a/src/builtin/module_jit.cpp +++ b/src/builtin/module_jit.cpp @@ -798,13 +798,22 @@ extern "C" { : fmt::format_to(cmd, FMT_STRING("\"\"{}\" \"{}\" \"{}\" \"{}\" msvcrt.lib -link {} -OUT:\"{}\" 2>&1\""), linker, objFilePath, runtimeLibrary, compilerLibrary, linkerParam, libraryName); #elif defined(__APPLE__) const auto linkerParam = isShared ? "-shared " : ""; - const auto rpath = "-Wl,-rpath," + get_prefix(runtimeLibrary); + // @executable_path first → relocated bundle finds dylibs next to the exe; + // build-tree path second → dev workflow keeps working without copying dylibs. + // The embedded `\" \"` splits the format-string's outer quotes so the linker + // sees two distinct -Wl,-rpath flags, not one with an embedded space. + const auto rpath = "-Wl,-rpath,@executable_path\" \"-Wl,-rpath," + get_prefix(runtimeLibrary); auto result = compilerLibrary.empty() ? fmt::format_to(cmd, FMT_STRING("\"{}\" {} \"{}\" -o \"{}\" \"{}\" \"{}\" 2>&1"), linker, linkerParam, rpath, libraryName, runtimeLibrary, objFilePath) : fmt::format_to(cmd, FMT_STRING("\"{}\" {} \"{}\" -o \"{}\" \"{}\" \"{}\" \"{}\" 2>&1"), linker, linkerParam, rpath, libraryName, runtimeLibrary, compilerLibrary, objFilePath); #else const auto linkerParam = isShared ? "-shared" : ""; - const auto rpath = "-Wl,-rpath," + get_prefix(runtimeLibrary); + // $ORIGIN first → relocated bundle finds .so next to the exe; + // build-tree path second → dev workflow keeps working without copying. + // \$ escapes the dollar so popen's shell passes $ORIGIN to ld unexpanded. + // The embedded `\" \"` splits the format-string's outer quotes so the linker + // sees two distinct -Wl,-rpath flags, not one with an embedded space. + const auto rpath = "-Wl,-rpath,\\$ORIGIN\" \"-Wl,-rpath," + get_prefix(runtimeLibrary); auto result = compilerLibrary.empty() ? fmt::format_to(cmd, FMT_STRING("\"{}\" {} \"{}\" -o \"{}\" \"{}\" \"{}\" 2>&1"), linker, linkerParam, rpath, libraryName, objFilePath, runtimeLibrary) diff --git a/tests/fio/get_this_module_dir_resolver.das b/tests/fio/get_this_module_dir_resolver.das new file mode 100644 index 000000000..03e046eaa --- /dev/null +++ b/tests/fio/get_this_module_dir_resolver.das @@ -0,0 +1,48 @@ +options gen2 +require dastest/testing_boost public + +require daslib/fio +require daslib/module_path + +[test] +def test_real_project_local_path_returns_baked_dir(t : T?) { + t |> run("real project-local path returns its dir_name") @(t : T?) { + // This test file is project-local (sits in tests/fio/, no /modules/ segment). + let real_baked = "{get_das_root()}/tests/fio/get_this_module_dir_resolver.das" + let dir = __builtin_resolve_this_module_dir(real_baked) + t |> equal(dir, "{get_das_root()}/tests/fio") + } +} + +[test] +def test_nonexistent_project_local_falls_back_to_exe_dir(t : T?) { + t |> run("nonexistent project-local baked path falls back to an existing dir") @(t : T?) { + // No /modules/ segment AND dir doesn't exist on disk → should fall + // back to rather than returning the dead path. Models the + // relocated-bundle scenario where main.das's dev-time dir is gone. + let dead = "/__no_such_dir_42__/__no_such_subdir__/main.das" + let dir = __builtin_resolve_this_module_dir(dead) + t |> success(dir != "/__no_such_dir_42__/__no_such_subdir__", + "must not return the dead baked dir, got '{dir}'") + t |> success(fexist(dir), "fallback dir '{dir}' must exist on disk") + } +} + +[test] +def test_nonexistent_modules_path_keeps_baked(t : T?) { + t |> run("nonexistent /modules/ baked path returns baked dir unchanged") @(t : T?) { + // When rel is non-empty the new fallback does NOT fire — preserves + // existing tier-3 behavior for module paths so we don't accidentally + // mask a missing module with the exe dir. + let dead = "/__no_such_root__/modules/__no_such_module__/foo.das" + let dir = __builtin_resolve_this_module_dir(dead) + t |> equal(dir, "/__no_such_root__/modules/__no_such_module__") + } +} + +[test] +def test_empty_baked_returns_empty(t : T?) { + t |> run("empty input returns empty") @(t : T?) { + t |> equal(__builtin_resolve_this_module_dir(""), "") + } +} diff --git a/utils/daspkg/commands.das b/utils/daspkg/commands.das index 75ceffc3b..0d8e29719 100644 --- a/utils/daspkg/commands.das +++ b/utils/daspkg/commands.das @@ -15,6 +15,7 @@ require daslib/json_boost require daslib/ansi_colors require daslib/clargs public +require pugixml/PUGIXML_boost require lockfile require utils @@ -1352,6 +1353,75 @@ def private bundle_exe_path(bundle_dir, bundle_name : string) : string { return path_join(bundle_dir, bundle_name) } +// Sanitize one component of a derived CFBundleIdentifier so it satisfies +// Apple's [A-Za-z0-9.-] rule. Lowercases, replaces every other byte with +// `-`, collapses runs of `-`, strips leading/trailing `-`. Returns "x" +// for inputs that yield nothing salvageable so the final id stays valid. +// Only used for the derived `com..` default — user-supplied +// `release_bundle_id()` passes through untouched. +def private sanitize_bundle_id_part(s : string) : string { + let lower = to_lower(s) + let raw = build_string() $(var writer) { + var prev_dash = false + peek_data(lower) $(arr) { + for (i in range(length(arr))) { + let b = int(arr[i]) + let is_alnum = (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') + if (is_alnum) { + write_char(writer, b) + prev_dash = false + } elif (!prev_dash) { + write_char(writer, '-') + prev_dash = true + } + } + } + } + var result = raw + if (!empty(result) && result |> ends_with("-")) { + result = slice(result, 0, length(result) - 1) + } + if (!empty(result) && result |> starts_with("-")) { + result = slice(result, 1) + } + return empty(result) ? "x" : result +} + +// Minimal macOS .app Info.plist. Built with dasPUGIXML so values are +// XML-escaped automatically — inputs (bundle_name, exe, bundle_id) are +// otherwise unrestricted on the XML side. CFPropertyList accepts plist +// without a DOCTYPE, so we omit it. +def private build_info_plist(name, exe, bundle_id : string) : string { + var out = "" + with_doc() $(doc) { + doc |> tag("plist") $(var pl) { + pl |> attr("version", "1.0") + pl |> tag("dict") $(var d) { + d |> tag("key", "CFBundleName") + d |> tag("string", name) + d |> tag("key", "CFBundleExecutable") + d |> tag("string", exe) + d |> tag("key", "CFBundleIdentifier") + d |> tag("string", bundle_id) + d |> tag("key", "CFBundlePackageType") + d |> tag("string", "APPL") + d |> tag("key", "CFBundleVersion") + d |> tag("string", "1.0") + d |> tag("key", "CFBundleShortVersionString") + d |> tag("string", "1.0") + d |> tag("key", "NSPrincipalClass") + d |> tag("string", "NSApplication") + d |> tag("key", "NSHighResolutionCapable") + d |> tag("true") + d |> tag("key", "NSSupportsAutomaticGraphicsSwitching") + d |> tag("true") + } + } + out = to_string(doc) + } + return out +} + // Run release() on a dep package and copy its release_include files into the // bundle at `/modules//`. Returns 0 on success, non-zero // on copy failure (missing release() is NOT an error — dep just contributes nothing). @@ -1430,10 +1500,11 @@ def cmd_release(root : string; out_dir : string) : int { } // Pick bundle name: explicit release_name() > package_name() > root dir name. + var pkg : PackageMeta + let have_pkg = run_das_package_meta(das_package, pkg) var bundle_name = spec.name if (empty(bundle_name)) { - var pkg : PackageMeta - if (run_das_package_meta(das_package, pkg) && !empty(pkg.pkg_name)) { + if (have_pkg && !empty(pkg.pkg_name)) { bundle_name = pkg.pkg_name } else { bundle_name = base_name(get_full_file_name(root)) @@ -1447,11 +1518,28 @@ def cmd_release(root : string; out_dir : string) : int { to_log(LOG_ERROR, "Error: unsafe bundle name `{bundle_name}` (must not contain path separators or be `.` / `..`)\n") return 1 } - let bundle_dir = path_join(out_dir, bundle_name) - if (fexist(bundle_dir)) { - force_rmdir(bundle_dir) + // macOS: emit a clickable .app bundle. The exe + dylibs + modules go into + // Contents/MacOS/, so all subsequent ship logic uses bundle_dir unchanged + // and the rpath / module resolver still see the same layout. + let is_mac_app = get_platform_name() == "darwin" + var app_root = "" + if (is_mac_app) { + app_root = path_join(out_dir, "{bundle_name}.app") + } + var bundle_dir = "" + if (is_mac_app) { + bundle_dir = path_join(app_root, "Contents/MacOS") + } else { + bundle_dir = path_join(out_dir, bundle_name) } - mkdir(bundle_dir) + var cleanup_target = bundle_dir + if (is_mac_app) { + cleanup_target = app_root + } + if (fexist(cleanup_target)) { + force_rmdir(cleanup_target) + } + mkdir_rec(bundle_dir) // 1. Build the standalone exe; `--list-shared-modules` writes the deps JSON // (auto-detected dylibs + transitive .das_package list) as a side effect. @@ -1487,6 +1575,16 @@ def cmd_release(root : string; out_dir : string) : int { if (fexist(obj_path)) { remove(obj_path) } + // macOS .app: drop the .exe extension (Mac convention). Info.plist's + // CFBundleExecutable matches the renamed binary. + if (is_mac_app) { + let actual_exe = "{exe_path}.exe" + var rerr : string + if (!rename(actual_exe, exe_path, rerr)) { + to_log(LOG_ERROR, "release: cannot rename {actual_exe} -> {exe_path}: {rerr}\n") + return 1 + } + } // 2. Parse deps JSON, ship dylibs at the relative path the exe expects. // The writer has already merged force-included modules into shared_modules, @@ -1513,6 +1611,47 @@ def cmd_release(root : string; out_dir : string) : int { log(" ship: {e.rel}\n") } + // 2.5 Ship daslang runtime dylibs next to the exe. Without these the bundle + // dyld-loads via the build-machine fallback rpath and crashes on relocation. + // The exe's primary rpath is @executable_path / $ORIGIN, so any .{dylib,so,dll} + // named libDaScriptDyn* (or its libliblib... cmake variant) shipped here + // resolves at runtime. CMake puts the RUNTIME artifact (Windows .dll) in + // `/bin`, while LIBRARY artifacts (.dylib / .so) go in + // `/lib` — search the correct dir per platform. + let platform = get_platform_name() + let runtime_lib_dir = (platform == "windows" + ? "{get_das_root()}/bin" + : "{get_das_root()}/lib") + // Patterns are `**/...` so the recursive walk crosses config subdirs + // (Visual Studio / Xcode put the artifact in `bin/Release/`, not `bin/`). + let runtime_glob = (platform == "windows" ? "**/libDaScriptDyn*.dll" + : platform == "darwin" ? "**/*libDaScriptDyn*.dylib" + : "**/*libDaScriptDyn*.so") + var runtime_copy_failed = false + var runtime_copies = 0 + glob(runtime_lib_dir, runtime_glob) $(rel_path, is_dir) { + if (is_dir) { + return + } + let src = path_join(runtime_lib_dir, rel_path) + let dst = path_join(bundle_dir, base_name(rel_path)) + var err : string + if (!copy_file(src, dst, true, err)) { + to_log(LOG_ERROR, "release: copy_file({src} -> {dst}) failed: {err}\n") + runtime_copy_failed = true + return + } + runtime_copies += 1 + log(" ship runtime: {base_name(rel_path)}\n") + } + if (runtime_copy_failed) { + return 1 + } + if (runtime_copies == 0) { + to_log(LOG_ERROR, "release: no daslang runtime libs matched `{runtime_glob}` under `{runtime_lib_dir}`; bundle would not load on a relocated machine\n") + return 1 + } + // 3. Transitive dep traversal — for each unique dep .das_package (excluding // the project's own root), run release() and copy its release_include // files to /modules//. @@ -1553,6 +1692,26 @@ def cmd_release(root : string; out_dir : string) : int { log(" shipped {length(asset_files)} project asset file(s)\n") } - log("Released to: {bundle_dir}\n") + // 5. macOS .app: write Contents/Info.plist. Bundle ID source priority: + // explicit release_bundle_id() > derived com.. + // > com.daspkg.. Apple requires CFBundleIdentifier to use + // only [A-Za-z0-9.-], so the derived default sanitizes both pieces; + // user-supplied release_bundle_id() passes through untouched. + if (is_mac_app) { + var bundle_id = spec.bundle_id + if (empty(bundle_id)) { + let author_raw = (have_pkg && !empty(pkg.author)) ? pkg.author : "daspkg" + bundle_id = "com.{sanitize_bundle_id_part(author_raw)}.{sanitize_bundle_id_part(bundle_name)}" + } + let plist_path = path_join(app_root, "Contents/Info.plist") + if (!fwrite(plist_path, build_info_plist(bundle_name, bundle_name, bundle_id))) { + to_log(LOG_ERROR, "release: cannot write {plist_path}\n") + return 1 + } + log(" wrote: Contents/Info.plist (CFBundleIdentifier={bundle_id})\n") + log("Released to: {app_root}\n") + } else { + log("Released to: {bundle_dir}\n") + } return 0 } diff --git a/utils/daspkg/package_runner.das b/utils/daspkg/package_runner.das index 54cc958ea..b86163dca 100644 --- a/utils/daspkg/package_runner.das +++ b/utils/daspkg/package_runner.das @@ -37,6 +37,7 @@ struct PackageReleaseInfo { include_globs : array exclude_globs : array forced_modules : array + bundle_id : string } @@ -120,6 +121,7 @@ def run_das_package_release(das_package_path : string; var info : PackageRelease for (m in src.forced_modules) { info.forced_modules |> push_clone(clone_string(m)) } + info.bundle_id = clone_string(src.bundle_id) } } return ok diff --git a/utils/daspkg/test_daspkg.das b/utils/daspkg/test_daspkg.das index 2edc02028..06dd8d177 100644 --- a/utils/daspkg/test_daspkg.das +++ b/utils/daspkg/test_daspkg.das @@ -20,6 +20,42 @@ require commands require daslib/result require package_runner public +// On Darwin daspkg release emits a clickable `.app`; everything else is the +// flat `//...` layout. These helpers let release tests assert +// against the right paths without per-platform branches at every check site. + +def bundle_root_dir(out_dir, name : string) : string { + if (get_platform_name() == "darwin") { + return path_join(out_dir, "{name}.app") + } + return path_join(out_dir, name) +} + +def bundle_artifacts_dir(out_dir, name : string) : string { + if (get_platform_name() == "darwin") { + return path_join(path_join(out_dir, "{name}.app"), "Contents/MacOS") + } + return path_join(out_dir, name) +} + +def bundle_exe_path(out_dir, name : string) : string { + let dir = bundle_artifacts_dir(out_dir, name) + if (get_platform_name() == "darwin") { + return path_join(dir, name) + } + return path_join(dir, "{name}.exe") +} + +// Path of the exe AFTER the bundle root has been moved/renamed to `moved_root`. +// On Darwin the moved directory IS the .app — the exe still lives at +// Contents/MacOS/. On Linux/Windows it's just /.exe. +def moved_bundle_exe_path(moved_root, name : string) : string { + if (get_platform_name() == "darwin") { + return path_join(moved_root, "Contents/MacOS/{name}") + } + return path_join(moved_root, "{name}.exe") +} + [test] def test_package_name_from_source(t : T?) { t |> run("github URL") @(t : T?) { @@ -1658,9 +1694,11 @@ def test_cmd_release_pure_daslang(t : T?) { let rc = cmd_release(tmp_root, out_dir) tt |> equal(0, rc, "cmd_release returns 0") - tt |> success(fexist("{out_dir}/smoke/smoke.exe"), "exe shipped") - tt |> success(fexist("{out_dir}/smoke/data.txt"), "asset shipped") - tt |> success(!fexist("{out_dir}/smoke/smoke.o"), ".o build artifact dropped") + let exe_path = bundle_exe_path(out_dir, "smoke") + let artifacts = bundle_artifacts_dir(out_dir, "smoke") + tt |> success(fexist(exe_path), "exe shipped at {exe_path}") + tt |> success(fexist(path_join(artifacts, "data.txt")), "asset shipped") + tt |> success(!fexist(path_join(artifacts, "smoke.o")), ".o build artifact dropped") force_rmdir(tmp_root) force_rmdir(out_dir) @@ -1702,8 +1740,12 @@ def test_cmd_release_native_dep(t : T?) { let rc = cmd_release(tmp_root, out_dir) tt |> equal(0, rc, "cmd_release returns 0") - tt |> success(fexist("{out_dir}/sqs/sqs.exe"), "exe shipped") - tt |> success(fexist("{out_dir}/sqs/modules/dasSQLITE/dasModuleSQLITE.shared_module"), "dylib shipped at expected relative path") + let exe_path = bundle_exe_path(out_dir, "sqs") + let artifacts = bundle_artifacts_dir(out_dir, "sqs") + let bundle_root = bundle_root_dir(out_dir, "sqs") + tt |> success(fexist(exe_path), "exe shipped at {exe_path}") + tt |> success(fexist(path_join(artifacts, "modules/dasSQLITE/dasModuleSQLITE.shared_module")), + "dylib shipped at expected relative path") // Move the whole bundle to a new location and run it. PR #2579's // exe-relative resolution is what makes this work — the dylib needs @@ -1711,10 +1753,11 @@ def test_cmd_release_native_dep(t : T?) { // Use `rename` (cross-platform via libc rename(2)/Win CRT) rather // than shell `cp -r` so the test runs on Windows CI; rename also // preserves the exe permission bit by definition. - let moved = rename("{out_dir}/sqs", moved_dir) + let moved = rename(bundle_root, moved_dir) tt |> success(moved, "bundle moved to new location") + let moved_exe = moved_bundle_exe_path(moved_dir, "sqs") var output : string - let run_rc = run_cmd("\"{moved_dir}/sqs.exe\"", output) + let run_rc = run_cmd("\"{moved_exe}\"", output) tt |> equal(0, run_rc, "moved bundle runs cleanly") tt |> success(find(output, "rows: 1") >= 0, "moved bundle prints expected sqlite output (got: {output})") @@ -1770,10 +1813,11 @@ def test_cmd_release_no_false_positive_shared(t : T?) { let rc = cmd_release(tmp_root, out_dir) tt |> equal(0, rc, "cmd_release returns 0") - tt |> success(fexist("{out_dir}/fpshared/modules/dasSQLITE/dasModuleSQLITE.shared_module"), + let artifacts = bundle_artifacts_dir(out_dir, "fpshared") + tt |> success(fexist(path_join(artifacts, "modules/dasSQLITE/dasModuleSQLITE.shared_module")), "dasSQLITE dylib shipped (program required it)") // Pick one registered-but-unused package and confirm it's NOT shipped. - let other_dir = "{out_dir}/fpshared/modules/{other_for_capture}" + let other_dir = path_join(artifacts, "modules/{other_for_capture}") tt |> equal(false, fexist(other_dir), "registered-but-unused package must NOT be shipped (false positive)") force_rmdir(tmp_root) @@ -1807,9 +1851,11 @@ def test_cmd_release_no_false_positive_das(t : T?) { let rc = cmd_release(tmp_root, out_dir) tt |> equal(0, rc, "cmd_release returns 0") - tt |> success(fexist("{out_dir}/fpdas/fpdas.exe"), "exe shipped") + let exe_path = bundle_exe_path(out_dir, "fpdas") + let artifacts = bundle_artifacts_dir(out_dir, "fpdas") + tt |> success(fexist(exe_path), "exe shipped at {exe_path}") // No modules/ tree at all — project has no shared modules and no daspkg deps. - tt |> equal(false, fexist("{out_dir}/fpdas/modules"), + tt |> equal(false, fexist(path_join(artifacts, "modules")), "no modules/ tree (no shared deps, no daspkg deps; daslib stdlib + project-local .das must NOT show up)") force_rmdir(tmp_root) @@ -1861,8 +1907,10 @@ def test_cmd_release_transitive_dep(t : T?) { let rc = cmd_release(tmp_root, out_dir) tt |> equal(0, rc, "cmd_release returns 0") - tt |> success(fexist("{out_dir}/transit/transit.exe"), "parent exe shipped") - tt |> success(fexist("{out_dir}/transit/modules/fakedep/data/asset.bin"), + let exe_path = bundle_exe_path(out_dir, "transit") + let artifacts = bundle_artifacts_dir(out_dir, "transit") + tt |> success(fexist(exe_path), "parent exe shipped at {exe_path}") + tt |> success(fexist(path_join(artifacts, "modules/fakedep/data/asset.bin")), "dep asset shipped via transitive release()") force_rmdir(tmp_root) force_rmdir(out_dir)