@geastack/core 0.1.42 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/runtime.cpp CHANGED
@@ -7,6 +7,7 @@
7
7
  #include "input.h"
8
8
  #include "memory.h"
9
9
  #include "events.h"
10
+ #include "touch.h"
10
11
  #include "power.h"
11
12
  #include "services/app_runner.h"
12
13
  #include "services/app_state.h"
@@ -48,10 +49,89 @@
48
49
  #include "ui/document.h"
49
50
  #include "ui/tree_internal.h"
50
51
 
52
+ #include <atomic>
53
+ #include <cstdint>
54
+
55
+ extern "C" void gea_cycle_collection_defer_begin() __attribute__((weak));
56
+ extern "C" void gea_cycle_collection_defer_end() __attribute__((weak));
57
+
51
58
  namespace gea::framework {
52
59
 
53
60
  namespace {
54
61
 
62
+ // The low bit is the requested state; the remaining bits distinguish each
63
+ // transition. An acknowledgement of an earlier pause cannot satisfy a new
64
+ // pause after a quick resume. These survive a request made by native boot,
65
+ // before either the UI task or its queue exists.
66
+ std::atomic<std::uint32_t> uiPauseRequest{0};
67
+ std::atomic<std::uint32_t> uiPauseAcknowledged{0};
68
+
69
+ void discardPausedInput()
70
+ {
71
+ #if !defined(GEA_EMBEDDED_DIRECT_CANVAS_CONTEXT) || !GEA_EMBEDDED_DIRECT_CANVAS_CONTEXT
72
+ services::AppState::lock();
73
+ events::TouchRuntime::resetGestureState();
74
+ services::AppState::unlock();
75
+ gea::platform::touch::Touchscreen::consumeLatestMove(nullptr, nullptr);
76
+ (void)input::consumeRotaryDelta();
77
+ input::HardwareKeyEvent key{};
78
+ while (input::consumeKeyEvent(key)) {}
79
+ #endif
80
+ }
81
+
82
+ void discardPausedEvent(const events::Event &event)
83
+ {
84
+ #if !defined(GEA_EMBEDDED_DIRECT_CANVAS_CONTEXT) || !GEA_EMBEDDED_DIRECT_CANVAS_CONTEXT
85
+ // A move marker must re-arm the controller even though its callback is
86
+ // dropped; otherwise the first drag after resume can stop producing moves.
87
+ if (event.type == events::EventType::Touch && event.touchPhase == events::TouchPhase::Move)
88
+ gea::platform::touch::Touchscreen::consumeLatestMove(nullptr, nullptr);
89
+ #else
90
+ (void)event;
91
+ #endif
92
+ }
93
+
94
+ bool acknowledgeUiPause(bool &paused)
95
+ {
96
+ const auto request = uiPauseRequest.load(std::memory_order_acquire);
97
+ if ((request & 1U) == 0) return false;
98
+ if (!paused) {
99
+ discardPausedInput();
100
+ paused = true;
101
+ }
102
+ // Called only by the event-loop owner, outside dispatch_event/runFrame and
103
+ // after discardPausedInput has released AppState. No lock is held while the
104
+ // runtime waits on its queue in the paused state.
105
+ uiPauseAcknowledged.store(request, std::memory_order_release);
106
+ return true;
107
+ }
108
+
109
+ // Input can allocate just like a frame callback, but touch dispatch and the
110
+ // frame's poll/rotary/key prelude run outside Application::frame's lifetime
111
+ // scope. Do not collect while generated callbacks still hold transient native
112
+ // references or the dispatcher is borrowing its current listener.
113
+ class InputCycleCollectionDeferral {
114
+ public:
115
+ InputCycleCollectionDeferral()
116
+ : active_(gea_cycle_collection_defer_begin && gea_cycle_collection_defer_end)
117
+ {
118
+ if (active_) gea_cycle_collection_defer_begin();
119
+ }
120
+ ~InputCycleCollectionDeferral()
121
+ {
122
+ if (!active_) return;
123
+ const auto previous = app::applicationFramePhaseRead();
124
+ app::applicationFramePhaseSet(app::ApplicationFramePhase::CycleCollection);
125
+ gea_cycle_collection_defer_end();
126
+ app::applicationFramePhaseSet(previous);
127
+ }
128
+ InputCycleCollectionDeferral(const InputCycleCollectionDeferral &) = delete;
129
+ InputCycleCollectionDeferral &operator=(const InputCycleCollectionDeferral &) = delete;
130
+
131
+ private:
132
+ bool active_;
133
+ };
134
+
55
135
  #if defined(GEA_EMBEDDED_DIRECT_CANVAS_CONTEXT) && GEA_EMBEDDED_DIRECT_CANVAS_CONTEXT
56
136
 
57
137
  void run_app_frame(int timestampMs, void *context)
@@ -96,19 +176,8 @@ void dispatch_key_input()
96
176
  event.bubbles = true;
97
177
  event.cancelable = true;
98
178
 
99
- const int inputId = tree.activeInputId();
100
- bool stopped = false;
101
- if (inputId >= 0) {
102
- event.targetId = inputId;
103
- event.currentTargetId = inputId;
104
- tree.dispatchEvent(event);
105
- stopped = event.propagationStopped;
106
- }
107
- if (!stopped) {
108
- event.propagationStopped = false;
109
- if (key.pressed) gea::embedded::ui::dispatchDocumentKeyDown(event);
110
- else gea::embedded::ui::dispatchDocumentKeyUp(event);
111
- }
179
+ event.targetId = tree.activeInputId();
180
+ gea::embedded::ui::dispatchRootKeyEvent(event);
112
181
  }
113
182
  }
