Skip to content

Commit f27b5cb

Browse files
committed
fix(desktop): stabilize Linux local-source startup
1 parent 0c6e768 commit f27b5cb

4 files changed

Lines changed: 237 additions & 18 deletions

File tree

apps/app/test/package-mode-aliases.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,63 @@ describe("package mode aliases", () => {
105105
expect(patchScript).toContain("EXTRACT_ACTION_PARAMS_TEMPLATE");
106106
expect(patchScript).toContain("extractActionParamsTemplate");
107107
});
108+
109+
it("keeps generated native module proxy stubs compatible with WebKit invariants", () => {
110+
const viteConfigText = fs.readFileSync(
111+
path.join(appRoot, "vite.config.ts"),
112+
"utf8",
113+
);
114+
115+
expect(viteConfigText).toContain("ownKeys(t) { return Reflect.ownKeys(t); }");
116+
expect(viteConfigText).toContain(
117+
"getOwnPropertyDescriptor(t, p) { return Reflect.getOwnPropertyDescriptor(t, p)",
118+
);
119+
expect(viteConfigText).toContain("p === 'prototype' || p === 'name' || p === 'length'");
120+
expect(viteConfigText).not.toContain("ownKeys() { return []; }");
121+
expect(viteConfigText).not.toContain("p === 'prototype') return {}");
122+
});
123+
124+
it("prefers local source aliases before stale package dist fallbacks", () => {
125+
const viteConfigText = fs.readFileSync(
126+
path.join(appRoot, "vite.config.ts"),
127+
"utf8",
128+
);
129+
130+
expect(viteConfigText).toContain(
131+
"const runtimeTarget = resolveRuntimeTarget(pkgDir, exportTarget)",
132+
);
133+
expect(viteConfigText).toContain("fs.existsSync(runtimeTarget)");
134+
expect(viteConfigText).toContain("replacement: runtimeTarget");
135+
expect(viteConfigText).toContain("@elizaos\\/ui\\/platform");
136+
expect(viteConfigText).toContain("@elizaos\\/ui\\/(.+)");
137+
expect(viteConfigText).toContain("fs.statSync(candidate).isFile()");
138+
expect(viteConfigText).not.toContain("fs.existsSync(resolvedTarget)");
139+
});
140+
141+
it("aliases React entrypoints to one resolved package copy", () => {
142+
const viteConfigText = fs.readFileSync(
143+
path.join(appRoot, "vite.config.ts"),
144+
"utf8",
145+
);
146+
147+
expect(viteConfigText).toContain('requireResolve("react")');
148+
expect(viteConfigText).toContain('requireResolve("react/jsx-runtime")');
149+
expect(viteConfigText).toContain('find: /^react-dom\\/client$/');
150+
expect(viteConfigText).toContain("replacement: reactDomClientEntry");
151+
});
152+
153+
it("keeps the local app-core browser surface aligned with app imports", () => {
154+
const appCoreBrowserPath = path.resolve(
155+
appRoot,
156+
"../..",
157+
"eliza/packages/app-core/src/browser.ts",
158+
);
159+
if (!fs.existsSync(appCoreBrowserPath)) {
160+
return;
161+
}
162+
163+
const appCoreBrowserText = fs.readFileSync(appCoreBrowserPath, "utf8");
164+
expect(appCoreBrowserText).toContain("resolveIosRuntimeConfig");
165+
expect(appCoreBrowserText).toContain("type IosRuntimeConfig");
166+
});
108167
});

apps/app/vite.config.ts

