From 5cd4ae61ddb168d215f99ced85a61e338f903b25 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Wed, 21 Oct 2026 13:07:44 +0800 Subject: [PATCH] feat(webui): add opt-in MaiBot appearance overlay --- AGENTS.md | 2 + packages/common/package.json | 4 + packages/common/src/webui-target.ts | 100 +++ packages/common/tests/webui-target.test.ts | 51 ++ packages/core/src/webui/server.ts | 36 +- packages/core/src/webui/static-frontend.ts | 34 ++ .../core/tests/webui-static-frontend.test.ts | 47 ++ packages/webui/INTEGRATIONS.md | 111 ++++ packages/webui/README.md | 8 + packages/webui/index.html | 3 + packages/webui/src/appearance/registry.ts | 29 + packages/webui/src/appearance/runtime.ts | 104 ++++ packages/webui/src/appearance/store.ts | 51 ++ .../targets/assets/Jersey20-OFL.txt | 93 +++ .../targets/assets/Jersey20-Regular.ttf | Bin 0 -> 128576 bytes .../webui/src/appearance/targets/maibot.css | 233 +++++++ .../webui/src/appearance/targets/maibot.ts | 27 + .../components/interior/segmented-control.tsx | 4 +- .../src/components/layout/main-layout.tsx | 2 + .../webui/src/components/layout/sidebar.tsx | 13 +- .../webui/src/components/layout/top-bar.tsx | 5 +- .../webui/src/components/pages/login-page.tsx | 5 +- .../src/components/pages/settings-page.tsx | 574 ++++++++++-------- .../webui/src/components/theme-toggle.tsx | 4 +- packages/webui/src/components/ui/button.tsx | 1 + packages/webui/src/contexts/ThemeContext.tsx | 56 +- packages/webui/src/main.tsx | 20 +- .../webui/tests/target-appearance.test.ts | 106 ++++ packages/webui/vite.config.ts | 16 +- 29 files changed, 1424 insertions(+), 315 deletions(-) create mode 100644 packages/common/src/webui-target.ts create mode 100644 packages/common/tests/webui-target.test.ts create mode 100644 packages/core/src/webui/static-frontend.ts create mode 100644 packages/core/tests/webui-static-frontend.test.ts create mode 100644 packages/webui/INTEGRATIONS.md create mode 100644 packages/webui/src/appearance/registry.ts create mode 100644 packages/webui/src/appearance/runtime.ts create mode 100644 packages/webui/src/appearance/store.ts create mode 100644 packages/webui/src/appearance/targets/assets/Jersey20-OFL.txt create mode 100644 packages/webui/src/appearance/targets/assets/Jersey20-Regular.ttf create mode 100644 packages/webui/src/appearance/targets/maibot.css create mode 100644 packages/webui/src/appearance/targets/maibot.ts create mode 100644 packages/webui/tests/target-appearance.test.ts diff --git a/AGENTS.md b/AGENTS.md index 0cce7035..a020ed87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,8 @@ Use focused tests while iterating. Before handing off a code change, run the ful `pnpm dev` starts Core and can attach to local QQ processes; use it only when runtime testing is intended. `pnpm dev:web` starts the frontend, which expects a WebUI backend. Follow the existing test fakes for isolated work rather than starting a live account unnecessarily. +WebUI deployment targets use a reversible presentation overlay. Read [the host appearance contract](packages/webui/INTEGRATIONS.md) before changing it. Keep target validation in `@snowluma/common/webui-target`, implementations in the frontend target registry, and saved preferences separate from effective appearance. Default deployments must not load target assets or host listeners; do not put target-name branches in business pages. + ## Implementation rules - **Fix the cause.** Reproduce failures at the owning layer and add a regression test for the observable behavior. Avoid account-, filename-, or example-specific patches. diff --git a/packages/common/package.json b/packages/common/package.json index 6f8d87d9..8f99da4a 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -8,6 +8,10 @@ "main": "./src/index.ts", "types": "./src/index.ts", "exports": { + "./webui-target": { + "types": "./src/webui-target.ts", + "default": "./src/webui-target.ts" + }, "./update-source": { "types": "./src/update-source.ts", "default": "./src/update-source.ts" diff --git a/packages/common/src/webui-target.ts b/packages/common/src/webui-target.ts new file mode 100644 index 00000000..1c2ab81c --- /dev/null +++ b/packages/common/src/webui-target.ts @@ -0,0 +1,100 @@ +/** Browser-safe contract shared by startup, the WebUI and embedding hosts. + * Targets are deployment choices; they never belong in the saved UI preferences. */ +export const WEBUI_TARGETS = { + maibot: { + label: '麦麦 / 一键包', + styles: ['millennium'], + defaultStyle: 'millennium', + }, +} as const; + +export type WebuiTarget = keyof typeof WEBUI_TARGETS; +export type WebuiColorMode = 'light' | 'dark' | 'system'; + +export type WebuiPresentation = { + [T in WebuiTarget]: { + version: 1; + target: T; + style: (typeof WEBUI_TARGETS)[T]['styles'][number]; + mode: WebuiColorMode; + }; +}[WebuiTarget]; + +/** null releases the override for this document, without changing deployment opt-in. */ +export type WebuiPresentationUpdate = WebuiPresentation | { version: 1; target: null }; +export const WEBUI_PRESENTATION_EVENT = 'snowluma:host-appearance'; +export const WEBUI_TARGET_BOOTSTRAP_ID = 'snowluma-webui-target'; + +export function resolveWebuiTarget(raw: string | undefined): WebuiTarget | null { + if (raw === undefined || raw.trim() === '') { + return null; + } + + const value = raw.trim(); + + if (!Object.hasOwn(WEBUI_TARGETS, value)) { + throw new Error( + `Invalid SNOWLUMA_WEBUI_TARGET: ${JSON.stringify(value)}. Expected ${Object.keys(WEBUI_TARGETS).join(', ')} or an empty value.`, + ); + } + + return value as WebuiTarget; +} + +export function defaultWebuiPresentation(target: WebuiTarget): WebuiPresentation { + return { version: 1, target, style: WEBUI_TARGETS[target].defaultStyle, mode: 'system' }; +} + +export function parseWebuiPresentation(value: unknown): WebuiPresentationUpdate { + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + value.version !== 1 || + !('target' in value) + ) { + throw new TypeError('Invalid host appearance: expected a version 1 presentation'); + } + + if (value.target === null) { + return { version: 1, target: null }; + } + + if (typeof value.target !== 'string' || !Object.hasOwn(WEBUI_TARGETS, value.target)) { + throw new TypeError('Invalid host appearance target'); + } + + const target = value.target as WebuiTarget; + const styles: readonly string[] = WEBUI_TARGETS[target].styles; + + if (!('style' in value) || typeof value.style !== 'string' || !styles.includes(value.style)) { + throw new TypeError(`Invalid host appearance style for ${target}`); + } + + if ( + !('mode' in value) || + (value.mode !== 'light' && value.mode !== 'dark' && value.mode !== 'system') + ) { + throw new TypeError('Invalid host appearance color mode'); + } + + // Both discriminants have been checked against the same target registry. + return { version: 1, target, style: value.style, mode: value.mode } as WebuiPresentation; +} + +/** Inject before the cached pre-paint theme script. Default HTML stays byte-for-byte intact. */ +export function injectWebuiTarget(html: string, target: WebuiTarget | null): string { + if (target === null) { + return html; + } + + if (!html.includes('')) { + throw new Error('WebUI HTML is missing ; cannot inject runtime target'); + } + + const json = JSON.stringify(defaultWebuiPresentation(target)).replaceAll('<', '\\u003c'); + return html.replace( + '', + `\n `, + ); +} diff --git a/packages/common/tests/webui-target.test.ts b/packages/common/tests/webui-target.test.ts new file mode 100644 index 00000000..67578702 --- /dev/null +++ b/packages/common/tests/webui-target.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { + defaultWebuiPresentation, + injectWebuiTarget, + parseWebuiPresentation, + resolveWebuiTarget, +} from '../src/webui-target'; + +describe('deployment presentation contract', () => { + it('requires explicit opt-in and rejects misspelled deployments', () => { + expect(resolveWebuiTarget(undefined)).toBeNull(); + expect(resolveWebuiTarget(' ')).toBeNull(); + expect(resolveWebuiTarget('maibot')).toBe('maibot'); + expect(() => resolveWebuiTarget('mai-bot')).toThrow('SNOWLUMA_WEBUI_TARGET'); + expect(() => resolveWebuiTarget('__proto__')).toThrow('SNOWLUMA_WEBUI_TARGET'); + }); + + it('keeps the unconfigured document intact and injects before cached appearance', () => { + const html = ''; + expect(injectWebuiTarget(html, null)).toBe(html); + const injected = injectWebuiTarget(html, 'maibot'); + expect(injected.indexOf('snowluma-webui-target')).toBeLessThan( + injected.indexOf('readSavedAppearance'), + ); + expect(injected).toContain(JSON.stringify(defaultWebuiPresentation('maibot'))); + expect(() => injectWebuiTarget('broken html', 'maibot')).toThrow('missing '); + }); + + it('validates the version, target, style and color mode without accepting arbitrary CSS', () => { + const valid = defaultWebuiPresentation('maibot'); + expect(parseWebuiPresentation(valid)).toEqual(valid); + expect( + parseWebuiPresentation({ ...valid, mode: 'dark', css: 'body{display:none}' }), + ).toEqual({ ...valid, mode: 'dark' }); + expect(parseWebuiPresentation({ version: 1, target: null })).toEqual({ + version: 1, + target: null, + }); + + for (const input of [ + null, + {}, + { ...valid, version: 2 }, + { ...valid, target: 'other' }, + { ...valid, style: 'modern' }, + { ...valid, mode: 'blue' }, + ]) { + expect(() => parseWebuiPresentation(input)).toThrow(); + } + }); +}); diff --git a/packages/core/src/webui/server.ts b/packages/core/src/webui/server.ts index 5fd9eb74..c3bc43a8 100644 --- a/packages/core/src/webui/server.ts +++ b/packages/core/src/webui/server.ts @@ -1,6 +1,7 @@ import { APP_VERSION } from '@snowluma/common/version'; import { serve } from '@hono/node-server'; -import { serveStatic } from '@hono/node-server/serve-static'; +import { resolveWebuiTarget } from '@snowluma/common/webui-target'; +import { registerStaticFrontend } from './static-frontend'; import type { HookManager, HookProcessInfo } from '@snowluma/bridge'; import { getRuntimeIntegrity, hasLowProcessIntegrity } from '@snowluma/bridge'; import { @@ -636,6 +637,15 @@ export async function initWebUI( updateRoot?: string; } = {}, ): Promise<{ port: number; stop: () => Promise }> { + const webuiTarget = resolveWebuiTarget(process.env.SNOWLUMA_WEBUI_TARGET); + + if (webuiTarget) { + log.info( + 'presentation target enabled: %s (runtime override; saved appearance is preserved)', + webuiTarget, + ); + } + const host = listener.host || '127.0.0.1'; const tlsEnabled = listener.tlsEnabled === true; const bootCheck = checkListenerExposure({ @@ -2267,29 +2277,7 @@ export async function initWebUI( // Build path is relative to the bundled / dev __dirname. SPA fallback to // index.html so client-side routes (if any) keep working. const staticRoot = path.resolve(__dirname, 'client'); - app.use('/*', serveStatic({ root: staticRoot })); - - const indexHtmlPath = path.join(staticRoot, 'index.html'); - app.get('*', (c) => { - // SPA fallback: only for navigations that didn't hit a static asset. - if ( - c.req.path.startsWith('/api/') || - c.req.path.startsWith('/avatar/') || - c.req.path.startsWith('/ui-asset/') - ) { - return c.text('not found', 404); - } - - if (existsSync(indexHtmlPath)) { - const html = readFileSync(indexHtmlPath, 'utf8'); - return c.html(html); - } - - return c.text( - 'WebUI client bundle not found. Run `pnpm --filter webui build` (or use the dev server on :5178).', - 404, - ); - }); + registerStaticFrontend(app, staticRoot, webuiTarget); const finalPort = await findAvailablePort(desiredPort, { host }); diff --git a/packages/core/src/webui/static-frontend.ts b/packages/core/src/webui/static-frontend.ts new file mode 100644 index 00000000..da64f2bd --- /dev/null +++ b/packages/core/src/webui/static-frontend.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import type { Hono, Context } from 'hono'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { injectWebuiTarget, type WebuiTarget } from '@snowluma/common/webui-target'; + +/** All document entry paths share runtime injection, including direct index.html requests. */ +export function registerStaticFrontend(app: Hono, root: string, target: WebuiTarget | null): void { + const indexPath = path.join(root, 'index.html'); + + const document = (c: Context) => { + if (!existsSync(indexPath)) { + return c.text( + 'WebUI client bundle not found. Run `pnpm --filter webui build` (or use the dev server on :5178).', + 404, + ); + } + + // A packaged frontend can be reused with a different deployment target on restart. + c.header('Cache-Control', 'no-store'); + return c.html(injectWebuiTarget(readFileSync(indexPath, 'utf8'), target)); + }; + + app.get('/', document); + app.get('/index.html', document); + app.use('/*', serveStatic({ root })); + app.get('*', (c) => { + if (['/api/', '/avatar/', '/ui-asset/'].some((prefix) => c.req.path.startsWith(prefix))) { + return c.text('not found', 404); + } + + return document(c); + }); +} diff --git a/packages/core/tests/webui-static-frontend.test.ts b/packages/core/tests/webui-static-frontend.test.ts new file mode 100644 index 00000000..79fc522f --- /dev/null +++ b/packages/core/tests/webui-static-frontend.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { registerStaticFrontend } from '../src/webui/static-frontend'; + +let root: string; +const html = 'SnowLumaapp'; + +beforeEach(() => { + root = mkdtempSync(path.join(tmpdir(), 'snowluma-presentation-')); + writeFileSync(path.join(root, 'index.html'), html); + writeFileSync(path.join(root, 'asset.js'), 'window.asset = true;'); +}); + +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('runtime target document delivery', () => { + it.each(['/', '/index.html', '/settings?tab=appearance'])( + 'injects on %s before authentication', + async (url) => { + const app = new Hono(); + registerStaticFrontend(app, root, 'maibot'); + const response = await app.request(url); + expect(response.status).toBe(200); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(await response.text()).toContain('"target":"maibot"'); + }, + ); + + it('reuses the exact same built frontend for an unconfigured deployment', async () => { + const app = new Hono(); + registerStaticFrontend(app, root, null); + expect(await (await app.request('/index.html')).text()).toBe(html); + }); + + it('does not inject into assets or API error responses', async () => { + const app = new Hono(); + registerStaticFrontend(app, root, 'maibot'); + expect(await (await app.request('/asset.js')).text()).toBe('window.asset = true;'); + + for (const url of ['/api/missing', '/avatar/missing', '/ui-asset/missing']) { + expect((await app.request(url)).status).toBe(404); + } + }); +}); diff --git a/packages/webui/INTEGRATIONS.md b/packages/webui/INTEGRATIONS.md new file mode 100644 index 00000000..cb11cf85 --- /dev/null +++ b/packages/webui/INTEGRATIONS.md @@ -0,0 +1,111 @@ +# Host appearance overlays + +Set `SNOWLUMA_WEBUI_TARGET=maibot` on the **SnowLuma process** to use MaiBot's +millennium appearance. The same release bundle supports standalone and embedded +deployments. An unset or empty value keeps the existing SnowLuma experience; +unknown values fail WebUI startup with an explicit diagnostic. + +This first adapter implements **millennium only**, in light and dark modes. +Other MaiBot styles are not implemented. There is no new appearance selector in +SnowLuma. The host owns theme changes; existing theme controls are unavailable +while managed, and the settings page explains which preferences are preserved. + +## Runtime and ownership + +1. Core reads the environment at startup. [Static document delivery](../core/src/webui/static-frontend.ts) + injects a validated, public JSON bootstrap into `/`, `/index.html` and SPA + fallback documents. Documents use `Cache-Control: no-store`. No secrets or + arbitrary environment variables reach the browser. +2. [Frontend bootstrap](src/appearance/runtime.ts) loads only the selected + target's assets before mounting React. Standalone deployments load no target + stylesheet, font or host listener. Vite development serves the same bootstrap + when its process has the environment variable; production builds remain neutral. +3. [ThemeContext](src/contexts/ThemeContext.tsx) keeps `savedAppearance` separate + from effective `appearance`. Only saved preferences enter localStorage or + `config/ui.json`. Target updates never invoke the persistence API. +4. The target owns colors, fonts, radius, wallpaper, CSS variable overrides, + custom CSS and custom cursor presentation. These saved values are preserved + but suspended. UI scale, density, motion/accessibility, navigation layout and + data preferences remain available. Safe-mode restrictions still apply when + returning to native appearance. +5. A single layout effect applies effective appearance. Changes do not reload + the document, change React keys or restart sessions. All target rules are + scoped to `html[data-webui-target]`, including portals under `body`. + +## Host protocol, version 1 + +The contract lives in [common/webui-target.ts](../common/src/webui-target.ts). +The deployment environment is an opt-in, **not a live transport**. Existing +launchers need to implement the sender below; merely setting the environment +variable cannot convey another process's current theme. + +A same-document embedding bridge (for example Electron `executeJavaScript`) +publishes the latest snapshot before dispatching the event: + +```js +const appearance = { + version: 1, + target: 'maibot', + style: 'millennium', + mode: 'dark', // 'light' | 'dark' | 'system' +}; +window.__SNOWLUMA_HOST_APPEARANCE__ = appearance; +window.dispatchEvent(new CustomEvent('snowluma:host-appearance', { detail: appearance })); +``` + +The snapshot handles messages sent before React or the lazy asset loader is +ready. Publish on each guest `dom-ready` and whenever the host's resolved +brightness changes. A host that already follows MaiBot should forward its own +resolved `light`/`dark` state; this avoids competing sources of truth. Validate +the guest's expected origin before injecting, serialize values with +`JSON.stringify`, and do not enable Node integration in the guest. + +Send `{ version: 1, target: null }` using the same mechanism to release the +override for the current document. The original appearance returns immediately. +The opted-in document keeps its host subscription so the host can reapply the +target later. Listeners are released on provider unmount. This does not change +the startup environment or enable a target in a standalone deployment. + +The current adapter starts with millennium and system brightness before a host +snapshot arrives. The last valid snapshot remains active if the host stops +sending. Snapshots are not persisted or shared across origins/tabs. A reload +requires the host to publish again. Unsupported versions, targets, styles or +modes are rejected with `[SnowLuma appearance]` console diagnostics; the previous +valid snapshot remains. The host cannot supply CSS, URLs or executable content +through this protocol. No unauthenticated HTTP write endpoint is introduced. + +## Adding a target + +- Add its identifier, styles and default style to `WEBUI_TARGETS` in the shared + contract. Keep validation at this boundary; do not accept arbitrary strings. +- Add one lazy loader to the exhaustive [frontend registry](src/appearance/registry.ts). + Implement a pure saved-to-effective resolver and scoped stylesheet under + `src/appearance/targets`. Keep any fonts local with their redistribution license. +- Use semantic color variables, shared component classes and neutral + `data-ui-surface` markers. Do not add target-name checks to business pages. +- Never persist resolved overrides, mutate saved preferences, introduce a + second DOM theme writer, or remount the application to switch presentation. +- Test default asset/listener isolation, initial document routes, malformed + messages, rapid updates, cleanup and exact restoration of saved preferences. + Verify forms, focus, scroll, portals and mobile layout with an isolated backend. + Electron integration needs a separate real-host check. + +## Sources and checks + +The adapter follows the main MaiBot repository's +[millennium stylesheet](https://github.com/Mai-with-u/MaiBot/blob/5c7637e6913c84d1c3d5e742fa9e95a7e70d3501/dashboard/src/styles/millennium.css), +not the archived standalone WebUI. Colors/materials also match the +[OneKey shell](https://github.com/Mai-with-u/MaiBotOneKey/blob/e8073b0bce35c8c4da973b06156602e14c42fee5/src/renderer/src/styles/millennium.css). +The bundled Jersey 20 font is from that MaiBot revision, copyright The Soft Type +Project Authors, under the [SIL Open Font License](src/appearance/targets/assets/Jersey20-OFL.txt). +Its license is emitted alongside the font in production assets. + +Focused checks (from the repository root): + +```sh +pnpm --filter @snowluma/common exec vitest run tests/webui-target.test.ts +pnpm --filter @snowluma/core exec vitest run tests/webui-static-frontend.test.ts +pnpm --filter webui exec vitest run tests/target-appearance.test.ts +``` + +Run the normal workspace checks from `AGENTS.md` before handing off changes. diff --git a/packages/webui/README.md b/packages/webui/README.md index c6b7c9ef..d84dd902 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -3,6 +3,14 @@ SnowLuma's browser console is a Vite + React application for managing runtime status, OneBot configuration, logs, storage, and server settings. +## Host appearance integration + +`SNOWLUMA_WEBUI_TARGET=maibot` opts a deployment into a reversible millennium +appearance overlay. Standalone defaults are unchanged. Hosts drive live light/dark +updates without saving over personal preferences or adding another selector. +See [the integration contract and extension guide](INTEGRATIONS.md) before changing +target startup, theme ownership, host messaging or scoped styles. + ## Interaction conventions - Destructive actions and second confirmations stay in modal dialogs. diff --git a/packages/webui/index.html b/packages/webui/index.html index 983febae..80cc5386 100644 --- a/packages/webui/index.html +++ b/packages/webui/index.html @@ -17,6 +17,9 @@