From c1aa43552425834d426ccbe2e6b924550d2e09f8 Mon Sep 17 00:00:00 2001 From: nejdetkadir Date: Tue, 20 Oct 2026 21:20:32 +0300 Subject: [PATCH 1/2] Define the embedded-assets prepare hook for apps without file storage gea_app_entry.cpp references gea_embedded_assets_prepare weakly, but the generator defined it only for apps with a file-backed asset bundle. Apple's ld rejects a weak reference nothing defines, so since 0.1.43 every iOS and macOS app without gea.embeddedAssets.storage fails to link, the blank starter included. ELF targets kept linking and skipped the call. The generator now defines it weakly for every app without a bundle and returns true, since embedded assets need no preparation. It is weak like gea_embedded_asset_lookup because a bundled binary links several apps' generated assets. The reference goes through GEA_PROGRAM_HOOK like the other hooks a program defines, so a target with GEA_LINKED_HOOK_DEFAULTS references it as an ordinary symbol. --- packages/core/gea_app_entry.cpp | 2 +- .../scripts/generate-gea-embedded-assets.mjs | 8 ++++ .../core/test/test_embedded_weak_symbols.mjs | 1 + packages/core/test/test_file_asset_bundle.mjs | 40 +++++++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/core/gea_app_entry.cpp b/packages/core/gea_app_entry.cpp index 74084d1..7d39db7 100644 --- a/packages/core/gea_app_entry.cpp +++ b/packages/core/gea_app_entry.cpp @@ -41,7 +41,7 @@ void drainMicrotasks(); #endif extern "C" void gea_cycle_collection_defer_begin() GEA_PROGRAM_HOOK; extern "C" void gea_cycle_collection_defer_end() GEA_PROGRAM_HOOK; -extern "C" bool gea_embedded_assets_prepare() __attribute__((weak)); +extern "C" bool gea_embedded_assets_prepare() GEA_PROGRAM_HOOK; namespace gea::framework::app { diff --git a/packages/core/scripts/generate-gea-embedded-assets.mjs b/packages/core/scripts/generate-gea-embedded-assets.mjs index b7e5517..6bb4ba0 100644 --- a/packages/core/scripts/generate-gea-embedded-assets.mjs +++ b/packages/core/scripts/generate-gea-embedded-assets.mjs @@ -332,6 +332,14 @@ if (assets.length === 0) { parts.push('}') parts.push('') } +if (!bundle) { + // Without file storage every asset is embedded and ready at startup. Define + // the hook anyway: gea_app_entry.cpp references it, and Apple's ld rejects a + // weak reference nothing defines. Weak like gea_embedded_asset_lookup, since + // a bundled binary links several apps' generated assets. + parts.push('extern "C" __attribute__((weak)) bool gea_embedded_assets_prepare() { return true; }') + parts.push('') +} writeFileIfChanged(outCpp, parts.join('\n')) process.stderr.write(`generate-gea-embedded-assets: ${assets.length} asset(s) -> ${path.relative(process.cwd(), outCpp)}\n`) diff --git a/packages/core/test/test_embedded_weak_symbols.mjs b/packages/core/test/test_embedded_weak_symbols.mjs index f4fe37f..48d1f4f 100644 --- a/packages/core/test/test_embedded_weak_symbols.mjs +++ b/packages/core/test/test_embedded_weak_symbols.mjs @@ -9,6 +9,7 @@ assert.doesNotMatch(entry, /weak_import/, 'embedded GCC does not support the Mac assert.match(entry, /#else\n#define GEA_PROGRAM_HOOK __attribute__\(\(weak\)\)\n#endif/) assert.match(entry, /gea_cycle_collection_defer_begin\(\) GEA_PROGRAM_HOOK;/) assert.match(entry, /gea_cycle_collection_defer_end\(\) GEA_PROGRAM_HOOK;/) +assert.match(entry, /gea_embedded_assets_prepare\(\) GEA_PROGRAM_HOOK;/) const initBody = entry.slice(entry.indexOf('void Application::init'), entry.indexOf('namespace {', entry.indexOf('void Application::init'))) const frameBody = entry.slice(entry.indexOf('void Application::frame'), entry.indexOf('void Application::toggleSettings')) diff --git a/packages/core/test/test_file_asset_bundle.mjs b/packages/core/test/test_file_asset_bundle.mjs index ef94fe8..3c6648a 100644 --- a/packages/core/test/test_file_asset_bundle.mjs +++ b/packages/core/test/test_file_asset_bundle.mjs @@ -161,6 +161,46 @@ try { run("corrupt"); files.forEach((file, i) => fs.writeFileSync(file.local, payloads[i])); run("ready"); + + // gea_app_entry.cpp calls the prepare hook whether or not the app uses file + // storage, and Apple's ld rejects a weak reference nothing defines, so the + // generator has to define it for apps without a bundle too. + const prepareMain = path.join(fixture, "prepare_main.cpp"); + fs.writeFileSync( + prepareMain, + 'extern "C" bool gea_embedded_assets_prepare();\nint main() { return gea_embedded_assets_prepare() ? 0 : 1; }\n', + ); + for (const [id, asset] of [ + ["no-storage-test", "public/pixel.png"], + ["no-assets-test", null], + ]) { + const plain = path.join(fixture, id); + fs.mkdirSync(path.join(plain, "public"), { recursive: true }); + fs.writeFileSync( + path.join(plain, "package.json"), + JSON.stringify({ gea: { id } }), + ); + if (asset) fs.copyFileSync(path.join(app, asset), path.join(plain, asset)); + const plainCpp = path.join(fixture, id + ".cpp"); + execFileSync( + process.execPath, + [ + path.join(core, "scripts/generate-gea-embedded-assets.mjs"), + "--app-dir", + plain, + "--out-cpp", + plainCpp, + ], + { stdio: "pipe" }, + ); + const plainBinary = path.join(fixture, id + "-prepare" + exe); + execFileSync( + process.env.CXX || "clang++", + ["-std=c++20", plainCpp, prepareMain, "-o", plainBinary], + { stdio: "inherit" }, + ); + execFileSync(plainBinary, [], { stdio: "inherit" }); + } console.log( "File asset bundles: validated content, rollback identity, corrupt/truncated/oversized/missing files, ASan/UBSan passed", ); From b23bcaeb1ba7f23439fb210a61f481e838a32f4d Mon Sep 17 00:00:00 2001 From: nejdetkadir Date: Tue, 20 Oct 2026 23:37:39 +0300 Subject: [PATCH 2/2] Keep the review fixes on top of the generator's prepare fallback 81cbe9f gave every non-bundle app a weak gea_embedded_assets_prepare from the generator, so this drops the definition 7ed9224 added there and restores the file-bundle test. The GEA_PROGRAM_HOOK spelling stays: next to that weak definition, a plain weak reference is the pair lld-link reports as a duplicate symbol. Also from the review on #75: the asset bundle loader logs when storage is not mounted, its one silent failure, and a build without file storage removes the deployment manifest an earlier storage-backed build left. New test_embedded_assets_prepare.mjs links the real gea_app_entry.cpp object with generated units for apps with no storage, no assets and file-backed assets, and checks that the hook resolves; it fails with the generator from before 81cbe9f. --- packages/core/include/platform/asset_bundle.h | 4 +- .../core/scripts/gea-file-asset-bundle.mjs | 9 ++- .../scripts/generate-gea-embedded-assets.mjs | 8 --- .../test/test_embedded_assets_manifest.mjs | 35 +++++++++++ .../test/test_embedded_assets_prepare.mjs | 60 +++++++++++++++++++ packages/core/test/test_file_asset_bundle.mjs | 40 ------------- 6 files changed, 105 insertions(+), 51 deletions(-) create mode 100644 packages/core/test/test_embedded_assets_manifest.mjs create mode 100644 packages/core/test/test_embedded_assets_prepare.mjs diff --git a/packages/core/include/platform/asset_bundle.h b/packages/core/include/platform/asset_bundle.h index 8347048..f6a19e6 100644 --- a/packages/core/include/platform/asset_bundle.h +++ b/packages/core/include/platform/asset_bundle.h @@ -27,8 +27,10 @@ struct AssetSegment { inline bool loadAssetBundle(const char *path, const AssetSegment *segments, std::size_t count, std::size_t bytes, std::uint32_t expectedCrc) { - if (!ensureMounted()) + if (!ensureMounted()) { + std::fprintf(stderr, "Gea asset bundle storage not mounted: %s\n", path); return false; + } auto *file = std::fopen(path, "rb"); if (!file) { std::fprintf(stderr, "Gea asset bundle missing: %s\n", path); diff --git a/packages/core/scripts/gea-file-asset-bundle.mjs b/packages/core/scripts/gea-file-asset-bundle.mjs index b92ce15..65cfc74 100644 --- a/packages/core/scripts/gea-file-asset-bundle.mjs +++ b/packages/core/scripts/gea-file-asset-bundle.mjs @@ -26,7 +26,13 @@ export function fileAssetBundle(appDir, outCpp, kind, entries) { ? JSON.parse(fs.readFileSync(manifestFile, "utf8")) : {}; const directory = fileAssetDirectory(manifest); - if (!directory || !entries.length) return null; + const metadata = `${outCpp}.assets.json`; + if (!directory || !entries.length) { + // A manifest left from an earlier storage-backed build would still have + // deploy tooling push that bundle. + fs.rmSync(metadata, { force: true }); + return null; + } const bytes = Buffer.concat(entries.map((entry) => Buffer.from(entry.data))); const hash = createHash("sha256").update(bytes).digest("hex"); const id = String(manifest.gea?.id || path.basename(appDir)).replace( @@ -43,7 +49,6 @@ export function fileAssetBundle(appDir, outCpp, kind, entries) { version: 1, files: [{ local, remote, bytes: bytes.length, sha256: hash }], }; - const metadata = `${outCpp}.assets.json`; const contents = JSON.stringify(deployment, null, 2) + "\n"; if ( !fs.existsSync(metadata) || diff --git a/packages/core/scripts/generate-gea-embedded-assets.mjs b/packages/core/scripts/generate-gea-embedded-assets.mjs index 6bb4ba0..b7e5517 100644 --- a/packages/core/scripts/generate-gea-embedded-assets.mjs +++ b/packages/core/scripts/generate-gea-embedded-assets.mjs @@ -332,14 +332,6 @@ if (assets.length === 0) { parts.push('}') parts.push('') } -if (!bundle) { - // Without file storage every asset is embedded and ready at startup. Define - // the hook anyway: gea_app_entry.cpp references it, and Apple's ld rejects a - // weak reference nothing defines. Weak like gea_embedded_asset_lookup, since - // a bundled binary links several apps' generated assets. - parts.push('extern "C" __attribute__((weak)) bool gea_embedded_assets_prepare() { return true; }') - parts.push('') -} writeFileIfChanged(outCpp, parts.join('\n')) process.stderr.write(`generate-gea-embedded-assets: ${assets.length} asset(s) -> ${path.relative(process.cwd(), outCpp)}\n`) diff --git a/packages/core/test/test_embedded_assets_manifest.mjs b/packages/core/test/test_embedded_assets_manifest.mjs new file mode 100644 index 0000000..9e5d928 --- /dev/null +++ b/packages/core/test/test_embedded_assets_manifest.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const core = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-assets-manifest-')) + +try { + // Dropping file storage must not leave the previous deployment manifest for + // deploy tooling to push. + const app = path.join(fixture, 'app') + const outCpp = path.join(fixture, 'images.cpp') + fs.mkdirSync(path.join(app, 'public'), { recursive: true }) + fs.writeFileSync(path.join(app, 'public', 'pixel.png'), 'x') + const generate = (gea) => { + fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ gea })) + execFileSync(process.execPath, [path.join(core, 'scripts', 'generate-gea-embedded-assets.mjs'), '--app-dir', app, '--out-cpp', outCpp], { + stdio: 'pipe' + }) + } + const windows = process.platform === 'win32' + const store = path.join(fixture, 'store') + const directory = windows ? '/' + store.slice(path.parse(store).root.length).replaceAll('\\', '/') : store + generate({ id: 'manifest-test', embeddedAssets: { storage: { type: 'file', directory } } }) + assert.ok(fs.existsSync(outCpp + '.assets.json'), 'a storage-backed build writes its manifest') + generate({ id: 'manifest-test' }) + assert.ok(!fs.existsSync(outCpp + '.assets.json'), 'a build without storage removes the stale manifest') + + console.log('Embedded assets manifest: a build without file storage removes the stale deployment manifest') +} finally { + fs.rmSync(fixture, { recursive: true, force: true }) +} diff --git a/packages/core/test/test_embedded_assets_prepare.mjs b/packages/core/test/test_embedded_assets_prepare.mjs new file mode 100644 index 0000000..16f22a9 --- /dev/null +++ b/packages/core/test/test_embedded_assets_prepare.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +// gea_app_entry.cpp references gea_embedded_assets_prepare, and Apple's ld rejects a +// weak reference nothing defines, so the generated assets unit has to define it for +// every app. Link the real entry object with each kind of generated unit and check +// that the hook resolves. lld-link has no relocatable link, so COFF is covered by the +// windows target's hook-defaults test instead. +if (process.platform === 'win32') { + console.log('Embedded assets prepare: skipped on Windows (no relocatable link)') + process.exit(0) +} + +const core = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const packages = path.dirname(core) +const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'gea-assets-prepare-')) +const cxx = process.env.CXX || 'clang++' +const includes = ['core/include', 'core', 'host/include', 'host', 'engine', 'engine/ui', 'elements', 'elements/ui', 'geaos'].map( + (dir) => '-I' + path.join(packages, dir) +) + +try { + const entry = path.join(fixture, 'entry.o') + execFileSync(cxx, ['-std=c++20', '-O2', '-c', path.join(core, 'gea_app_entry.cpp'), ...includes, '-o', entry], { stdio: 'inherit' }) + + const linked = (name, gea, asset) => { + const app = path.join(fixture, name) + fs.mkdirSync(path.join(app, 'public'), { recursive: true }) + if (asset) fs.writeFileSync(path.join(app, 'public', 'pixel.png'), 'x') + fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ gea })) + const cpp = path.join(fixture, name + '.cpp') + execFileSync(process.execPath, [path.join(core, 'scripts', 'generate-gea-embedded-assets.mjs'), '--app-dir', app, '--out-cpp', cpp], { + stdio: 'pipe' + }) + const unit = path.join(fixture, name + '.o') + execFileSync(cxx, ['-std=c++20', '-O2', '-c', cpp, ...includes, '-o', unit], { stdio: 'inherit' }) + const combined = path.join(fixture, name + '-linked.o') + execFileSync(cxx, ['-r', entry, unit, '-o', combined], { stdio: 'inherit' }) + const symbols = execFileSync(process.env.NM || 'nm', [combined], { encoding: 'utf8' }) + return symbols.split('\n').find((line) => /\b_?gea_embedded_assets_prepare$/.test(line)) + } + + for (const [name, gea, asset] of [ + ['no-storage', { id: 'no-storage' }, true], + ['no-assets', { id: 'no-assets' }, false], + ['file-backed', { id: 'file-backed', embeddedAssets: { storage: { type: 'file', directory: '/nand/assets' } } }, true] + ]) { + const prepare = linked(name, gea, asset) + assert.ok(prepare, `${name}: the linked object names gea_embedded_assets_prepare`) + assert.doesNotMatch(prepare, /\s[Uwv]\s/, `${name}: gea_app_entry's reference resolves: ${prepare.trim()}`) + } + + console.log('Embedded assets prepare: entry links against no-storage, no-asset and file-backed generated units') +} finally { + fs.rmSync(fixture, { recursive: true, force: true }) +} diff --git a/packages/core/test/test_file_asset_bundle.mjs b/packages/core/test/test_file_asset_bundle.mjs index 3c6648a..ef94fe8 100644 --- a/packages/core/test/test_file_asset_bundle.mjs +++ b/packages/core/test/test_file_asset_bundle.mjs @@ -161,46 +161,6 @@ try { run("corrupt"); files.forEach((file, i) => fs.writeFileSync(file.local, payloads[i])); run("ready"); - - // gea_app_entry.cpp calls the prepare hook whether or not the app uses file - // storage, and Apple's ld rejects a weak reference nothing defines, so the - // generator has to define it for apps without a bundle too. - const prepareMain = path.join(fixture, "prepare_main.cpp"); - fs.writeFileSync( - prepareMain, - 'extern "C" bool gea_embedded_assets_prepare();\nint main() { return gea_embedded_assets_prepare() ? 0 : 1; }\n', - ); - for (const [id, asset] of [ - ["no-storage-test", "public/pixel.png"], - ["no-assets-test", null], - ]) { - const plain = path.join(fixture, id); - fs.mkdirSync(path.join(plain, "public"), { recursive: true }); - fs.writeFileSync( - path.join(plain, "package.json"), - JSON.stringify({ gea: { id } }), - ); - if (asset) fs.copyFileSync(path.join(app, asset), path.join(plain, asset)); - const plainCpp = path.join(fixture, id + ".cpp"); - execFileSync( - process.execPath, - [ - path.join(core, "scripts/generate-gea-embedded-assets.mjs"), - "--app-dir", - plain, - "--out-cpp", - plainCpp, - ], - { stdio: "pipe" }, - ); - const plainBinary = path.join(fixture, id + "-prepare" + exe); - execFileSync( - process.env.CXX || "clang++", - ["-std=c++20", plainCpp, prepareMain, "-o", plainBinary], - { stdio: "inherit" }, - ); - execFileSync(plainBinary, [], { stdio: "inherit" }); - } console.log( "File asset bundles: validated content, rollback identity, corrupt/truncated/oversized/missing files, ASan/UBSan passed", );