Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added packages/app/public/android-chrome-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/app/public/android-chrome-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added packages/app/public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/app/public/brand/favicons/android-chrome-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/app/public/brand/favicons/android-chrome-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/app/public/brand/favicons/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 7 additions & 5 deletions packages/app/public/site.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@
"type": "image/x-icon"
},
{
"src": "/brand/favicons/android-chrome-192x192.png",
"src": "/brand/favicons/app-icon-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/brand/favicons/android-chrome-512x512.png",
"src": "/brand/favicons/app-icon-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any maskable"
}
]
}
}
37 changes: 6 additions & 31 deletions packages/app/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,9 @@
/**
* Renderer boot entry and composition root for the cross-platform Eliza app
* shell (web browser, Electrobun desktop, and Capacitor iOS/Android). Runs
* before React mounts: starts cold-start telemetry, registers host-external
* view importers, and resolves cloud-only branding from the injected API base
* / desktop runtime mode.
*
* `main()` drives the boot pipeline — embed-iframe session handshake, app-window
* and model-tester route shortcuts, managed cloud launch connection, the
* headless iOS full-Bun backend smoke gate, popout and detached/overlay window
* shells, then the per-platform bridge stack (storage + Capacitor bridges, iOS
* local-agent fetch/native-request bridges, Android native agent fetch bridge,
* screen-capture / OCR / voice harnesses) — before mounting the React tree
* (`@elizaos/ui` App, optionally wrapped by the web-only CloudRouterShell) and
* running `initializePlatform()` concurrently after paint.
*
* Also owns deep-link handling (custom `<scheme>://` + `eliza.app` universal
* links → hash routes, navigate-view events, or first-run remote connect), the
* trusted-apiBase / native-WebSocket URL policy (tightened for iOS store + cloud
* builds; a bearer token is never accepted from an OS deep link), the mobile
* device bridge + agent tunnel + background runner, and the desktop tray /
* global-shortcut / chat-overlay wiring. Modules not needed for first paint are
* deferred onto the idle path. Exports the resolved platform flags.
*/
// FIRST side-effect: repair the same-origin WebSocket base for the plain-web
// served bundle before the `client` singleton can dial its socket. The dev
// server injects a desktop-loopback `__ELIZA_WS_BASE__` (ws://127.0.0.1:31337)
// that client-base reads first; on a reverse-proxied web page the socket must
// be same-origin (wss://<host>/ws). No-op on desktop / native. See module.
import "./web-ws-base-fix";
import { ErrorBoundary } from "@elizaos/ui/components/ui/error-boundary";
import "@elizaos/ui/styles";
// Native-only (ios/android/desktop): register the Eliza Cloud Applications
Expand Down Expand Up @@ -174,7 +156,6 @@ import {
type IosRuntimeConfig,
resolveIosRuntimeConfig,
} from "./ios-runtime";
import { startKeyboardDictationSession } from "./keyboard-dictation";
import {
createMobileLifecycle,
type MobileLifecycle,
Expand Down Expand Up @@ -1790,12 +1771,6 @@ function handleDeepLink(url: string): void {
// is consumed by installAecLoopHarness's hashchange watcher.
setHashRoute("aec-loop", parsed.searchParams);
break;
case "keyboard-dictation":
// iOS keyboard app-handoff dictation (#12185): extensions have no mic,
// so the ElizaKeyboard extension opens the app; record + transcribe
// here, publish the transcript to the App Group, keyboard inserts it.
startKeyboardDictationSession(parsed.searchParams);
break;
case "connect": {
const gatewayUrl = parsed.searchParams.get("url");
if (gatewayUrl) {
Expand Down
174 changes: 174 additions & 0 deletions packages/app/src/web-ws-base-fix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Same-origin API + WebSocket base repair for the PLAIN-WEB served bundle.
*
* Context: when the app is served by the Vite dev server as a plain browser page
* (NOT the electrobun desktop shell, NOT a Capacitor native webview), the dev
* `appDevWsBasePlugin` injects `window.__ELIZA_WS_BASE__ = "ws://127.0.0.1:<apiPort>"`
* into the served HTML (apiPort defaults to 31337, the desktop loopback API).
*
* `client-base.ts` `getInjectedWsBase()` reads that global FIRST — before it
* would otherwise derive the socket host from `window.location`. So even with an
* empty (same-origin) REST base, the realtime socket dials the dead desktop
* loopback `ws://127.0.0.1:31337/ws`, which is refused, and live chat never
* connects.
*
* When the page is actually served over http/https from a real remote host that
* a reverse proxy (nginx) fronts — proxying `/ws` and `/api` to the backend with
* auth injected — the correct socket target is same-origin
* `wss://<location.host>/ws` and REST is same-origin `/api`.
*
* Two things must be corrected for the plain-web path, both UPSTREAM of the
* DO-NOT-EDIT client-base.ts:
*
* 1. WS base: rewrite the injected desktop-loopback `__ELIZA_WS_BASE__` to
* same-origin `wss://<host>` so `getInjectedWsBase()` resolves the correct
* socket host.
*
* 2. REST base: set `__ELIZA_API_BASE__` to same-origin `https://<host>` so
* `this.baseUrl` is NON-EMPTY. This is required because client-base's
* `connectWs()` has a guard that BAILS when `baseUrl` is empty AND the page
* host has no port and isn't loopback (a Capacitor synthetic-host
* protection). A plain remote https host like `sol-overhaul.shad0w.xyz`
* (portless, non-loopback) trips that guard, so an empty REST base leaves
* the socket un-opened even with a correct WS base. A same-origin absolute
* REST base is equivalent to relative `/api` (nginx proxies it with the
* injected auth header) and makes the guard pass so the socket opens.
*
* Scope: this only mutates `getElizaApiBase()`'s globals (`__ELIZA_API_BASE__` /
* `__ELIZAOS_API_BASE__`), which the REST client reads. It deliberately does NOT
* touch `__ELIZA_APP_API_BASE__` / the branded `__<PREFIX>_API_BASE__` that
* `getInjectedAppApiBase()` reads for cloud-only branding — so app branding is
* unaffected.
*
* Desktop (electrobun) and native (Capacitor) contexts are left untouched — they
* legitimately need the injected / native base.
*
* This module MUST be imported as the first side-effect in `main.tsx`, before
* the `client` singleton's `connectWs()` can run.
*/
import { Capacitor } from "@capacitor/core";
import { isElectrobunRuntime } from "@elizaos/ui/bridge";

const LOOPBACK_HOSTNAMES = new Set([
"localhost",
"127.0.0.1",
"::1",
"[::1]",
"0.0.0.0",
]);

function isLoopbackHostname(hostname: string): boolean {
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
}

function setInjectedGlobal(key: string, value: string): void {
try {
const w = window as unknown as Record<string, unknown>;
w[key] = value;
} catch {
// best-effort — never block boot
}
}

/**
* Same-origin realtime socket base for the current page:
* `wss://<host>` on https, `ws://<host>` on http. client-base appends `/ws`
* and the clientId/token query itself, so only the origin (protocol + host)
* needs to be correct here.
*/
function sameOriginWsBase(): string {
const loc = window.location;
const proto = loc.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${loc.host}`;
}

/** Same-origin REST API base for the current page: `https://<host>`. */
function sameOriginRestBase(): string {
const loc = window.location;
return `${loc.protocol}//${loc.host}`;
}

/**
* Returns true only for the plain-web served context that should use a
* same-origin API/socket (not desktop, not native, page on a real http/https
* non-loopback host).
*/
function isPlainWebSameOriginContext(): boolean {
if (typeof window === "undefined") return false;
// Desktop shell needs the injected loopback API base.
if (isElectrobunRuntime()) return false;
// Capacitor iOS/Android use their own native/injected bases.
try {
if (Capacitor.isNativePlatform()) return false;
} catch {
// If Capacitor isn't resolvable treat as web; fall through.
}
const loc = window.location;
if (loc.protocol !== "http:" && loc.protocol !== "https:") return false;
// Loopback page host = an actual local dev-in-browser session pointed at the
// real loopback API; leave the injection alone there.
if (isLoopbackHostname(loc.hostname)) return false;
return true;
}

function injectedWsBaseIsForeignLoopback(value: unknown): boolean {
if (typeof value !== "string" || !value.trim()) return false;
try {
const parsed = new URL(value);
if (parsed.protocol !== "ws:" && parsed.protocol !== "http:") {
// A wss:/https: injection already implies a real proxied host; don't
// second-guess it.
return false;
}
// ws:/http: injection is the desktop-loopback default; on a plain-web
// remote page it is always wrong.
return true;
} catch {
return false;
}
}

/**
* Repoint the dev-injected desktop-loopback API + WS bases at the current
* (reverse-proxied) origin on the plain-web path so REST hits same-origin
* `/api` and the realtime socket dials `wss://<host>/ws`. No-op on desktop /
* native / loopback-dev contexts.
*/
export function repairWebSameOriginWsBase(): void {
if (!isPlainWebSameOriginContext()) return;
const w = window as unknown as {
__ELIZA_WS_BASE__?: unknown;
__ELIZAOS_WS_BASE__?: unknown;
};
const anyForeign =
injectedWsBaseIsForeignLoopback(w.__ELIZA_WS_BASE__) ||
injectedWsBaseIsForeignLoopback(w.__ELIZAOS_WS_BASE__);
if (!anyForeign) return;

// 1) WS base → same-origin wss://<host>.
const wsTarget = sameOriginWsBase();
setInjectedGlobal("__ELIZA_WS_BASE__", wsTarget);
setInjectedGlobal("__ELIZAOS_WS_BASE__", wsTarget);
try {
const wRecord = window as unknown as Record<string, unknown>;
for (const key of Object.keys(wRecord)) {
if (
/^__[A-Z0-9]+_WS_BASE__$/.test(key) &&
injectedWsBaseIsForeignLoopback(wRecord[key])
) {
setInjectedGlobal(key, wsTarget);
}
}
} catch {
// best-effort
}

// 2) REST base → same-origin https://<host>, so the client's baseUrl is
// non-empty and connectWs()'s empty-baseUrl guard does not bail. Only the
// getElizaApiBase() globals — NOT the app-branding globals.
const restTarget = sameOriginRestBase();
setInjectedGlobal("__ELIZA_API_BASE__", restTarget);
setInjectedGlobal("__ELIZAOS_API_BASE__", restTarget);
}

repairWebSameOriginWsBase();
17 changes: 17 additions & 0 deletions packages/ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2428,6 +2428,23 @@ export function App() {
"max(calc(var(--safe-area-top, 0px) - 1.25rem), 1.25rem)",
}}
>
{/* BOTTOM-BAR / SAFE-AREA FLOOR (do not remove): a viewport-filling
dark background-token floor mounted on EVERY route, behind the
shader (z-0) and every other layer. html/body/#root paint the
orange launch guard (--launch-bg #ef5a1f) as a FOUC color, and on
shared-background routes (home/chat) the AppBackground shader was
the ONLY thing hiding it. On iOS the composer overlay is anchored
by the visualViewport-derived `bottom`, so in the home-indicator
safe-area the shader coverage can fall short and the orange host
color bled through as a band under the composer. This floor makes
the bottom inset (and every unpainted zone) the dark BACKGROUND
token — never accent — regardless of route or shader state. The
shader/wallpaper renders on top of it unchanged on shared routes. */}
<div
aria-hidden="true"
data-testid="app-safe-area-floor"
className="pointer-events-none fixed inset-0 z-[-1] bg-bg"
/>
{/* The unified app background, mounted once here so it persists
seamlessly across shared-background routes. It keeps the
background event channel mounted for the whole session, but only
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/backgrounds/AppBackground.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ describe("AppBackground", () => {
).toBeNull();
});

it("always paints the legibility scrim inside the image wallpaper", () => {
seed({ mode: "image", color: "#000000", imageUrl: "/api/media/wallpaper.png" });
const { container } = render(<AppBackground />);
const scrim = container.querySelector<HTMLElement>(
'[data-testid="app-background-image-scrim"]',
);
expect(scrim).not.toBeNull();
// The scrim lives INSIDE the image layer (one background layer invariant)
// and darkens via the theme --bg token so content stays legible over any
// wallpaper in both themes.
expect(
scrim?.closest('[data-testid="app-background-image"]'),
).not.toBeNull();
expect(scrim?.className).toContain("bg-bg/50");
});

it("renders the programmable shader (or its color-field fallback) for glsl mode", () => {
seed({
mode: "glsl",
Expand Down
23 changes: 22 additions & 1 deletion packages/ui/src/backgrounds/ImageBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ export interface ImageBackgroundProps {
* A full-bleed cover image for the unified app background. Centered, cover-fit,
* no repeat — the user's uploaded or generated wallpaper sits behind the home
* and every view that opts into the shared background.
*
* The image is always painted UNDER a half-strength `--bg` scrim (the child
* layer below). A photo wallpaper is ambience, not content: without the scrim
* a bright, saturated image (the stock sunset especially) competes with the
* greeting, cards, and composer sitting on top of it. Mixing 50% of the page
* background over the photo pulls its brightness, contrast, AND chroma toward
* the theme's base surface, so foreground text wins in both themes — dark mode
* dims the photo toward the warm brand black, light mode lifts it toward the
* warm white that dark text needs. A token scrim (no blur, no per-pixel
* filter work) is also the cheapest possible treatment: one plain composited
* layer, gate-safe, GPU-trivial.
*/
export function ImageBackground({
imageUrl,
Expand All @@ -30,6 +41,16 @@ export function ImageBackground({
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
/>
>
{/* Legibility scrim: recede the wallpaper so content wins. Kept INSIDE
the image layer (not a sibling) so the shell's exactly-one-background
invariant holds and every image wallpaper — default or user-uploaded —
gets the same treatment. */}
<div
aria-hidden="true"
data-testid="app-background-image-scrim"
className="absolute inset-0 bg-bg/50"
/>
</div>
);
}
Loading
Loading