114
183
 
@@ -116,6 +185,7 @@ void run_app_frame(int timestampMs, void *context)
116
185
  {
117
186
  (void)context;
118
187
  const uint32_t nowMs = static_cast<uint32_t>(timestampMs);
188
+ InputCycleCollectionDeferral deferCycleCollection;
119
189
  // Low-rate platform work shares the runtime event loop. This keeps input and
120
190
  // battery observation deterministic without dedicating several mostly-idle
121
191
  // FreeRTOS tasks (and their stacks) to polling.
@@ -153,7 +223,11 @@ void dispatch_event(const events::Event &event, const RuntimeOptions &options)
153
223
  break;
154
224
  }
155
225
  services::AppState::lock();
156
- const bool touchChangedScene = events::TouchRuntime::dispatchEvent(event);
226
+ bool touchChangedScene;
227
+ {
228
+ InputCycleCollectionDeferral deferCycleCollection;
229
+ touchChangedScene = events::TouchRuntime::dispatchEvent(event);
230
+ }
157
231
  services::AppState::unlock();
158
232
  // Input-triggered render: a touch that actually ran handlers (down / real
159
233
  // move / up) likely changed the scene, so post a Frame now instead of
@@ -198,6 +272,7 @@ void dispatch_event(const events::Event &event, const RuntimeOptions &options)
198
272
  // A finger held still posts no moves, so gestureActiveWithin() lapses to
199
273
  // false after the window and the loop quiesces — no busy-render on a hold.
200
274
  for (;;) {
275
+ if (Runtime::uiPauseRequested()) break;
201
276
  #if defined(ESP_PLATFORM) && defined(GEA_NATIVE_DEBUGGER) && GEA_NATIVE_DEBUGGER
202
277
  if (services::FrameScheduler::debuggerFrameRate() > 0) break;
203
278
  #endif
@@ -213,11 +288,12 @@ void dispatch_event(const events::Event &event, const RuntimeOptions &options)
213
288
  // keep going, instead of returning to the timer-paced outer loop.
214
289
  // drainFramesAndCheckInput() leaves a non-Frame event at the queue
215
290
  // front, so receiveEvent() returns it immediately without blocking.
216
- while (services::FrameScheduler::drainFramesAndCheckInput()) {
291
+ while (!Runtime::uiPauseRequested() && services::FrameScheduler::drainFramesAndCheckInput()) {
217
292
  events::Event pending{};
218
293
  if (!services::FrameScheduler::receiveEvent(&pending)) break;
219
294
  dispatch_event(pending, options);
220
295
  }
296
+ if (Runtime::uiPauseRequested()) break;
221
297
  services::FrameScheduler::runFrame(frameCallbacks);
222
298
  }
223
299
  // Persist any localStorage writes made this frame (or from a store's
@@ -242,6 +318,40 @@ void dispatch_event(const events::Event &event, const RuntimeOptions &options)
242
318
 
243
319
  } // namespace
244
320
 
321
+ void Runtime::requestUiPause(bool paused)
322
+ {
323
+ auto observed = uiPauseRequest.load(std::memory_order_relaxed);
324
+ for (;;) {
325
+ if (((observed & 1U) != 0) == paused) return;
326
+ const auto next = ((observed + 2U) & ~1U) | (paused ? 1U : 0U);
327
+ if (uiPauseRequest.compare_exchange_weak(observed, next, std::memory_order_acq_rel,
328
+ std::memory_order_relaxed)) break;
329
+ }
330
+ #if !GEA_EMBEDDED_NO_DISPLAY
331
+ // Before queue creation this harmlessly fails. A full queue is already a
332
+ // wake-up; an idle runtime receives this event and observes the new request.
333
+ events::Event wake{};
334
+ wake.type = events::EventType::Timeout;
335
+ (void)services::FrameScheduler::sendEvent(wake, 0);
336
+ #endif
337
+ }
338
+
339
+ bool Runtime::uiPauseRequested()
340
+ {
341
+ return (uiPauseRequest.load(std::memory_order_acquire) & 1U) != 0;
342
+ }
343
+
344
+ bool Runtime::uiPaused()
345
+ {
346
+ #if GEA_EMBEDDED_NO_DISPLAY
347
+ return uiPauseRequested();
348
+ #else
349
+ const auto request = uiPauseRequest.load(std::memory_order_acquire);
350
+ return (request & 1U) != 0 && uiPauseAcknowledged.load(std::memory_order_acquire) == request &&
351
+ uiPauseRequest.load(std::memory_order_acquire) == request;
352
+ #endif
353
+ }
354
+
245
355
  void Runtime::runNativeBoot()
