fix: resolve Control UI public assets from base path

This commit is contained in:
Shakker
2026-05-31 12:52:38 +01:00
committed by Shakker
parent c06096eabc
commit d3025b4007
5 changed files with 115 additions and 6 deletions

View File

@@ -8,7 +8,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="manifest.webmanifest" />
<link rel="manifest" href="/manifest.webmanifest" />
<script>
(function () {
var THEMES = { claw: 1, knot: 1, dash: 1 };

View File

@@ -1,5 +1,6 @@
import "./styles.css";
import "./ui/app.ts";
import { inferControlUiPublicAssetPath } from "./ui/public-assets.ts";
type ViteImportMeta = ImportMeta & {
readonly env?: {
@@ -11,8 +12,10 @@ declare const OPENCLAW_CONTROL_UI_BUILD_ID: string | undefined;
const isProd = (import.meta as ViteImportMeta).env?.PROD === true;
syncDocumentPublicAssetLinks();
if (isProd && "serviceWorker" in navigator) {
const swUrl = new URL("./sw.js", window.location.href);
const swUrl = new URL(inferControlUiPublicAssetPath("sw.js"), window.location.origin);
swUrl.searchParams.set("v", OPENCLAW_CONTROL_UI_BUILD_ID || "dev");
void navigator.serviceWorker.register(swUrl, { updateViaCache: "none" });
} else if (!isProd && "serviceWorker" in navigator) {
@@ -23,3 +26,21 @@ if (isProd && "serviceWorker" in navigator) {
}
});
}
function syncDocumentPublicAssetLinks() {
setDocumentLinkHref('link[rel="icon"][type="image/svg+xml"]', "favicon.svg");
setDocumentLinkHref('link[rel="icon"][type="image/png"]', "favicon-32.png");
setDocumentLinkHref('link[rel="apple-touch-icon"]', "apple-touch-icon.png");
setDocumentLinkHref('link[rel="manifest"]', "manifest.webmanifest");
}
function setDocumentLinkHref(
selector: string,
asset: Parameters<typeof inferControlUiPublicAssetPath>[0],
) {
const link = document.querySelector<HTMLLinkElement>(selector);
if (!link) {
return;
}
link.href = inferControlUiPublicAssetPath(asset);
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { controlUiPublicAssetPath, inferControlUiPublicAssetPath } from "./public-assets.ts";
describe("controlUiPublicAssetPath", () => {
it("resolves root-mounted public assets from the URL root", () => {
expect(controlUiPublicAssetPath("favicon.svg", "")).toBe("/favicon.svg");
expect(controlUiPublicAssetPath("manifest.webmanifest", null)).toBe("/manifest.webmanifest");
});
it("resolves base-mounted public assets under the configured base path", () => {
expect(controlUiPublicAssetPath("favicon.svg", "/ui")).toBe("/ui/favicon.svg");
expect(controlUiPublicAssetPath("sw.js", "/apps/openclaw/")).toBe("/apps/openclaw/sw.js");
});
});
describe("inferControlUiPublicAssetPath", () => {
it("uses the root for known nested routes without a configured base path", () => {
expect(
inferControlUiPublicAssetPath("manifest.webmanifest", { pathname: "/skills/workshop" }),
).toBe("/manifest.webmanifest");
});
it("infers base-mounted assets from nested routes", () => {
expect(inferControlUiPublicAssetPath("sw.js", { pathname: "/openclaw/skills/workshop" })).toBe(
"/openclaw/sw.js",
);
});
it("prefers an explicit base path over pathname inference", () => {
expect(
inferControlUiPublicAssetPath("apple-touch-icon.png", {
basePath: "/control/",
pathname: "/skills/workshop",
}),
).toBe("/control/apple-touch-icon.png");
});
});

View File

@@ -0,0 +1,52 @@
import { inferBasePathFromPathname, normalizeBasePath } from "./navigation.ts";
export type ControlUiPublicAsset =
| "apple-touch-icon.png"
| "favicon-32.png"
| "favicon.ico"
| "favicon.svg"
| "manifest.webmanifest"
| "sw.js";
type WindowWithControlUiBasePath = Window &
typeof globalThis & {
__OPENCLAW_CONTROL_UI_BASE_PATH__?: string;
};
export function controlUiPublicAssetPath(
asset: ControlUiPublicAsset,
basePath: string | null | undefined,
): string {
const base = normalizeBasePath(basePath ?? "");
return base ? `${base}/${asset}` : `/${asset}`;
}
export function inferControlUiPublicAssetPath(
asset: ControlUiPublicAsset,
params?: {
basePath?: string | null;
pathname?: string;
},
): string {
const configured = params?.basePath ?? readConfiguredBasePath();
const inferredBasePath =
configured != null
? configured
: inferBasePathFromPathname(params?.pathname ?? currentPathname());
return controlUiPublicAssetPath(asset, inferredBasePath);
}
function readConfiguredBasePath(): string | null {
if (typeof window === "undefined") {
return null;
}
const value = (window as WindowWithControlUiBasePath).__OPENCLAW_CONTROL_UI_BASE_PATH__;
return typeof value === "string" ? value : null;
}
function currentPathname(): string {
if (typeof window === "undefined") {
return "/";
}
return window.location.pathname;
}

View File

@@ -6,6 +6,7 @@ import {
} from "../../../../src/agents/tool-policy-shared.js";
import { DEFAULT_ASSISTANT_AVATAR } from "../assistant-identity.ts";
import { buildQualifiedChatModelValue } from "../chat-model-ref.ts";
import { controlUiPublicAssetPath } from "../public-assets.ts";
import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "../string-coerce.ts";
import type {
AgentIdentityResult,
@@ -244,13 +245,11 @@ export function resolveChatAvatarRenderUrl(
}
export function agentLogoUrl(basePath: string): string {
const base = normalizeOptionalString(basePath)?.replace(/\/$/, "") ?? "";
return base ? `${base}/favicon.svg` : "/favicon.svg";
return controlUiPublicAssetPath("favicon.svg", basePath);
}
export function assistantAvatarFallbackUrl(basePath: string): string {
const base = normalizeOptionalString(basePath)?.replace(/\/$/, "") ?? "";
return base ? `${base}/apple-touch-icon.png` : "/apple-touch-icon.png";
return controlUiPublicAssetPath("apple-touch-icon.png", basePath);
}
function isAvatarUrl(value: string): boolean {