Lines changed: 147 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ function tryResolve(id: string): string | undefined {
126126
const capacitorKeyboardEntry = tryResolve("@capacitor/keyboard");
127127
const capacitorPreferencesEntry = tryResolve("@capacitor/preferences");
128128
const capacitorAppEntry = tryResolve("@capacitor/app");
129+
const reactEntry = requireResolve("react");
130+
const reactJsxRuntimeEntry = requireResolve("react/jsx-runtime");
131+
const reactJsxDevRuntimeEntry = requireResolve("react/jsx-dev-runtime");
132+
const reactDomEntry = requireResolve("react-dom");
133+
const reactDomClientEntry = requireResolve("react-dom/client");
129134
// `@elizaos/app-core` is always real. `@elizaos/app-wallet` is required by
130135
// onboarding callbacks + AppContext (useWalletState), so resolve it real
131136
// when present. `app-hyperscape` is real when its package is present.
@@ -241,6 +246,15 @@ function resolveLocalUiAliases(): Alias[] {
241246
find: /^@elizaos\/ui$/,
242247
replacement: path.join(uiPkgRoot, "src/index.ts"),
243248
},
249+
{
250+
find: /^@elizaos\/ui\/api$/,
251+
replacement: path.join(uiPkgRoot, "src/api/index.ts"),
252+
},
253+
{
254+
find: /^@elizaos\/ui\/api\/(.*)$/,
255+
replacement: `${uiPkgRoot}/src/api/$1.ts`,
256+
customResolver: resolveExistingUiSourceModule,
257+
},
244258
{
245259
find: /^@elizaos\/ui\/components\/ui\/(.*)$/,
246260
replacement: `${uiPkgRoot}/src/components/ui/$1.tsx`,
@@ -280,10 +294,20 @@ function resolveLocalUiAliases(): Alias[] {
280294
find: /^@elizaos\/ui\/layouts\/(.+)\/([^/]+)$/,
281295
replacement: `${uiPkgRoot}/src/layouts/$1/$2.tsx`,
282296
},
297+
{
298+
find: /^@elizaos\/ui\/platform\/(.*)$/,
299+
replacement: `${uiPkgRoot}/src/platform/$1.ts`,
300+
customResolver: resolveExistingUiSourceModule,
301+
},
283302
{
284303
find: /^@elizaos\/ui\/lib\/(.*)$/,
285304
replacement: `${uiPkgRoot}/src/lib/$1.ts`,
286305
},
306+
{
307+
find: /^@elizaos\/ui\/(.+)$/,
308+
replacement: `${uiPkgRoot}/src/$1`,
309+
customResolver: resolveExistingUiSourceModule,
310+
},
287311
];
288312
}
289313