246
356
  {
247
357
  // An app with native hardware of its own (audio, USB host, sensors) brings
@@ -464,10 +574,29 @@ void Runtime::run(const RuntimeOptions &options)
464
574
  // which starves whatever else shares it. boot() was the work; return.
465
575
  (void)options;
466
576
  #else
577
+ bool paused = false;
467
578
  while (true) {
579
+ if (acknowledgeUiPause(paused)) {
580
+ events::Event discarded{};
581
+ if (services::FrameScheduler::receiveEvent(&discarded)) discardPausedEvent(discarded);
582
+ continue;
583
+ }
584
+ if (paused) {
585
+ discardPausedInput();
586
+ paused = false;
587
+ (void)services::FrameScheduler::takeCatchUpRequest();
588
+ // A paused Frame was consumed without runFrame clearing the target's
589
+ // coalesced-pending flag. Force one ordinary frame to re-arm its clock;
590
+ // relying on a timer or a best-effort queue post could leave it stuck.
591
+ events::Event refresh{};
592
+ refresh.type = events::EventType::Frame;
593
+ dispatch_event(refresh, options);
594
+ continue;
595
+ }
468
596
  events::Event event{};
469
597
  if (services::FrameScheduler::receiveEvent(&event)) {
470
- dispatch_event(event, options);
598
+ if (uiPauseRequested()) discardPausedEvent(event);
599
+ else dispatch_event(event, options);
471
600
  }
472
601
  }
473
602
  #endif
@@ -5,6 +5,7 @@ import process from 'node:process'
5
5
  import { spawnSync } from 'node:child_process'
6
6
  import { createRequire } from 'node:module'
7
7
  import { fileURLToPath, pathToFileURL } from 'node:url'
8
+ import { resolveAppGeaVitePlugin } from './gea-vite-plugin-resolution.mjs'
8
9
  import { inlineJsonImports, transformGeaEmbeddedCompatSource } from './gea-embedded-compat-transform.mjs'
9
10
  import { normalizeEmbeddedJsxOptions, shouldIgnoreCompatStagingDirectory } from './gea-embedded-compat-staging.mjs'
10
11
  import { dotEnvDefines, inlineProcessEnv } from './dotenv-defines.mjs'
@@ -24,7 +25,8 @@ import {
24
25
  const nativeModules = new Map()
25
26
  let nativeModuleSources = new Map()
26
27
  const args = process.argv.slice(2)
27
- const requireFromLib = createRequire(new URL('../package.json', import.meta.url))
28
+ const corePackageJson = new URL('../package.json', import.meta.url)
29
+ const requireFromLib = createRequire(corePackageJson)
28
30
  const coreRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
29
31
  const elementsRoot = path.resolve(coreRoot, '..', 'elements')
30
32
  const engineRoot = path.resolve(coreRoot, '..', 'engine')
@@ -74,7 +76,6 @@ function readPackageName(dir) {
74
76
  // `paths` entry, so both consumers of the staged tree agree.
75
77
  const runtimePath = path.join(coreRoot, 'runtime.ts')
76
78
  const tryResolve = (m) => { try { return requireFromLib.resolve(m) } catch { return '' } }
77
- const geaPluginResolved = fileURLToPath(import.meta.resolve('@geajs/vite-plugin'))
78
79
  const { parse } = requireFromLib('@babel/parser')
79
80
  const traverseModule = requireFromLib('@babel/traverse')
80
81
  const t = requireFromLib('@babel/types')
@@ -211,7 +212,29 @@ function copyCssUrlAssets(srcCss, dstCss) {
211
212
  const dstAsset = path.resolve(dstDir, clean)
212
213
  if (dstAsset === srcAsset) continue
213
214
  fs.mkdirSync(path.dirname(dstAsset), { recursive: true })
214
- fs.copyFileSync(srcAsset, dstAsset)
215
+ copyAssetAtomically(srcAsset, dstAsset)
216
+ }
217
+ }
218
+
219
+ // A url() that climbs out of the app lands outside this build's own directory
220
+ // (`../../../assets/fonts/x.ttf` staged under `<build>/<app>/` is
221
+ // `<build>/assets/fonts/x.ttf`), where other builds sharing the parent stage
222
+ // the same file at the same time -- core's app pipelines all do. copyFileSync
223
+ // truncates and rewrites in place, so a concurrent reader saw a partial font
224
+ // ("Offset is outside the bounds of the DataView"). Identical bytes are left
225
+ // alone, and new bytes appear in one rename, never half-written.
226
+ function copyAssetAtomically(srcAsset, dstAsset) {
227
+ const bytes = fs.readFileSync(srcAsset)
228
+ if (fs.existsSync(dstAsset) && fs.readFileSync(dstAsset).equals(bytes)) return
229
+ const staged = `${dstAsset}.${process.pid}.tmp`
230
+ fs.writeFileSync(staged, bytes)
231
+ try {
232
+ fs.renameSync(staged, dstAsset)
233
+ } catch (error) {
234
+ // Windows refuses to replace a file another process has open; that
235
+ // process staged it first, so the bytes it holds are these bytes.
236
+ fs.rmSync(staged, { force: true })
237
+ if (!(fs.existsSync(dstAsset) && fs.readFileSync(dstAsset).equals(bytes))) throw error
215
238
  }
216
239
  }
217
240
 
@@ -468,9 +491,9 @@ function resolveAppleNativeAliasModules(appDir) {
468
491
  // another target replaces with its own implementation.
469
492
  // Each replacement names the app's STAGED copy when there is one: the compat
470
493
  // build compiles the staged tree, so an alias into the app directory itself
471
- // pulled a second copy of every module the replacement imports (skytail's
472
- // mosaico kit index brought its own kit-types.ts, and the two KitCategory
473
- // records refused to convert).
494
+ // pulled a second copy of every module the replacement imports (a game's
495
+ // board-specific index brought its own copy of a shared types module, and the
496
+ // two copies of one record type refused to convert).
474
497
  function resolveAppModuleAliases(appDir, stagedAppDir = appDir) {
475
498
  let manifest = {}
476
499
  try {
@@ -528,6 +551,7 @@ function resolveGeaThreeAliasModules(appDir) {
528
551
  }
529
552
 
530
553
  function writeCompatViteConfig({ configPath, compatSrcDir, appDir, stagedAppDir, entry, viteOutDir, geaIrPath, appleNative, moduleGraphOutDir, envDefines }) {
554
+ const geaPluginResolved = resolveAppGeaVitePlugin(appDir, corePackageJson)
531
555
  const engineComponentsPath = path.join(engineRoot, 'components/index.ts')
532
556
  const engineComponentSubpath = path.join(engineRoot, 'components/$1')
533
557
  const elementsPath = path.join(elementsRoot, 'components/index.ts')
@@ -556,7 +580,7 @@ function writeCompatViteConfig({ configPath, compatSrcDir, appDir, stagedAppDir,
556
580
  // disambiguate, and — with all types erased — geatsc is forced to lower the
557
581
  // whole reactive runtime dynamically (gea_cpp_value, ill-formed auto-vectors,
558
582
  // proxy derefs). From source, everything is typed and lowers natively, exactly
559
- // like three.js/hono/mongodb. The env var still wins when explicitly set;
583
+ // like the third-party libraries an app imports. The env var still wins when explicitly set;
560
584
  // otherwise follow ordinary package resolution, including hoisted installs.
561
585
  const compilerRuntimeEntry = process.env.GEA_COMPILER_RUNTIME_SOURCE || resolveCompilerRuntimeEntry([appDir, coreRoot])
562
586
  const compilerRuntimeAlias = compilerRuntimeEntry && fs.existsSync(compilerRuntimeEntry)
@@ -0,0 +1,82 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+
5
+ export function fileAssetDirectory(manifest) {
6
+ const storage = manifest?.gea?.embeddedAssets?.storage;
7
+ if (storage === undefined) return null;
8
+ if (
9
+ storage?.type !== "file" ||
10
+ typeof storage.directory !== "string" ||
11
+ !storage.directory.startsWith("/") ||
12
+ storage.directory === "/" ||
13
+ storage.directory !== path.posix.normalize(storage.directory) ||
14
+ /[\\\0\r\n]/.test(storage.directory)
15
+ ) {
16
+ throw new Error(
17
+ 'gea.embeddedAssets.storage must specify type="file" and a normalized absolute directory',
18
+ );
19
+ }
20
+ return storage.directory;
21
+ }
22
+
23
+ export function fileAssetBundle(appDir, outCpp, kind, entries) {
24
+ const manifestFile = path.join(appDir, "package.json");
25
+ const manifest = fs.existsSync(manifestFile)
26
+ ? JSON.parse(fs.readFileSync(manifestFile, "utf8"))
27
+ : {};
28
+ const directory = fileAssetDirectory(manifest);
29
+ if (!directory || !entries.length) return null;
30
+ const bytes = Buffer.concat(entries.map((entry) => Buffer.from(entry.data)));
31
+ const hash = createHash("sha256").update(bytes).digest("hex");
32
+ const id = String(manifest.gea?.id || path.basename(appDir)).replace(
33
+ /[^a-zA-Z0-9_-]/g,
34
+ "_",
35
+ );
36
+ const filename = `${id}-${kind}-${hash}.bin`;
37
+ const local = path.join(path.dirname(outCpp), filename);
38
+ const remote = path.posix.join(directory, filename);
39
+ fs.mkdirSync(path.dirname(local), { recursive: true });
40
+ if (!fs.existsSync(local) || !fs.readFileSync(local).equals(bytes))
41
+ fs.writeFileSync(local, bytes);
42
+ const deployment = {
43
+ version: 1,
44
+ files: [{ local, remote, bytes: bytes.length, sha256: hash }],
45
+ };
46
+ const metadata = `${outCpp}.assets.json`;
47
+ const contents = JSON.stringify(deployment, null, 2) + "\n";
48
+ if (
49
+ !fs.existsSync(metadata) ||
50
+ fs.readFileSync(metadata, "utf8") !== contents
51
+ )
52
+ fs.writeFileSync(metadata, contents);
53
+ let crc = 0xffffffff;
54
+ for (const value of bytes) {
55
+ crc ^= value;
56
+ for (let bit = 0; bit < 8; bit++)
57
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
58
+ }
59
+ return {
60
+ entries,
61
+ remote,
62
+ bytes: bytes.length,
63
+ crc: (crc ^ 0xffffffff) >>> 0,
64
+ };
65
+ }
66
+
67
+ export function fileAssetLoader(bundle, name) {
68
+ return [
69
+ `static bool ${name}() {`,
70
+ " static const bool ready = [] {",
71
+ " const gea::platform::storage::AssetSegment segments[] = {",
72
+ ...bundle.entries.map(
73
+ (entry) => ` {${entry.symbol}, ${entry.data.length}},`,
74
+ ),
75
+ " };",
76
+ ` return gea::platform::storage::loadAssetBundle(${JSON.stringify(bundle.remote)}, segments, ${bundle.entries.length}, ${bundle.bytes}, ${bundle.crc}U);`,
77
+ " }();",
78
+ " return ready;",
79
+ "}",
80
+ "",
81
+ ];
82
+ }
@@ -35,7 +35,9 @@ export function geaNativeStylePlugin({ runtimeEntry }) {
35
35
  return [path.join(sourceRoot, `${name}.ts`), path.join(sourceRoot, name, 'index.ts')].find(entry => fs.existsSync(entry)) || null
36
36
  },
37
37
  load(id) {
38
- if (id.split('?')[0] !== serializer) return null
38
+ // Vite spells module ids with forward slashes on Windows too; normalize
39
+ // before comparing with the native path (a no-op on POSIX).
40
+ if (path.normalize(id.split('?')[0]) !== serializer) return null
39
41
  // This module is the browser serialization boundary, not the JSX IR.
40
42
  // Replacing it here preserves numeric values in the native IR and prevents
41
43
  // browser px defaults from reaching native runtime-backed components/h().
@@ -0,0 +1,49 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { execFileSync } from 'node:child_process'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ /** Honor an app's declared compiler plugin; only legacy undeclared apps inherit Core's. */
7
+ function resolveImport(directory) {
8
+ // Let Node apply real ESM export conditions (the plugin is import-only).
9
+ const url = execFileSync(process.execPath,
10
+ ['--input-type=module', '-e', "process.stdout.write(import.meta.resolve('@geajs/vite-plugin'))"],
11
+ { cwd: directory, encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'] })
12
+ return fs.realpathSync(fileURLToPath(url))
13
+ }
14
+
15
+ function workspaceOwner(appDir) {
16
+ const app = path.resolve(appDir)
17
+ if (fs.existsSync(path.join(app, 'node_modules', '@geajs', 'vite-plugin'))) return null
18
+ for (let dir = path.dirname(app); dir !== path.dirname(dir); dir = path.dirname(dir)) {
19
+ let manifest
20
+ try { manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) } catch { continue }
21
+ const patterns = Array.isArray(manifest.workspaces) ? manifest.workspaces : manifest.workspaces?.packages ?? []
22
+ const relative = path.relative(dir, app).split(path.sep).join('/')
23
+ if (patterns.some(pattern => path.posix.matchesGlob(relative, pattern.replace(/\/+$/, '')))) return dir
24
+ }
25
+ return null
26
+ }
27
+
28
+ export function resolveAppGeaVitePlugin(appDir, corePackageJson) {
29
+ const manifest = path.join(appDir, 'package.json')
30
+ // A bare fixture directory has no manifest and so declares no plugin of its own.
31
+ const app = fs.existsSync(manifest) ? JSON.parse(fs.readFileSync(manifest, 'utf8')) : {}
32
+ const declared = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
33
+ .some(field => Object.hasOwn(app[field] || {}, '@geajs/vite-plugin'))
34
+ if (!declared) return resolveImport(path.dirname(corePackageJson instanceof URL ? fileURLToPath(corePackageJson) : corePackageJson))
35
+ // Resolving from the app may otherwise walk up to an unrelated ancestor install.
36
+ // Require an actual app-owned installation entry, or the hoisted one of an npm
37
+ // workspace root that lists this app among its workspaces.
38
+ const installed = path.join(workspaceOwner(appDir) ?? appDir, 'node_modules', '@geajs', 'vite-plugin')
39
+ try {
40
+ const packageRoot = fs.realpathSync(installed)
41
+ const actual = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'))
42
+ if (actual.name !== '@geajs/vite-plugin') throw new Error('installed package name mismatch')
43
+ const resolved = resolveImport(appDir)
44
+ if (!resolved.startsWith(packageRoot + path.sep)) throw new Error('resolved outside declared installation')
45
+ return resolved
46
+ } catch (error) {
47
+ throw new Error(`App declares @geajs/vite-plugin but its installation is unavailable: ${error.message}`)
48
+ }
49
+ }
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { describeStaticAsset } from './gea-static-asset.mjs'
26
26
  import { compileTimeOnlyAssetPaths } from './gea-embedded-asset-policy.mjs'
27
+ import { fileAssetBundle, fileAssetLoader } from './gea-file-asset-bundle.mjs'
27
28
  import fs from 'node:fs'
28
29
  import path from 'node:path'
29
30
  import process from 'node:process'
@@ -230,12 +231,19 @@ function writeFileIfChanged(filePath, contents) {
230
231
  }
231
232
 
232
233
  const assets = collectAssets(appDir)
234
+ const bundleEntries = new Map()
235
+ for (const file of assets) {
236
+ const symbol = assetSymbol(relPath(file))
237
+ if (!bundleEntries.has(symbol)) bundleEntries.set(symbol, { symbol, data: fs.readFileSync(file) })
238
+ }
239
+ const bundle = fileAssetBundle(appDir, outCpp, 'images', [...bundleEntries.values()])
233
240
 
234
241
  const parts = []
235
242
  parts.push('// Generated by scripts/generate-gea-embedded-assets.mjs. Do not edit by hand.')
236
243
  parts.push(`// app: ${relPath(appDir) || path.basename(appDir)}`)
237
244
  parts.push('')
238
245
  parts.push('#include <cstring>')
246
+ if (bundle) parts.push('#include "platform/asset_bundle.h"')
239
247
  parts.push('')
240
248
 
241
249
  if (assets.length === 0) {
@@ -280,14 +288,23 @@ if (assets.length === 0) {
280
288
  // rejects `weak` on an internal const. `extern "C"` + initializer = a weak
281
289
  // definition with external, unmangled (namespace-independent) linkage,
282
290
  // which both GCC and Clang accept.
283
- parts.push(`extern "C" __attribute__((weak)) const unsigned char ${sym}[] = {`)
284
- parts.push(byteArrayBody(buffer))
285
- parts.push('};')
291
+ if (bundle) {
292
+ parts.push(`extern "C" { __attribute__((weak)) GEA_FILE_ASSET_BUFFER unsigned char ${sym}[${Math.max(1, buffer.length)}]; }`)
293
+ } else {
294
+ parts.push(`extern "C" __attribute__((weak)) const unsigned char ${sym}[] = {`)
295
+ parts.push(byteArrayBody(buffer))
296
+ parts.push('};')
297
+ }
286
298
  parts.push(`extern "C" __attribute__((weak)) const unsigned long ${sym}_len = ${buffer.length}UL;`)
287
299
  parts.push('')
288
300
  }
301
+ if (bundle) {
302
+ parts.push(...fileAssetLoader(bundle, 'prepareImageAssets'))
303
+ parts.push('extern "C" bool gea_embedded_assets_prepare() { return prepareImageAssets(); }')
304
+ }
289
305
  parts.push('extern "C" __attribute__((weak)) bool gea_embedded_asset_lookup(const char *path, const unsigned char **data, unsigned long *length) {')
290
306
  parts.push(' if (!path || !data || !length) return false;')
307
+ if (bundle) parts.push(' if (!prepareImageAssets()) return false;')
291
308
  for (const file of assets) {
292
309
  const rel = relPath(file)
293
310
  const sym = assetSymbol(rel)
@@ -11,6 +11,7 @@ import { execFileSync } from "node:child_process";
11
11
  import { createHash } from "node:crypto";
12
12
  import { createRequire } from "node:module";
13
13
  import { fileURLToPath } from "node:url";
14
+ import { fileAssetBundle, fileAssetLoader } from "./gea-file-asset-bundle.mjs";
14
15
 
15
16
  const args = process.argv.slice(2);
16
17
 
@@ -1060,6 +1061,7 @@ function generatedCpp(fonts, families) {
1060
1061
  // this TU too (a weak symbol can't be defined twice in one TU); `font_data`
1061
1062
  // points at the shared symbol either way.
1062
1063
  const atlasSymbols = new Map();
1064
+ const atlasEntries = [];
1063
1065
  for (const font of fonts) {
1064
1066
  const hash = createHash("sha1")
1065
1067
  .update(Buffer.from(font.atlasData))
@@ -1069,14 +1071,23 @@ function generatedCpp(fonts, families) {
1069
1071
  font.atlasSymbol = symbol;
1070
1072
  if (atlasSymbols.has(hash)) continue;
1071
1073
  atlasSymbols.set(hash, symbol);
1074
+ atlasEntries.push({ symbol, data: font.atlasData });
1075
+ }
1076
+ const bundle = fileAssetBundle(appDir, outCpp, 'fonts', atlasEntries);
1077
+ if (bundle) lines.unshift('#include "platform/asset_bundle.h"');
1078
+ for (const { symbol, data } of atlasEntries) {
1079
+ if (bundle) {
1080
+ lines.push(`__attribute__((weak)) GEA_FILE_ASSET_BUFFER std::uint8_t ${symbol}[${Math.max(1, data.length)}];`);
1081
+ continue;
1082
+ }
1072
1083
  // `extern` + initializer: a namespace-scope `const` is internal by default,
1073
1084
  // and `weak` requires external linkage ("weak declaration must be public").
1074
1085
  lines.push(
1075
- `extern __attribute__((weak)) const std::uint8_t ${symbol}[${font.atlasData.length}] = {`,
1086
+ `extern __attribute__((weak)) const std::uint8_t ${symbol}[${data.length}] = {`,
1076
1087
  );
1077
- for (let i = 0; i < font.atlasData.length; i += 32) {
1088
+ for (let i = 0; i < data.length; i += 32) {
1078
1089
  const chunk = Array.from(
1079
- font.atlasData.slice(i, Math.min(i + 32, font.atlasData.length)),
1090
+ data.slice(i, Math.min(i + 32, data.length)),
1080
1091
  );
1081
1092
  lines.push(
1082
1093
  ` ${chunk.map((byte) => `0x${byte.toString(16).padStart(2, "0")}`).join(", ")},`,
@@ -1085,6 +1096,7 @@ function generatedCpp(fonts, families) {
1085
1096
  lines.push("};");
1086
1097
  lines.push("");
1087
1098
  }
1099
+ if (bundle) lines.push(...fileAssetLoader(bundle, 'prepareFontAssets'));
1088
1100
 
1089
1101
  for (const font of fonts) {
1090
1102
  // `atlasBits` defaults to 8 in RasterizedFontData, so an 8-bit atlas leaves
@@ -1144,12 +1156,14 @@ function generatedCpp(fonts, families) {
1144
1156
  lines.push("");
1145
1157
  lines.push(`void ${names.ensureLinked}()`);
1146
1158
  lines.push("{");
1159
+ if (bundle) lines.push(' (void)prepareFontAssets();');
1147
1160
  lines.push("}");
1148
1161
  lines.push("");
1149
1162
  lines.push(
1150
1163
  `const gea::framework::graphics::RasterizedFontData *${names.lookupFont}(int fontId)`,
1151
1164
  );
1152
1165
  lines.push("{");
1166
+ if (bundle) lines.push(' if (!prepareFontAssets()) return nullptr;');
1153
1167
  lines.push(" for (const auto &entry : generated_fonts) {");
1154
1168
  lines.push(
1155
1169
  " if (entry.font && entry.font->id == fontId) return entry.font;",
@@ -1162,6 +1176,7 @@ function generatedCpp(fonts, families) {
1162
1176
  `const gea::framework::graphics::RasterizedFontData *${names.lookupFontForFamily}(int familyId, int sizePx)`,
1163
1177
  );
1164
1178
  lines.push("{");
1179
+ if (bundle) lines.push(' if (!prepareFontAssets()) return nullptr;');
1165
1180
  lines.push(" const RasterizedFontData *best = nullptr;");
1166
1181
  lines.push(" int bestDiff = 2147483647;");
1167
1182
  lines.push(" for (const auto &entry : generated_fonts) {");
@@ -1510,7 +1525,13 @@ function writeFileIfChanged(filePath, contents) {
1510
1525
  function writeGeneratedFonts() {
1511
1526
  const fontFaces = collectSourceFontFaces();
1512
1527
  const tuples = collectUsedFontTuples(fontFaces);
1513
- const familyNames = [...new Set(tuples.map((tuple) => tuple.family))].sort();
1528
+ // Runtime fonts are selected at actual layout time, including CSS variables
1529
+ // and programmatic styles with no statically discoverable family/size tuple.
1530
+ // Keep every explicitly declared local face; raster-only builds retain their
1531
+ // existing tuple-pruned size/charset policy.
1532
+ const familyNames = runtimeTtfFonts
1533
+ ? [...fontFaces.keys()].sort()
1534
+ : [...new Set(tuples.map((tuple) => tuple.family))].sort();
1514
1535
  const families = familyNames.map((name, id) => ({ id, name }));
1515
1536
  const familyIds = new Map(families.map((family) => [family.name, family.id]));
1516
1537
 
@@ -0,0 +1,46 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { resolveAppGeaVitePlugin } from '../gea-vite-plugin-resolution.mjs'
7
+
8
+ test('declared app plugin honors its real package/version and symlink; undeclared alone inherits Core', () => {
9
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gea-plugin-resolution-')))
10
+ try {
11
+ const app = path.join(root,'app'), core = path.join(root,'core')
12
+ for (const p of [app,core]) { fs.mkdirSync(p); fs.writeFileSync(path.join(p,'package.json'),'{}') }
13
+ const install = (parent, version) => {
14
+ const pkg=path.join(parent,'node_modules/@geajs/vite-plugin'); fs.mkdirSync(pkg,{recursive:true})
15
+ fs.writeFileSync(path.join(pkg,'package.json'),JSON.stringify({name:'@geajs/vite-plugin',version,type:'module',exports:{'.':{import:'./index.mjs'}}}))
16
+ fs.writeFileSync(path.join(pkg,'index.mjs'),`export const version=${JSON.stringify(version)}`)
17
+ return pkg
18
+ }
19
+ const legacy=install(core,'1.4.1')
20
+ assert.equal(resolveAppGeaVitePlugin(app,path.join(core,'package.json')),path.join(legacy,'index.mjs'))
21
+ fs.writeFileSync(path.join(app,'package.json'),JSON.stringify({devDependencies:{'@geajs/vite-plugin':'file:../source'}}))
22
+ assert.throws(()=>resolveAppGeaVitePlugin(app,path.join(core,'package.json')),/App declares/)
23
+ const source=install(root,'1.5.0')
24
+ fs.mkdirSync(path.join(app,'node_modules/@geajs'),{recursive:true})
25
+ fs.symlinkSync(source,path.join(app,'node_modules/@geajs/vite-plugin'))
26
+ assert.equal(resolveAppGeaVitePlugin(app,path.join(core,'package.json')),path.join(source,'index.mjs'))
27
+ fs.unlinkSync(path.join(source,'index.mjs'))
28
+ assert.throws(()=>resolveAppGeaVitePlugin(app,path.join(core,'package.json')),/App declares/)
29
+ } finally { fs.rmSync(root,{recursive:true,force:true}) }
30
+ })
31
+
32
+ test('declared app plugin hoisted to its npm workspace root resolves; an unlisted ancestor install does not', () => {
33
+ const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gea-plugin-workspace-')))
34
+ try {
35
+ const app = path.join(root,'apps/clock'), stray = path.join(root,'tools/stray'), core = path.join(root,'core')
36
+ for (const p of [app,stray,core]) fs.mkdirSync(p,{recursive:true})
37
+ for (const p of [app,stray]) fs.writeFileSync(path.join(p,'package.json'),JSON.stringify({devDependencies:{'@geajs/vite-plugin':'^1.4.2'}}))
38
+ fs.writeFileSync(path.join(core,'package.json'),'{}')
39
+ fs.writeFileSync(path.join(root,'package.json'),JSON.stringify({private:true,workspaces:['apps/*']}))
40
+ const pkg=path.join(root,'node_modules/@geajs/vite-plugin'); fs.mkdirSync(pkg,{recursive:true})
41
+ fs.writeFileSync(path.join(pkg,'package.json'),JSON.stringify({name:'@geajs/vite-plugin',version:'1.4.2',type:'module',exports:{'.':{import:'./index.mjs'}}}))
42
+ fs.writeFileSync(path.join(pkg,'index.mjs'),'export const version="1.4.2"')
43
+ assert.equal(resolveAppGeaVitePlugin(app,path.join(core,'package.json')),path.join(pkg,'index.mjs'))
44
+ assert.throws(()=>resolveAppGeaVitePlugin(stray,path.join(core,'package.json')),/App declares/)
45
+ } finally { fs.rmSync(root,{recursive:true,force:true}) }
46
+ })