@@ -353,16 +377,16 @@ function resolveLocalElizaAppAliases(): Alias[] {
353377
for (const [key, value] of Object.entries(pkg.exports || {})) {
354378
const exportTarget = resolveExportTarget(value);
355379
if (!exportTarget) continue;
356-
const resolvedTarget = path.resolve(pkgDir, exportTarget);
357-
// Only create an alias when the target file actually exists on disk.
358-
// In a fresh local clone, dist/ may not be built yet. Skipping the
359-
// alias lets the import fall through to the stub or npm package.
360-
if (!fs.existsSync(resolvedTarget)) continue;
380+
const runtimeTarget = resolveRuntimeTarget(pkgDir, exportTarget);
381+
// Prefer local source targets when the package export points at dist/.
382+
// Fresh local clones often have src/ but no dist/ yet; checking dist
383+
// first lets imports fall through to stale npm/Bun-store packages.
384+
if (!fs.existsSync(runtimeTarget)) continue;
361385
const aliasKey =
362386
key === "." ? pkgName : `${pkgName}/${key.replace(/^\.\//, "")}`;
363387
aliases.push({
364388
find: new RegExp(`^${escapeRegExp(aliasKey)}$`),
365-
replacement: resolveRuntimeTarget(pkgDir, exportTarget),
389+
replacement: runtimeTarget,
366390
});
367391
}
368392

@@ -488,21 +512,21 @@ function resolveLocalAppCoreAliases(): Alias[] {
488512

489513
for (const [key, value] of Object.entries(appCorePkg.exports || {})) {
490514
if (key === ".") continue; // handled by the explicit bare alias above
491-
if (typeof value !== "string") continue;
492-
const aliasKey =
493-
key === "."
494-
? "@elizaos/app-core"
495-
: `@elizaos/app-core/${key.replace(/^\.\//, "")}`;
496-
497-
// Resolve the string value, handling both plain strings and conditional exports.
498515
const resolvedValue: string | null =
499516
typeof value === "string"
500517
? value
501518
: typeof value === "object" && value !== null
502-
? ((value as Record<string, string>).import ??
519+
? ((value as Record<string, string>).source ??
520+
(value as Record<string, string>).import ??
503521
(value as Record<string, string>).default ??
504522
null)
505523
: null;
524+
if (!resolvedValue) continue;
525+
526+
const aliasKey =
527+
key === "."
528+
? "@elizaos/app-core"
529+
: `@elizaos/app-core/${key.replace(/^\.\//, "")}`;
506530

507531
// CSS files in app-core exports point to dist paths (e.g. ./styles/styles.css).
508532
// In Wave A these moved to @elizaos/ui. If the dist path doesn't exist locally,
@@ -549,6 +573,49 @@ function resolveLocalAppCoreAliases(): Alias[] {
549573

550574
const uiSource = path.join(appCoreSrcRoot, "ui");
551575
const uiPkgSrcRoot = uiPkgRoot ? path.join(uiPkgRoot, "src") : null;
576+
// Wave A moved styles from @elizaos/app-core to @elizaos/ui. Keep an
577+
// explicit redirect ahead of the catch-all so local-source builds do not
578+
// resolve CSS requests to missing app-core source files.
579+
const uiStylesSourceDir = uiPkgRoot
580+
? path.join(uiPkgRoot, "src/styles")
581+
: null;
582+
const appCoreStylesLocalDir = path.join(appCoreSrcRoot, "styles");
583+
const cssRedirectAlias: Alias[] =
584+
uiStylesSourceDir &&
585+
fs.existsSync(path.join(uiStylesSourceDir, "styles.css")) &&
586+
!fs.existsSync(path.join(appCoreStylesLocalDir, "styles.css"))
587+
? [
588+
{
589+
find: /^@elizaos\/app-core\/styles\/(.+\.css)$/,
590+
replacement: `${uiStylesSourceDir}/$1`,
591+
},
592+
]
593+
: [];
594+
const uiComponentsSourceDir = uiPkgRoot ? path.join(uiPkgRoot, "src") : null;
595+
596+
function resolveAppCoreWithUiFallback(id: string): string {
597+
if (fs.existsSync(id)) return id;
598+
const withTsx = id.endsWith(".tsx") ? id : `${id}.tsx`;
599+
if (fs.existsSync(withTsx)) return withTsx;
600+
const withTs = id.endsWith(".ts") ? id : `${id}.ts`;
601+
if (fs.existsSync(withTs)) return withTs;
602+
603+
if (uiComponentsSourceDir && id.startsWith(`${appCoreSrcRoot}${path.sep}`)) {
604+
const relativeToAppCoreSrc = id.slice(appCoreSrcRoot.length + 1);
605+
const uiEquivalent = path.join(
606+
uiComponentsSourceDir,
607+
relativeToAppCoreSrc,
608+
);
609+
if (fs.existsSync(uiEquivalent)) return uiEquivalent;
610+
const uiEquivalentTsx = `${uiEquivalent}.tsx`;
611+
if (fs.existsSync(uiEquivalentTsx)) return uiEquivalentTsx;
612+
const uiEquivalentTs = `${uiEquivalent}.ts`;
613+
if (fs.existsSync(uiEquivalentTs)) return uiEquivalentTs;
614+
}
615+
616+
return id;
617+
}
618+
552619
const legacyAppCoreUiAliases: Alias[] = uiPkgSrcRoot
553620
? [
554621
{
@@ -840,7 +907,7 @@ function resolveExistingUiSourceModule(id: string) {
840907
}
841908

842909
for (const candidate of candidates) {
843-
if (fs.existsSync(candidate)) {
910+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
844911
return candidate;
845912
}
846913
}
@@ -1310,7 +1377,7 @@ function generateNodeBuiltinStub(moduleId: string, req = _require): string {
13101377
// * mutation traps (set / defineProperty) don't throw under strict mode
13111378
// * `instanceof`, `default`, `__esModule` resolve sensibly for ESM<->CJS
13121379
"function noopFn() { return noop; }",
1313-
"const handler = { get(t, p) { if (typeof p === 'symbol') return undefined; if (p === '__esModule') return true; if (p === 'default') return noop; if (p === 'prototype') return {}; if (p in t) return t[p]; return noop; }, set(t, p, v) { try { t[p] = v; } catch {} return true; }, has() { return true; }, ownKeys() { return []; }, getOwnPropertyDescriptor() { return { configurable: true, enumerable: true }; }, apply() { return noop; }, construct() { return noop; }, defineProperty(t, p, d) { try { Object.defineProperty(t, p, { configurable: true, writable: true, enumerable: true, ...d }); } catch {} return true; } };",
1380+
"const handler = { get(t, p) { if (p === 'prototype' || p === 'name' || p === 'length' || typeof p === 'symbol') return Reflect.get(t, p); if (p === '__esModule') return true; if (p === 'default') return noop; if (p in t) return t[p]; return noop; }, set(t, p, v) { try { t[p] = v; } catch {} return true; }, has() { return true; }, ownKeys(t) { return Reflect.ownKeys(t); }, getOwnPropertyDescriptor(t, p) { return Reflect.getOwnPropertyDescriptor(t, p) ?? { configurable: true, enumerable: true, writable: true, value: noop }; }, apply() { return noop; }, construct() { return noop; }, defineProperty(t, p, d) { try { Object.defineProperty(t, p, { configurable: true, writable: true, enumerable: true, ...d }); } catch {} return true; } };",
13141381
"const noop = new Proxy(noopFn, handler);",
13151382
"const stub = noop;",
13161383
"const asyncNoop = () => Promise.resolve();",
@@ -1754,6 +1821,7 @@ function generatePluginElizacloudStub(): string {
17541821
// agent runtime modules. The renderer never enters those code paths; the
17551822
// stub satisfies Rollup's static analysis and trees away at module init.
17561823
const PLUGIN_LOCAL_INFERENCE_STUB_NAMES = [
1824+
"detectEmbeddingPreset",
17571825
"getLocalInferenceActiveModelId",
17581826
"getLocalInferenceActiveSnapshot",
17591827
"getLocalInferenceChatStatus",
@@ -1765,6 +1833,53 @@ function generatePluginLocalInferenceStub(): string {
17651833
return generateNamedExportStub(PLUGIN_LOCAL_INFERENCE_STUB_NAMES);
17661834
}
17671835

1836+
function generatePluginAgentSkillsStub(): string {
1837+
return generateNamedExportStub([
1838+
"discoverSkills",
1839+
"handleCuratedSkillsRoutes",
1840+
"handleSkillsRoutes",
1841+
]);
1842+
}
1843+
1844+
function generatePluginAppManagerStub(): string {
1845+
return [
1846+
"const noop = () => undefined;",
1847+
"const asyncFalse = async () => false;",
1848+
"export class AppManager {}",
1849+
"export const handleAppsRoutes = asyncFalse;",
1850+
"export const readAppRunStore = () => [];",
1851+
"export const resolveAppRunStoreFilePath = () => '';",
1852+
"export const resolveLegacyAppRunStoreFilePath = () => '';",
1853+
"export const writeAppRunStore = noop;",
1854+
"export default new Proxy(noop, { get: () => noop, apply: () => undefined });",
1855+
].join("\n");
1856+
}
1857+
1858+
function generatePluginRegistryStub(): string {
1859+
return [
1860+
"const noop = () => undefined;",
1861+
"const asyncFalse = async () => false;",
1862+
"const emptyPluginList = () => ({ plugins: [], categories: [], installed: [] });",
1863+
"export const buildPluginListResponse = emptyPluginList;",
1864+
"export const handlePluginRoutes = asyncFalse;",
1865+
"export const handlePluginsCompatRoutes = asyncFalse;",
1866+
"export const installAndRestart = noop;",
1867+
"export const installPlugin = noop;",
1868+
"export const listInstalledPlugins = () => [];",
1869+
"export const uninstallAndRestart = noop;",
1870+
"export const uninstallPlugin = noop;",
1871+
"export default new Proxy(noop, { get: () => noop, apply: () => undefined });",
1872+
].join("\n");
1873+
}
1874+
1875+
function generatePluginWalletStub(): string {
1876+
return generateNamedExportStub(["handleWalletRoutes"]);
1877+
}
1878+
1879+
function generatePluginX402Stub(): string {
1880+
return generateNamedExportStub(["validateX402Startup"]);
1881+
}
1882+
17681883
function generateAgentPluginAutoEnableStub(): string {
17691884
return [
17701885
"export const CONNECTOR_PLUGINS = {};",
@@ -1982,6 +2097,11 @@ const NATIVE_MODULE_STUB_GENERATORS = new Map<
19822097
["async_hooks", generateAsyncHooksStub],
19832098
["@elizaos/plugin-elizacloud", generatePluginElizacloudStub],
19842099
["@elizaos/plugin-local-inference", generatePluginLocalInferenceStub],
2100+
["@elizaos/plugin-agent-skills", generatePluginAgentSkillsStub],
2101+
["@elizaos/plugin-app-manager", generatePluginAppManagerStub],
2102+
["@elizaos/plugin-registry", generatePluginRegistryStub],
2103+
["@elizaos/plugin-wallet", generatePluginWalletStub],
2104+
["@elizaos/plugin-x402", generatePluginX402Stub],
19852105
["esbuild", generateEsbuildStub],
19862106
// @node-rs/argon2's server-side Rust binding is referenced by
19872107
// app-core's password-hashing helpers. Renderer never executes them
@@ -2091,6 +2211,7 @@ function nativeModuleStubPlugin(): Plugin {
20912211
"@elizaos/plugin-sql",
20922212
"@elizaos/plugin-agent-skills",
20932213
"@elizaos/plugin-agent-orchestrator",
2214+
"@elizaos/plugin-app-manager",
20942215
// The agent runtime is server-only — it lives in the API child
20952216
// process, not in the renderer. app-core/dist code can leak agent
20962217
// imports (account-pool etc.); stub them so Rollup doesn't try to
@@ -2100,6 +2221,11 @@ function nativeModuleStubPlugin(): Plugin {
21002221
// tts proxy routes). Renderer references the exported names but
21012222
// never executes the code; named-export stub registered above.
21022223
"@elizaos/plugin-elizacloud",
2224+
// Server-side plugin install/discovery routes. The renderer only needs
2225+
// static named exports to resolve when app-core server barrels leak in.
2226+
"@elizaos/plugin-registry",
2227+
"@elizaos/plugin-wallet",
2228+
"@elizaos/plugin-x402",
21032229
// @node-rs/argon2 has a wasm32-wasi variant that browser builds
21042230
// surface via dynamic import. The browser can't resolve the bare
21052231
// specifier at runtime; stub it so the bundle loads. Real hashing
@@ -2544,6 +2670,11 @@ export default defineConfig({
25442670
alias: [
25452671
// Bare Node built-in polyfills for browser — pathe provides ESM path,
25462672
// events is pre-bundled via optimizeDeps.
2673+
{ find: /^react$/, replacement: reactEntry },
2674+
{ find: /^react\/jsx-runtime$/, replacement: reactJsxRuntimeEntry },
2675+
{ find: /^react\/jsx-dev-runtime$/, replacement: reactJsxDevRuntimeEntry },
2676+
{ find: /^react-dom$/, replacement: reactDomEntry },
2677+
{ find: /^react-dom\/client$/, replacement: reactDomClientEntry },
25472678
{ find: /^path$/, replacement: patheEntry },
25482679
{ find: /^@capacitor\/core$/, replacement: capacitorCoreEntry },
25492680
// Aliases for Capacitor packages that may not be hoisted to root node_modules

scripts/cleanup-desktop-orphans.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
*
66
* Two failure modes this handles:
77
*
8-
* 1. **Orphan dev-server.ts / Milady-dev.app processes.** The Electrobun
8+
* 1. **Orphan dev-server.ts / Milady-dev processes.** The Electrobun
99
* orchestrator spawns `bun --watch eliza/packages/app-core/src/runtime/dev-server.ts`
10-
* plus a Milady-dev.app launcher. If the parent terminal closed
10+
* plus a Milady-dev launcher. If the parent terminal closed
1111
* ungracefully (Ctrl-Z, `pkill -9 dev-platform`, IDE crash), the
1212
* children survive — they still hold port 31337, the PGlite WAL,
1313
* and the `eliza-pglite.lock` file. A fresh `dev:desktop` then
@@ -64,6 +64,9 @@ const ORPHAN_PATTERNS = [
6464
// Milady-dev.app launcher binary and its bun child.
6565
"Milady-dev.app/Contents/MacOS/launcher",
6666
"Milady-dev.app/Contents/MacOS/../Resources/main.js",
67+
// Linux Electrobun dev bundle launcher and packaged Bun child.
68+
"build/dev-linux-x64/Milady-dev/bin/launcher",
69+
"build/dev-linux-x64/Milady-dev/bin/../Resources/main.js",
6770
// The dev-platform orchestrator itself.
6871
"eliza/packages/app-core/scripts/dev-platform.mjs",
6972
// The Milady-side wrapper that delegates to dev-platform.mjs.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { describe, expect, it } from "vitest";
5+
6+
const here = path.dirname(fileURLToPath(import.meta.url));
7+
const scriptText = fs.readFileSync(
8+
path.join(here, "cleanup-desktop-orphans.mjs"),
9+
"utf8",
10+
);
11+
12+
describe("cleanup-desktop-orphans", () => {
13+
it("matches Linux Electrobun dev bundle orphans", () => {
14+
expect(scriptText).toContain("build/dev-linux-x64/Milady-dev/bin/launcher");
15+
expect(scriptText).toContain(
16+
"build/dev-linux-x64/Milady-dev/bin/../Resources/main.js",
17+
);
18+
});
19+
20+
it("keeps macOS Electrobun orphan coverage", () => {
21+
expect(scriptText).toContain("Milady-dev.app/Contents/MacOS/launcher");
22+
expect(scriptText).toContain(
23+
"Milady-dev.app/Contents/MacOS/../Resources/main.js",
24+
);
25+
});
26+
});

0 commit comments

Comments
 (0)