@linxin666/dsh-client-ui-skin-center 0.1.1
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/LICENSE +29 -0
- package/README.md +140 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +866 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +311 -0
- package/package.json +53 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":["css"],"sources":["../../../packages/skins/skin-center/src/client/generated/skins.ts","../../../packages/skins/skin-center/src/client/try-on.ts","../../../packages/skins/skin-center/src/client/SkinCenter.tsx","../../../packages/skins/skin-center/src/client/locales.ts","../../../packages/skins/skin-center/src/client/index.ts"],"sourcesContent":["/**\n * AUTO-GENERATED by scripts/skin-center-bundles — do not edit by hand.\n * Skin registry for the GUI skin center: gallery metadata only. Client\n * bundle text is NOT embedded here — try-on fetches each skin's\n * prebuilt lib/client.js on demand from the host route\n * /api/skin-center/bundle/<id> (a same-origin script executed by the\n * page's own module loader; no eval, and the GUI never parses the\n * embedded art base64 at startup).\n * Regenerate with `node scripts/skin-center-bundles`.\n */\n\n/** One registry entry: gallery metadata for the skin center card. */\nexport interface SkinCenterEntry {\n /** Skin id (packages/skins/<id>/). */\n id: string\n /** Display name (zh). */\n name: string\n /** Display name (en). */\n nameEn: string\n /** One-line tagline. */\n tagline: string\n /** Accent color for the swatch. */\n accent: string\n /** Author credit. */\n author?: string\n /** Longer description. */\n description?: string\n /** Tag list. */\n tags?: string[]\n /** Body attribute that scopes the skin styles. */\n bodyAttr: string\n /** Plugin package name (the boot-graph entry id when active). */\n package: string\n /** Gallery sort order (packages/skins/<name>/skin.json). */\n order?: number\n}\n\n/** Every skin, ordered by packages/skins/<name>/skin.json `order`. */\nexport const SKIN_CENTER_ENTRIES: readonly SkinCenterEntry[] = [\n {\"id\":\"qq98\",\"name\":\"QQ2008 怀旧版\",\"nameEn\":\"QQ2008 Retro\",\"author\":\"dsh-web-ui\",\"tagline\":\"水晶蓝桌面 · 玻璃深蓝标题栏 · 戴围巾企鹅\",\"description\":\"dsh web ui 家族收录的第一个皮肤:QQ2008 水晶蓝年代。深蓝渐变桌面、玻璃质感标题栏、浅蓝状态栏和圆角高光控件,配一只戴围巾的企鹅。\",\"tags\":[\"retro\",\"qq\",\"2008\",\"crystal-blue\",\"nostalgia\"],\"accent\":\"#2b7cd9\",\"bodyAttr\":\"data-dsh-retro\",\"package\":\"@linxin666/dsh-client-ui-skin-qq98\",\"order\":1},\n\n {\"id\":\"ths\",\"name\":\"同花顺风格\",\"nameEn\":\"Tonghuashun Trading\",\"author\":\"dsh-web-ui\",\"tagline\":\"品牌红标题栏 · 实时行情状态栏 · 灰蓝数据终端\",\"description\":\"同花顺风格炒股主题:品牌红标题栏带上证指数行情签,状态栏红涨绿跌,自选股风格的侧边栏和交易终端面板,写代码也像盯盘。\",\"tags\":[\"stock\",\"trading\",\"terminal\",\"red\"],\"accent\":\"#e60012\",\"bodyAttr\":\"data-dsh-ths\",\"package\":\"@linxin666/dsh-client-ui-skin-ths\",\"order\":2},\n\n {\"id\":\"xp\",\"name\":\"Windows XP (Luna)\",\"nameEn\":\"Windows XP Luna\",\"author\":\"dsh-web-ui\",\"tagline\":\"Luna 蓝窗口条 · 绿色开始按钮 · Bliss 蓝天桌面\",\"description\":\"Windows XP (Luna) 复古主题:蓝色渐变窗口条带窗口按钮、米色状态栏(大写/数字/滚动指示灯)、侧边栏任务栏上的绿色「开始」按钮、资源管理器风格树行和 Bliss 蓝天桌面,全局直角。\",\"tags\":[\"retro\",\"xp\",\"luna\",\"windows\",\"start-button\"],\"accent\":\"#316ac5\",\"bodyAttr\":\"data-dsh-xp\",\"package\":\"@linxin666/dsh-client-ui-skin-xp\",\"order\":3},\n\n {\"id\":\"blue-fantasy\",\"name\":\"蓝色幻想\",\"nameEn\":\"Blue Fantasy\",\"author\":\"powerdog996(DreamSkin 社区)· dsh-web-ui 适配\",\"tagline\":\"鲸鱼插画背景 · periwinkle 靛蓝调色板 · 半透明面板\",\"description\":\"DreamSkin「DeepSeek-鲸鱼娘」Codex 桌面主题的 dsh 适配:鲸鱼插画背景垫在半透明面板之下,遮罩随亮/暗主题实时切换,periwinkle 靛蓝色调重映射到全部 dsh token。\",\"tags\":[\"dreamskin\",\"whale\",\"indigo\",\"art\",\"translucent\"],\"accent\":\"#4a5fa8\",\"bodyAttr\":\"data-dsh-blue-fantasy\",\"package\":\"@linxin666/dsh-client-ui-skin-blue-fantasy\",\"order\":4},\n\n {\"id\":\"dragon-heir\",\"name\":\"龙的传人\",\"nameEn\":\"Dragon Heir\",\"author\":\"dsh-web-ui\",\"tagline\":\"不屈龙魂 · 万里长城双主题 · 朱砂龙印\",\"description\":\"龙的传人 — 一面是不屈龙魂(墨龙穿云、朱砂印章、不屈锋芒),一面是万里长城(青黛山色、金晖镀墙、苍茫暮色)。亮暗主题各自配一幅画与一枚龙印 favicon,面板半透明磨砂,让画透出来。\",\"tags\":[\"dragon\",\"loong\",\"chinese\",\"ink-wash\",\"great-wall\",\"dual-theme\"],\"accent\":\"#c3272b\",\"bodyAttr\":\"data-dsh-dragon-heir\",\"package\":\"@linxin666/dsh-client-ui-skin-dragon-heir\",\"order\":5},\n\n {\"id\":\"minecraft\",\"name\":\"Minecraft 方块世界\",\"nameEn\":\"Minecraft Voxel\",\"author\":\"dsh-web-ui\",\"tagline\":\"动态全景天空盒 · 方块按钮 · 告示牌输入框\",\"description\":\"复刻《我的世界》主界面氛围的方块皮肤:程序化绘制的像素全景天空盒(方块山、像素云、方块树、草方块地面)在身后缓慢旋转,界面浮在石板上;按钮还原 MC 菜单按钮(灰石板、悬停变黄、按下下沉),输入框做成带钉子的木告示牌。\",\"tags\":[\"minecraft\",\"voxel\",\"pixel\",\"game\",\"panorama\",\"skybox\"],\"accent\":\"#7cbd4b\",\"bodyAttr\":\"data-dsh-minecraft\",\"package\":\"@linxin666/dsh-client-ui-skin-minecraft\",\"order\":6},\n\n {\"id\":\"whale-song\",\"name\":\"鲸吟\",\"nameEn\":\"Whale Song\",\"author\":\"dsh-web-ui\",\"tagline\":\"深海鲸语女神背景 · 冰蓝海洋调色板 · 金色细线点缀\",\"description\":\"《鲸吟》— 深海鲸语女神主题:无文字纯氛围背景画(蓝发女神与鲸群居左、冰蓝星座网格与金线点缀、右侧大量留白)垫在半透明面板之下,遮罩随亮/暗主题实时切换,冰蓝/浅青/深海军蓝/钴蓝冷色体系重映射到全部 dsh token,暗色变体为深海夜航调。\",\"tags\":[\"whale\",\"ocean\",\"ice-blue\",\"goddess\",\"art\",\"translucent\"],\"accent\":\"#4d8fd4\",\"bodyAttr\":\"data-dsh-whale-song\",\"package\":\"@linxin666/dsh-client-ui-skin-whale-song\",\"order\":7},\n\n]\n","/**\n * Try-on engine for the in-GUI skin center.\n *\n * A skin's client bundle is executed through the REAL module system, not a\n * shim and not eval: the host route `/api/skin-center/bundle/<id>` serves\n * the skin's prebuilt `lib/client.js` as a same-origin script (mirroring\n * the kernel's own defaultLoadBundle — see dsh-client-modules), and its\n * body calls `window.__ModuleLoader__.load({id, factory})`, which only\n * REGISTERS the factory. `window.__DSH_MODULES__.import(package)` (the\n * kernel's ClientModuleSystem, contract C5/C6) then materializes it — which\n * auto-injects the skin's CSS `<style data-plugin>` tag — and\n * `surface.apply(miniCtx)` mounts the skin exactly as the fiber system\n * would, returning a full disposer. That makes try-on and its teardown the\n * real code paths, with no CSP `unsafe-eval` dependence and no startup\n * cost: the ~700KB of embedded art base64 is only parsed when a skin is\n * actually tried on.\n *\n * Mutual exclusion: the GUI never hosts two skins at once. The currently\n * ACTIVE skin is owned by its own cordis fiber (its disposer is not\n * reachable), so try-on retracts the active skin's visual writes by recipe:\n * remove its body attribute (its stylesheet goes inert), clear the\n * body-level backdrop inline styles (blue-fantasy's whale art), detach only\n * known skin chrome body children (title/status bars marked `data-skin-chrome`\n * or carrying the skin's body attribute, leaving other plugins' portals and\n * toasts in place), and neutralize known global-rule leaks (xp's sidebar\n * taskbar/start). Everything is snapshotted and restored on exit in original\n * order. The active skin's own fiber is never touched, so exiting try-on\n * returns the page to exactly the pre-try-on state.\n *\n * A ghost MutationObserver may survive retraction (blue-fantasy re-writes\n * its backdrop on theme flips), so during try-on a neutralizing observer\n * re-clears the backdrop props whenever `data-ds-dark-theme` changes.\n */\n\nimport { SKIN_CENTER_ENTRIES, type SkinCenterEntry } from './generated/skins.ts'\n\n/** Body-level backdrop properties skins may write inline (blue-fantasy). */\nconst BACKDROP_PROPS = [\n 'background-image',\n 'background-position',\n 'background-size',\n 'background-attachment',\n 'background-repeat',\n] as const\n\n/**\n * Per-skin neutralization CSS: rules that hide visual leaks whose styles\n * are NOT scoped under the skin's body attribute (they live on app elements\n * the skin touches, so detaching chrome cannot remove them). Matched by\n * css-module class substring, which is stable across rebuilds.\n */\nconst NEUTRALIZE_CSS: Record<string, string> = {\n // xp styles its start button + taskbar strip in the sidebar footer with\n // top-level (unscoped) rules; its apply() removes them on dispose, but\n // during try-on its ghost observer would re-add them, so hide instead.\n xp: [\n `[data-pane='sidebar'] [class*='xpTaskbar']{background:transparent!important;border-top:none!important;box-shadow:none!important}`,\n `[data-pane='sidebar'] [class*='xpStart']{display:none!important}`,\n ].join(''),\n}\n\n/** The window surfaces the boot protocol installs (manifest.ts contract). */\ninterface SkinCenterWindow {\n __DSH_BOOT__?: { entries?: Array<{ id: string }> }\n __DSH_MODULES__?: {\n import(specifier: string): Promise<unknown>\n invalidate(id: string): void\n }\n}\n\n/** Host base path of the skin bundle route (registered by src/routes.ts). */\nconst BUNDLE_ROUTE = '/api/skin-center/bundle'\n\n/**\n * Execute one skin's client bundle as a real same-origin script, mirroring\n * the kernel's own defaultLoadBundle (dsh-client-modules): the script body\n * calls `window.__ModuleLoader__.load({id, factory})`, which only registers\n * the factory — materialization is the caller's separate `import` step. No\n * eval: try-on works under any CSP that allows same-origin scripts (the\n * shell itself loads plugin bundles this way), and a failed fetch rejects\n * so the caller can restore the active skin instead of leaving it retracted.\n * @param url - same-origin bundle URL.\n * @returns a promise resolving once the script executed.\n */\nfunction loadBundleScript(url: string): Promise<void> {\n return new Promise((resolve, reject) => {\n const el = document.createElement('script')\n el.async = true\n el.src = url\n el.addEventListener('load', () => {\n el.remove()\n resolve()\n }, { once: true })\n el.addEventListener('error', () => {\n el.remove()\n reject(new Error(`skin-center: bundle script ${url} failed to load`))\n }, { once: true })\n document.head.append(el)\n })\n}\n\n/** Read the page's composed boot-graph entry ids (only enabled plugins appear). */\nfunction bootEntryIds(): string[] {\n const boot = (window as SkinCenterWindow).__DSH_BOOT__\n return boot?.entries?.map(entry => entry.id) ?? []\n}\n\n/** The skin package currently ACTIVE in the boot graph, if it is one of ours. */\nexport function activeSkinEntry(): SkinCenterEntry | undefined {\n const ids = new Set(bootEntryIds())\n return SKIN_CENTER_ENTRIES.find(entry => ids.has(entry.package))\n}\n\n/**\n * Whether a direct body child is skin chrome owned by `skin`: marked with the\n * `data-skin-chrome` marker (minecraft/dragon-heir) or carrying the skin's\n * scoping body attribute. Everything else — other plugins' portals, toasts and\n * overlays appended to body — is left alone.\n */\nfunction isSkinChrome(el: Element, skin: SkinCenterEntry | null): boolean {\n if (el.hasAttribute('data-skin-chrome')) return true\n return skin !== null && el.hasAttribute(skin.bodyAttr)\n}\n\n/** Minimal ctx the skins' apply() needs: cordis effect lifecycle only. */\ninterface MiniCtx {\n effect(callback: () => () => void, label?: string): () => void\n /**\n * Service reads are answered with undefined: try-on is a pure-DOM stage,\n * so optional service access (e.g. ths reading the connection handle)\n * degrades to its fallback instead of throwing mid-apply.\n */\n get(key: string): unknown\n /** Hidden handle for the controller: run the disposers in reverse order. */\n __disposeAll(): void\n}\n\nfunction miniCtx(): MiniCtx {\n const disposers: Array<() => void> = []\n return {\n effect(callback) {\n disposers.push(callback())\n return () => {}\n },\n get() {\n return undefined\n },\n __disposeAll(): void {\n for (const dispose of disposers.reverse()) dispose()\n },\n }\n}\n\n/** Snapshot of the active skin's visual writes, restored on try-on exit. */\ninterface ActiveVisuals {\n skin: SkinCenterEntry | null\n /** Attribute value before retraction (null = attribute absent). */\n bodyAttr: string | null\n /** body inline style before retraction (null = none). */\n bodyStyle: string | null\n /** Skin chrome elements detached from body, re-inserted at their anchors. */\n detached: Array<{ el: HTMLElement; anchor: Node | null }>\n /** Neutralizes ghost backdrop writes while a try-on is live. */\n clearObserver: MutationObserver | null\n /** Hides global-rule leaks of the active skin (xp taskbar). */\n neutralizeStyle: HTMLStyleElement | null\n}\n\n/**\n * One live try-on session: owns the tried-on skin's disposer plus the\n * captured active-skin visuals, and restores everything on exit.\n */\nexport class TryOnController {\n private session: {\n /** The tried-on skin, or null when trying on the official stock look. */\n entry: SkinCenterEntry | null\n dispose: () => void\n active: ActiveVisuals\n } | null = null\n\n /**\n * Generation counter. A newer try-on or exit increments it, so an in-flight\n * `tryOn` (awaiting the real bundle load) can detect it was superseded and\n * drop only what it mounted instead of clobbering the newer session.\n */\n private epoch = 0\n\n /**\n * Loads one skin's client bundle so its factory registers on the page's\n * `__ModuleLoader__`. Defaults to a same-origin script tag from the host\n * route `/api/skin-center/bundle/<id>`; tests inject a stub.\n */\n private readonly loadBundle: (entry: SkinCenterEntry) => Promise<void>\n\n constructor(options: { loadBundle?: (entry: SkinCenterEntry) => Promise<void> } = {}) {\n this.loadBundle = options.loadBundle ?? (entry => loadBundleScript(`${BUNDLE_ROUTE}/${encodeURIComponent(entry.id)}`))\n }\n /** The skin currently being tried on, if any. */\n get trying(): SkinCenterEntry | null {\n return this.session?.entry ?? null\n }\n\n /** Whether the official stock look (no skin) is being tried on. */\n get tryingOfficial(): boolean {\n return this.session !== null && this.session.entry === null\n }\n\n /** Start trying on `entry` (replaces any live session). */\n async tryOn(entry: SkinCenterEntry): Promise<void> {\n if (entry.package === activeSkinEntry()?.package) return\n this.exit()\n const epoch = ++this.epoch\n\n const active: ActiveVisuals = this.captureAndRetractActive()\n let dispose: (() => void) | undefined\n try {\n dispose = await this.loadAndApply(entry)\n } catch (error) {\n if (epoch === this.epoch) this.restoreActive(active)\n throw error\n }\n if (epoch !== this.epoch) {\n // Superseded while loading (a newer try-on or exit started): drop only\n // what this attempt mounted — the newer operation owns the surface and\n // captured the active-skin visuals it needs on exit.\n this.cleanupModule(entry)\n dispose()\n return\n }\n this.session = { entry, dispose, active }\n }\n\n /**\n * Try on the official stock look: retract the active skin's visual writes\n * (same recipe as a skin try-on) and mount nothing. Exiting restores the\n * active skin exactly like any other try-on session.\n */\n tryOnOfficial(): void {\n if (activeSkinEntry() === null) return\n this.exit()\n this.epoch += 1\n const active: ActiveVisuals = this.captureAndRetractActive()\n this.session = { entry: null, dispose: () => {}, active }\n }\n\n /** Exit the live session: dispose the tried-on skin, then restore the active skin. */\n exit(): void {\n const session = this.session\n if (session === null) return\n this.epoch += 1\n this.session = null\n session.dispose()\n if (session.entry !== null) this.cleanupModule(session.entry)\n this.restoreActive(session.active)\n }\n\n /** Execute + materialize + mount the target skin through the real loader. */\n private async loadAndApply(entry: SkinCenterEntry): Promise<() => void> {\n const modules = (window as SkinCenterWindow).__DSH_MODULES__\n if (modules === undefined) throw new Error('skin-center: window.__DSH_MODULES__ missing')\n // This try-on session owns the module record for the package for its\n // whole life (a try-on of the ACTIVE skin is rejected above, and the GUI\n // never hosts two skins at once). Drop any factory a crashed earlier\n // session left behind so the script below registers a fresh one — the\n // duplicate-registration retry dance is unnecessary with the real\n // loader, whose load() throws on a duplicate id.\n modules.invalidate(entry.package)\n await this.loadBundle(entry)\n const surface = await modules.import(entry.package)\n const apply = (surface as { apply?: (ctx: unknown) => unknown }).apply\n if (typeof apply !== 'function') {\n throw new Error(`skin-center: \"${entry.package}\" client bundle exports no apply`)\n }\n const ctx = miniCtx()\n try {\n apply(ctx)\n } catch (error) {\n // A skin that throws mid-apply leaves partial writes behind (body\n // attribute, chrome, style tag) and never registers its disposer —\n // the tryOn catch only restores the ACTIVE skin. Roll the residue back\n // here so a crashed try-on can never bleed into the next one.\n this.cleanupModule(entry)\n document.body.removeAttribute(entry.bodyAttr)\n for (const el of [...document.body.children] as HTMLElement[]) {\n if (isSkinChrome(el, entry)) el.remove()\n }\n throw error\n }\n return ctx.__disposeAll\n }\n\n /** Drop the tried-on module record + its injected style tag. */\n private cleanupModule(entry: SkinCenterEntry): void {\n const modules = (window as SkinCenterWindow).__DSH_MODULES__\n modules?.invalidate(entry.package)\n for (const el of document.querySelectorAll(`style[data-plugin=${JSON.stringify(entry.package)}]`)) {\n el.remove()\n }\n }\n\n /**\n * Snapshot the active skin's visual writes and retract them so the tried-on\n * skin can take over the whole surface.\n */\n private captureAndRetractActive(): ActiveVisuals {\n const skin = activeSkinEntry() ?? null\n const body = document.body\n const bodyAttr = skin === null ? null : body.getAttribute(skin.bodyAttr)\n if (skin !== null && bodyAttr !== null) body.removeAttribute(skin.bodyAttr)\n\n const bodyStyle = body.getAttribute('style')\n for (const prop of BACKDROP_PROPS) body.style.removeProperty(prop)\n\n // Detach only known skin chrome (marker/bodyAttr), leaving other\n // plugins' portals/toasts in place; remember each element's next sibling\n // so restore re-inserts in the original order.\n const children = [...body.children] as HTMLElement[]\n const chrome = new Set<HTMLElement>()\n for (const el of children) {\n if (el.id !== 'root' && isSkinChrome(el, skin)) chrome.add(el)\n }\n const detached: Array<{ el: HTMLElement; anchor: Node | null }> = []\n for (let i = 0; i < children.length; i++) {\n const el = children[i]\n if (!chrome.has(el)) continue\n let anchor: Node | null = null\n for (let j = i + 1; j < children.length; j++) {\n if (!chrome.has(children[j])) { anchor = children[j]; break }\n }\n detached.push({ el, anchor })\n }\n for (const { el } of detached) el.remove()\n\n // Neutralize a surviving ghost observer (blue-fantasy's backdrop\n // re-writer) across theme flips during the try-on.\n const clearObserver = new MutationObserver(() => {\n for (const prop of BACKDROP_PROPS) body.style.removeProperty(prop)\n })\n clearObserver.observe(body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })\n\n const neutralizeCss = skin === null ? undefined : NEUTRALIZE_CSS[skin.id]\n const neutralizeStyle = neutralizeCss === undefined ? null : this.injectStyle(neutralizeCss)\n\n return { skin, bodyAttr, bodyStyle, detached, clearObserver, neutralizeStyle }\n }\n\n /** Restore the active skin's captured visual state. */\n private restoreActive(active: ActiveVisuals): void {\n const body = document.body\n if (active.skin !== null && active.bodyAttr !== null) {\n body.setAttribute(active.skin.bodyAttr, active.bodyAttr)\n }\n if (active.bodyStyle !== null) {\n body.setAttribute('style', active.bodyStyle)\n } else {\n body.removeAttribute('style')\n }\n for (const { el, anchor } of active.detached) {\n body.insertBefore(el, anchor !== null && anchor.parentNode === body ? anchor : null)\n }\n active.clearObserver?.disconnect()\n active.neutralizeStyle?.remove()\n }\n\n private injectStyle(css: string): HTMLStyleElement {\n const tag = document.createElement('style')\n tag.dataset.skinCenterNeutralize = ''\n tag.textContent = css\n document.head.append(tag)\n return tag\n }\n}\n","/**\n * The skin-center plugin card: one disclosure card inside the Web UI plugin\n * group (插件配置 → Web UI 插件), listing every installed skin plus the\n * official stock look. Live try-on executes the real bundle inside the GUI\n * (light/dark preview, full restore on exit); Apply is one click — the host\n * half runs `dsh-skin use` through /api/skin-center/apply, the config\n * watcher hot-reloads the patch, and the page reloads into the new skin.\n * Copy rides the standard `t` seat; the theme preview control drives the\n * official theme service (persisted, same as the Appearance row).\n */\nimport { useState, useSyncExternalStore, type ReactNode } from 'react'\nimport type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'\nimport { SKIN_CENTER_ENTRIES, type SkinCenterEntry } from './generated/skins.ts'\nimport { activeSkinEntry, TryOnController } from './try-on.ts'\nimport css from './skin-center.module.css'\n\n/** Business face the skin-center apply() injects into the card. */\nexport interface SkinCenterInjected {\n controller: TryOnController\n theme: {\n getTheme(): ThemeSnapshot\n subscribe(listener: () => void): () => void\n setTheme(id: 'light' | 'dark'): void\n }\n}\n\n/** Plugin-card component props: group-item runtime share + locale seat + injected face. */\nexport type SkinCenterComponentProps =\n PropsRuntime<'web-ui.plugin.item'> & PropsLocale<'skinCenter'> & SkinCenterInjected\n\n/** The apply target of the official stock-look card. */\nconst OFFICIAL = 'official'\n\n/**\n * Render the skin-center card: a disclosure header naming the plugin, with\n * the skin list (official default + every installed skin; try-on / theme\n * preview / one-click apply) inside its body.\n * @param props - card props.\n * @returns the plugin card.\n */\nexport function SkinCenter({ t, controller, theme }: SkinCenterComponentProps) {\n const snapshot = useSyncExternalStore(theme.subscribe, theme.getTheme)\n const activePackage = activeSkinEntry()?.package\n const [open, setOpen] = useState(false)\n const [tryingId, setTryingId] = useState<string | null>(null)\n const [tryingOfficial, setTryingOfficial] = useState(false)\n const [applying, setApplying] = useState<string | null>(null)\n const [error, setError] = useState<string | null>(null)\n\n const tryOn = (entry: SkinCenterEntry): void => {\n setError(null)\n void controller.tryOn(entry)\n .then(() => {\n setTryingId(entry.id)\n setTryingOfficial(false)\n })\n .catch(() => {\n // The controller may have torn down a previous session before the\n // load failed; reset both flags so no stale \"trying on\" lingers.\n setError(t('tryOnError'))\n setTryingId(null)\n setTryingOfficial(false)\n })\n }\n\n const tryOnOfficial = (): void => {\n setError(null)\n try {\n controller.tryOnOfficial()\n } catch {\n setError(t('tryOnError'))\n setTryingOfficial(false)\n return\n }\n setTryingId(null)\n setTryingOfficial(true)\n }\n\n const exitTryOn = (): void => {\n controller.exit()\n setTryingId(null)\n setTryingOfficial(false)\n }\n\n /**\n * Poll the host state until the config watcher reports the target active\n * (the patch write lands before the watcher re-applies it), or time out.\n * @param target - skin id, or `official` for the stock look.\n * @returns whether the target became active within the poll budget.\n */\n const confirmActive = (target: string): Promise<boolean> =>\n new Promise(resolve => {\n const expected = target === OFFICIAL ? 'none' : target\n let tries = 0\n const tick = (): void => {\n tries += 1\n void fetch('/api/skin-center/state')\n .then(async response => {\n const payload = await response.json().catch(() => null) as { ok?: boolean; active?: string } | null\n if (response.ok && payload?.ok === true && payload.active === expected) {\n resolve(true)\n return\n }\n if (tries >= 20) resolve(false)\n else window.setTimeout(tick, 250)\n })\n .catch(() => {\n if (tries >= 20) resolve(false)\n else window.setTimeout(tick, 250)\n })\n }\n tick()\n })\n\n /**\n * One-click apply: the host half runs `dsh-skin use <target>` (or\n * `use official`), the config watcher hot-reloads the patch within\n * seconds, then this page reloads to pick up the new boot graph.\n * @param target - skin id, or `official` for the stock look.\n */\n const applySkin = (target: string): void => {\n setError(null)\n setApplying(target)\n const body = target === OFFICIAL ? { official: true } : { skin: target }\n void fetch('/api/skin-center/apply', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n })\n .then(async response => {\n const payload = await response.json().catch(() => null) as { ok?: boolean; error?: string } | null\n if (!response.ok || payload?.ok !== true) {\n throw new Error(payload?.error ?? `HTTP ${response.status}`)\n }\n setApplying(null)\n // Patch written; reload only once the watcher reports the target\n // active, so the page never boots into the old skin.\n void confirmActive(target).then(confirmed => {\n if (confirmed) {\n window.location.reload()\n } else {\n const command = target === OFFICIAL ? 'dsh-skin use official' : `dsh-skin use ${target}`\n setError(`${t('appliedUnconfirmed')} — ${command}`)\n }\n })\n })\n .catch((cause: unknown) => {\n setApplying(null)\n const detail = cause instanceof Error ? cause.message : String(cause)\n const command = target === OFFICIAL ? 'dsh-skin use official' : `dsh-skin use ${target}`\n setError(`${t('applyFailed')} (${detail}) — ${command}`)\n })\n }\n\n const dark = snapshot.active.colorScheme === 'dark'\n\n /** One row: try-on control + apply button. Shared by the official card and every skin card. */\n const actionButtons = (opts: {\n key: string\n isActive: boolean\n isTrying: boolean\n onTryOn: () => void\n applyLabel: string\n }): ReactNode => (\n <div className={css.actions}>\n {opts.isActive ? (\n <button type=\"button\" className={`${css.button} ${css.buttonGhost}`} disabled>\n {t('tryOn')}\n </button>\n ) : opts.isTrying ? (\n <button type=\"button\" className={`${css.button} ${css.buttonPrimary}`} onClick={exitTryOn}>\n {t('exitTryOn')}\n </button>\n ) : (\n <button type=\"button\" className={`${css.button} ${css.buttonPrimary}`} onClick={opts.onTryOn}>\n {t('tryOn')}\n </button>\n )}\n <button\n type=\"button\"\n className={css.button}\n disabled={applying !== null}\n onClick={() => { applySkin(opts.key) }}\n >\n {applying === opts.key ? t('applying') : opts.applyLabel}\n </button>\n </div>\n )\n\n return (\n <li className={css.pluginCard}>\n <button\n type=\"button\"\n className={css.cardHeader}\n aria-expanded={open}\n aria-label={`${t(open ? 'collapse' : 'expand')}: ${t('title')}`}\n onClick={() => { setOpen(current => !current) }}\n >\n <span className={css.headText}>\n <span className={css.pluginName}>\n {t('title')}\n <span className={css.titleBadge}>{String(SKIN_CENTER_ENTRIES.length)}</span>\n </span>\n <span className={css.cardDescription} title={t('cardDescription')}>{t('cardDescription')}</span>\n </span>\n <span className={open ? css.chevronOpen : css.chevron}>▾</span>\n </button>\n\n {open\n ? (\n <div className={css.cardBody}>\n <div className={css.head}>\n <div className={css.intro} title={t('intro')}>{t('intro')}</div>\n <div className={css.themeRow}>\n <span className={css.themeLabel}>{t('theme')}</span>\n <button\n type=\"button\"\n className={`${css.themeButton} ${dark ? '' : css.themeButtonActive}`}\n onClick={() => { theme.setTheme('light') }}\n >\n {t('themeLight')}\n </button>\n <button\n type=\"button\"\n className={`${css.themeButton} ${dark ? css.themeButtonActive : ''}`}\n onClick={() => { theme.setTheme('dark') }}\n >\n {t('themeDark')}\n </button>\n </div>\n </div>\n\n {error !== null && <div className={css.error}>{error}</div>}\n\n <div className={css.list}>\n {(() => {\n const isActive = activePackage === undefined\n const isTrying = tryingOfficial\n const badge = isActive ? t('active') : isTrying ? t('tryingOn') : null\n return (\n <div className={css.card} key={OFFICIAL}>\n <div className={css.cardHead}>\n <span className={css.swatch} style={{ background: '#98a1ab' }} aria-hidden=\"true\" />\n <span className={css.cardName} title={t('official')}>{t('official')}</span>\n {badge !== null && (\n <span className={`${css.badge} ${isActive ? css.badgeActive : css.badgeTrying}`}>\n {badge}\n </span>\n )}\n </div>\n <div className={css.cardTagline} title={t('officialTagline')}>{t('officialTagline')}</div>\n {actionButtons({\n key: OFFICIAL,\n isActive,\n isTrying,\n onTryOn: tryOnOfficial,\n applyLabel: t('restore'),\n })}\n </div>\n )\n })()}\n\n {SKIN_CENTER_ENTRIES.map(entry => {\n const isActive = entry.package === activePackage\n const isTrying = entry.id === tryingId\n const badge = isActive ? t('active') : isTrying ? t('tryingOn') : null\n return (\n <div className={css.card} key={entry.id}>\n <div className={css.cardHead}>\n <span className={css.swatch} style={{ background: entry.accent }} aria-hidden=\"true\" />\n <span className={css.cardName} title={entry.nameEn}>{entry.nameEn}</span>\n {badge !== null && (\n <span className={`${css.badge} ${isActive ? css.badgeActive : css.badgeTrying}`}>\n {badge}\n </span>\n )}\n </div>\n <div className={css.cardTagline} title={entry.tagline}>{entry.tagline}</div>\n {actionButtons({\n key: entry.id,\n isActive,\n isTrying,\n onTryOn: () => { tryOn(entry) },\n applyLabel: t('apply'),\n })}\n </div>\n )\n })}\n </div>\n </div>\n )\n : null}\n </li>\n )\n}\n","/**\n * Skin-center locale dictionaries. The plugin-card name, its description,\n * and every control of the in-GUI skin center is localized through the\n * standard `t` seat.\n */\n\n/** Copy keys owned by this plugin. */\nexport type SkinCenterKey =\n | 'title'\n | 'cardDescription'\n | 'expand'\n | 'collapse'\n | 'intro'\n | 'official'\n | 'officialTagline'\n | 'active'\n | 'tryingOn'\n | 'tryOn'\n | 'exitTryOn'\n | 'apply'\n | 'applying'\n | 'restore'\n | 'applyFailed'\n | 'appliedUnconfirmed'\n | 'theme'\n | 'themeLight'\n | 'themeDark'\n | 'tryOnError'\n\nexport const en: Record<SkinCenterKey, string> = {\n title: 'Skin Center',\n cardDescription: 'Try on any installed skin live in the GUI — exit restores instantly, applying persists in one click.',\n expand: 'Expand',\n collapse: 'Collapse',\n intro: 'Try on any skin live — it takes effect instantly, exit restores the current look. Apply persists it across restarts.',\n official: 'Official default',\n officialTagline: 'The stock DSH look with no skin applied.',\n active: 'Active',\n tryingOn: 'Trying on',\n tryOn: 'Try on',\n exitTryOn: 'Exit try-on',\n apply: 'Apply',\n applying: 'Applying…',\n restore: 'Restore',\n applyFailed: 'Apply failed',\n appliedUnconfirmed: 'Applied, but the change has not been confirmed — refresh the page if the skin did not switch',\n theme: 'Theme preview',\n themeLight: 'Light',\n themeDark: 'Dark',\n tryOnError: 'Try-on failed — see console',\n}\n\nexport const zh: Record<SkinCenterKey, string> = {\n title: '皮肤中心',\n cardDescription: '在 GUI 内即时试穿任意皮肤,退出即完全还原;应用一键完成并自动刷新。',\n expand: '展开',\n collapse: '收起',\n intro: '任意皮肤可即时试穿,退出即完全还原;「应用」一键持久化,页面自动刷新生效。',\n official: '官方默认',\n officialTagline: '还原 DSH 官方默认外观,不应用任何皮肤。',\n active: '当前激活',\n tryingOn: '试穿中',\n tryOn: '试穿',\n exitTryOn: '退出试穿',\n apply: '应用',\n applying: '应用中…',\n restore: '恢复默认',\n applyFailed: '应用失败',\n appliedUnconfirmed: '已写入配置但尚未确认生效——若皮肤未切换请手动刷新页面',\n theme: '主题预览',\n themeLight: '亮色',\n themeDark: '暗色',\n tryOnError: '试穿失败,详见控制台',\n}\n","/**\n * In-GUI skin center, browser half: registers the Skins plugin card into the\n * Web UI plugin group (`web-ui.plugin.item`, declared by the web-ui-settings\n * group card under 插件配置) and provides the try-on controller + official\n * theme handle to it. The card lists every installed skin (embedded\n * registry), tries it on live inside the GUI, exits with a full restore, and\n * copies the one-command apply. The plugin writes only DOM and the settings\n * ledger — no services, no events, no model access.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale).\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\nimport { SkinCenter, type SkinCenterInjected } from './SkinCenter.tsx'\nimport { en, zh, type SkinCenterKey } from './locales.ts'\nimport { TryOnController } from './try-on.ts'\n\nexport type { SkinCenterComponentProps, SkinCenterInjected } from './SkinCenter.tsx'\nexport { TryOnController } from './try-on.ts'\n\n/** Locale namespace owned by this plugin. */\nexport const NS = 'skinCenter'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** The skin-center card's copy. */\n skinCenter: SkinCenterKey\n }\n\n interface SlotMap {\n /**\n * The child slot the Web UI plugin group declares; this card registers\n * into the group instead of the top-level `settings.plugin.item` list.\n * Spelled here with the same shape so this package can register without\n * depending on the sibling UI package.\n */\n 'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }\n }\n}\n\n/** Owner share of a plugin card (the group card supplies nothing). */\nexport interface SettingsPluginItemOwnerProps {\n /** Marker field: card owner props are intentionally empty. */\n children?: never\n}\n\n/** Required services: slots + locale (plugin card) and theme (preview toggle). */\nexport const inject = ['slots', 'locale', 'theme']\n\n/**\n * Register the skin-center dictionaries, the body scope attribute, and the\n * Skins plugin card inside the Web UI plugin group.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-skin-center: dictionaries')\n\n // The card's own styles scope under this attribute so they keep applying\n // during try-on (when the active skin's attribute is retracted).\n ctx.effect(() => {\n document.body.dataset.dshSkinCenter = ''\n return () => { delete document.body.dataset.dshSkinCenter }\n }, 'ui-skin-center: body scope')\n\n const theme = ctx.get('theme') as ThemeRuntime\n const controller = new TryOnController()\n const injected = (): SkinCenterInjected => ({\n controller,\n theme: {\n getTheme: () => theme.getTheme(),\n subscribe: listener => ctx.on('theme/change', listener),\n setTheme: id => theme.setTheme(id),\n },\n })\n\n ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({\n name: 'web-ui.plugin.item',\n id: 'skins',\n order: 110,\n locale: NS,\n inject: injected,\n }, SkinCenter))\n}\n"],"mappings":"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"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
//#region src/routes.ts
|
|
6
|
+
/**
|
|
7
|
+
* Skin-center HTTP routes — the browser half talks to the host through plain
|
|
8
|
+
* same-origin endpoints: JSON for state/apply, plus the bundle route serving
|
|
9
|
+
* each skin's prebuilt `lib/client.js` as a same-origin script for live
|
|
10
|
+
* try-on (the GUI never embeds the ~700KB of art base64 in its own bundle).
|
|
11
|
+
* The host half delegates skin switching to the `dsh-skin` CLI
|
|
12
|
+
* (the single authority over the `dsh-skin managed` section of
|
|
13
|
+
* `~/.dsh/cordis.patch.yml` and the profile symlink), so switching skins from
|
|
14
|
+
* the GUI is exactly `dsh-skin use <name>` — the config watcher hot-reloads
|
|
15
|
+
* the patch within seconds and the frontend reloads the page to pick up the
|
|
16
|
+
* new boot graph. Same pattern as dsh-pet's `/api/pet` family.
|
|
17
|
+
*
|
|
18
|
+
* Unlike pet's behavioral endpoints, `/apply` writes the user's boot config,
|
|
19
|
+
* so every route also rejects cross-site requests (Sec-Fetch-Site / Origin
|
|
20
|
+
* fence) — a malicious webpage must not be able to switch the user's skin
|
|
21
|
+
* through a localhost CSRF post.
|
|
22
|
+
* @module @linxin666/dsh-client-ui-skin-center/routes
|
|
23
|
+
*/
|
|
24
|
+
/** Browser-facing base path of the skin-center API. */
|
|
25
|
+
const SKIN_CENTER_API_PREFIX = "/api/skin-center";
|
|
26
|
+
/** Cap a dsh-skin invocation; a hung CLI must never block the server. */
|
|
27
|
+
const DSH_SKIN_TIMEOUT_MS = 15e3;
|
|
28
|
+
/** One JSON response. */
|
|
29
|
+
function json(res, status, body) {
|
|
30
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
31
|
+
res.end(JSON.stringify(body));
|
|
32
|
+
}
|
|
33
|
+
/** Require the method or answer 405. */
|
|
34
|
+
function requireMethod(req, res, method) {
|
|
35
|
+
if (req.method === method) return true;
|
|
36
|
+
json(res, 405, {
|
|
37
|
+
ok: false,
|
|
38
|
+
error: "method-not-allowed"
|
|
39
|
+
});
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Same-origin fence. Browsers send `Sec-Fetch-Site` on every fetch: same-site
|
|
44
|
+
* and cross-site pages both resolve their `Origin` here, so the checks are:
|
|
45
|
+
* a `cross-site` fetch is always rejected, and an `Origin` that does not
|
|
46
|
+
* match the request `Host` is rejected. Requests without either header
|
|
47
|
+
* (curl, node http, old browsers) pass — this is a local single-user tool,
|
|
48
|
+
* and the fence only targets the cross-site browser vector.
|
|
49
|
+
*/
|
|
50
|
+
function isSameOriginRequest(req) {
|
|
51
|
+
const site = req.headers["sec-fetch-site"];
|
|
52
|
+
if (typeof site === "string" && site === "cross-site") return false;
|
|
53
|
+
const origin = req.headers.origin;
|
|
54
|
+
if (typeof origin === "string" && origin !== "" && origin !== "null") {
|
|
55
|
+
const host = req.headers.host;
|
|
56
|
+
if (typeof host !== "string" || host === "") return false;
|
|
57
|
+
try {
|
|
58
|
+
if (new URL(origin).host !== host) return false;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
/** Reject cross-site requests with 403. */
|
|
66
|
+
function requireSameOrigin(req, res) {
|
|
67
|
+
if (isSameOriginRequest(req)) return true;
|
|
68
|
+
json(res, 403, {
|
|
69
|
+
ok: false,
|
|
70
|
+
error: "cross-site-request-rejected"
|
|
71
|
+
});
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/** Read a JSON request body (bounded). */
|
|
75
|
+
function readJsonBody(req) {
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
let size = 0;
|
|
78
|
+
const chunks = [];
|
|
79
|
+
req.on("data", (chunk) => {
|
|
80
|
+
size += chunk.length;
|
|
81
|
+
if (size > 64 * 1024) {
|
|
82
|
+
reject(/* @__PURE__ */ new Error("body-too-large"));
|
|
83
|
+
queueMicrotask(() => req.destroy());
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
chunks.push(chunk);
|
|
87
|
+
});
|
|
88
|
+
req.on("end", () => {
|
|
89
|
+
if (chunks.length === 0) {
|
|
90
|
+
resolve({});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
95
|
+
} catch {
|
|
96
|
+
reject(/* @__PURE__ */ new Error("invalid-json"));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
req.on("error", reject);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Run `dsh-skin <args>` and resolve with its stdout.
|
|
104
|
+
* @param args - CLI arguments (e.g. `['use', 'qq98']`).
|
|
105
|
+
* @returns stdout on exit code 0.
|
|
106
|
+
* @throws the CLI's stderr (or the spawn error) on any failure.
|
|
107
|
+
*/
|
|
108
|
+
function runDshSkin(args) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
execFile("dsh-skin", args, { timeout: DSH_SKIN_TIMEOUT_MS }, (error, stdout, stderr) => {
|
|
111
|
+
if (error === null) {
|
|
112
|
+
resolve(stdout);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const spawnError = error;
|
|
116
|
+
if (spawnError.code === "ENOENT") {
|
|
117
|
+
reject(/* @__PURE__ */ new Error("dsh-skin CLI not found on PATH — install it from dsh-web-ui/scripts/dsh-skin"));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const detail = (stderr ?? "").trim() || spawnError.message;
|
|
121
|
+
reject(new Error(detail || `dsh-skin ${args.join(" ")} failed`));
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** A GET route wrapping one async call, fenced to same-origin requests. */
|
|
126
|
+
function getRoute(path, run) {
|
|
127
|
+
return {
|
|
128
|
+
kind: "exact",
|
|
129
|
+
path,
|
|
130
|
+
handler: (req, res) => {
|
|
131
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
132
|
+
if (!requireSameOrigin(req, res)) return;
|
|
133
|
+
run().then((value) => json(res, 200, value), (error) => {
|
|
134
|
+
json(res, 500, {
|
|
135
|
+
ok: false,
|
|
136
|
+
error: error instanceof Error ? error.message : String(error)
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** A POST JSON route wrapping one async call, fenced to same-origin requests. */
|
|
143
|
+
function postRoute(path, run) {
|
|
144
|
+
return {
|
|
145
|
+
kind: "exact",
|
|
146
|
+
path,
|
|
147
|
+
handler: (req, res) => {
|
|
148
|
+
if (!requireMethod(req, res, "POST")) return Promise.resolve();
|
|
149
|
+
if (!requireSameOrigin(req, res)) return Promise.resolve();
|
|
150
|
+
return readJsonBody(req).then((body) => {
|
|
151
|
+
return run(typeof body === "object" && body !== null ? body : {}).then((value) => json(res, 200, value), (error) => {
|
|
152
|
+
json(res, 400, {
|
|
153
|
+
ok: false,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error)
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}, (error) => {
|
|
158
|
+
json(res, 400, {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: error instanceof Error ? error.message : String(error)
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Repo layout: skin bundles live at packages/skins/<id>/lib/client.js. */
|
|
167
|
+
const SKINS_DIR = fileURLToPath(new URL("../../../skins/", import.meta.url));
|
|
168
|
+
/**
|
|
169
|
+
* Map skin id -> directory under packages/skins/, scanned from each
|
|
170
|
+
* skin.json. The id is validated against this map (never used as a raw
|
|
171
|
+
* path) so the bundle route cannot be walked off the skins tree.
|
|
172
|
+
* @returns skin id -> directory name.
|
|
173
|
+
*/
|
|
174
|
+
function skinDirectories() {
|
|
175
|
+
const out = /* @__PURE__ */ new Map();
|
|
176
|
+
for (const dir of readdirSync(SKINS_DIR)) {
|
|
177
|
+
const metaFile = join(SKINS_DIR, dir, "skin.json");
|
|
178
|
+
if (!statSync(metaFile, { throwIfNoEntry: false })) continue;
|
|
179
|
+
let meta;
|
|
180
|
+
try {
|
|
181
|
+
meta = JSON.parse(readFileSync(metaFile, "utf8"));
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (typeof meta.id === "string" && /^[a-z0-9-]+$/.test(meta.id)) out.set(meta.id, dir);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The on-demand bundle route: serve packages/skins/<id>/lib/client.js as a
|
|
191
|
+
* same-origin script. Try-on loads it through a script tag (the kernel's
|
|
192
|
+
* own bundle-loading mechanism), so the body registers the skin factory on
|
|
193
|
+
* `window.__ModuleLoader__` without any eval.
|
|
194
|
+
* @returns the prefix route (matches /api/skin-center/bundle/<id>).
|
|
195
|
+
*/
|
|
196
|
+
function bundleRoute() {
|
|
197
|
+
const prefix = `${SKIN_CENTER_API_PREFIX}/bundle`;
|
|
198
|
+
return {
|
|
199
|
+
kind: "prefix",
|
|
200
|
+
path: prefix,
|
|
201
|
+
handler: (req, res) => {
|
|
202
|
+
if (!requireMethod(req, res, "GET")) return;
|
|
203
|
+
if (!requireSameOrigin(req, res)) return;
|
|
204
|
+
let id;
|
|
205
|
+
try {
|
|
206
|
+
id = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname.slice(prefix.length + 1));
|
|
207
|
+
} catch {
|
|
208
|
+
json(res, 400, {
|
|
209
|
+
ok: false,
|
|
210
|
+
error: "invalid-skin-id"
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (!/^[a-z0-9-]+$/.test(id)) {
|
|
215
|
+
json(res, 400, {
|
|
216
|
+
ok: false,
|
|
217
|
+
error: "invalid-skin-id"
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const dir = skinDirectories().get(id);
|
|
223
|
+
if (dir === void 0) {
|
|
224
|
+
json(res, 404, {
|
|
225
|
+
ok: false,
|
|
226
|
+
error: "skin-not-found"
|
|
227
|
+
});
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const bundle = join(SKINS_DIR, dir, "lib", "client.js");
|
|
231
|
+
if (!statSync(bundle, { throwIfNoEntry: false })) {
|
|
232
|
+
json(res, 404, {
|
|
233
|
+
ok: false,
|
|
234
|
+
error: "skin-bundle-missing"
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
res.writeHead(200, { "content-type": "text/javascript; charset=utf-8" });
|
|
239
|
+
res.end(readFileSync(bundle, "utf8"));
|
|
240
|
+
} catch (error) {
|
|
241
|
+
json(res, 500, {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: error instanceof Error ? error.message : String(error)
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Build the skin-center route family.
|
|
251
|
+
* @param deps - optional runner override (tests).
|
|
252
|
+
*/
|
|
253
|
+
function makeSkinCenterRoutes(deps = {}) {
|
|
254
|
+
const run = deps.run ?? runDshSkin;
|
|
255
|
+
const current = () => run(["current"]).then((out) => out.trim() || "none");
|
|
256
|
+
return [
|
|
257
|
+
getRoute(`${SKIN_CENTER_API_PREFIX}/state`, async () => ({
|
|
258
|
+
ok: true,
|
|
259
|
+
active: await current()
|
|
260
|
+
})),
|
|
261
|
+
bundleRoute(),
|
|
262
|
+
postRoute(`${SKIN_CENTER_API_PREFIX}/apply`, async (body) => {
|
|
263
|
+
const skin = body.skin;
|
|
264
|
+
const official = body.official === true;
|
|
265
|
+
if (typeof skin !== "string" || skin === "") {
|
|
266
|
+
if (!official) throw new Error("invalid-skin: pass a skin name or official: true");
|
|
267
|
+
} else if (official) throw new Error("invalid-skin: skin and official are mutually exclusive");
|
|
268
|
+
const out = await run(["use", official ? "official" : skin]);
|
|
269
|
+
return {
|
|
270
|
+
ok: true,
|
|
271
|
+
active: await current(),
|
|
272
|
+
message: out.trim()
|
|
273
|
+
};
|
|
274
|
+
})
|
|
275
|
+
];
|
|
276
|
+
}
|
|
277
|
+
//#endregion
|
|
278
|
+
//#region src/index.ts
|
|
279
|
+
/** Stable cordis plugin name (matches cordis.patch.yml insert id). */
|
|
280
|
+
const name = "ui-skin-center";
|
|
281
|
+
/** Services required before the skin-center can mount its routes. */
|
|
282
|
+
const inject = ["webServer"];
|
|
283
|
+
/**
|
|
284
|
+
* Register the skin-center API routes.
|
|
285
|
+
*
|
|
286
|
+
* Failure policy: route mounting problems are logged, never thrown — the web
|
|
287
|
+
* shell fails the whole boot when a plugin apply throws, and the skin center
|
|
288
|
+
* must not take the GUI down.
|
|
289
|
+
* @param ctx - cordis context.
|
|
290
|
+
*/
|
|
291
|
+
function apply(ctx) {
|
|
292
|
+
const routes = makeSkinCenterRoutes();
|
|
293
|
+
try {
|
|
294
|
+
ctx.effect(() => {
|
|
295
|
+
const disposers = [];
|
|
296
|
+
try {
|
|
297
|
+
for (const route of routes) disposers.push(ctx.webServer.register(route));
|
|
298
|
+
} catch (error) {
|
|
299
|
+
for (const dispose of disposers) dispose();
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
return () => {
|
|
303
|
+
for (const dispose of disposers) dispose();
|
|
304
|
+
};
|
|
305
|
+
}, "ui-skin-center: routes");
|
|
306
|
+
} catch (error) {
|
|
307
|
+
console.error("[ui-skin-center] route registration failed:", error);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
export { SKIN_CENTER_API_PREFIX, apply, inject, makeSkinCenterRoutes, name };
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@linxin666/dsh-client-ui-skin-center",
|
|
3
|
+
"description": "In-GUI skin center for the dsh web GUI: lists the official default plus every installed skin (qq98 / ths / xp / blue-fantasy / dragon-heir / minecraft), tries it on live inside the real GUI (executes the actual client bundle, light/dark preview), exits with a full restore, and applies in one click through the host /api/skin-center API (dsh-skin use, hot-reloaded, no restart)",
|
|
4
|
+
"version": "0.1.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./src/*": "./src/*",
|
|
11
|
+
"./package.json": "./package.json"
|
|
12
|
+
},
|
|
13
|
+
"dsh": {
|
|
14
|
+
"bundle": {
|
|
15
|
+
"patch": "./cordis.patch.yml"
|
|
16
|
+
},
|
|
17
|
+
"client": {
|
|
18
|
+
"inject": [
|
|
19
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
20
|
+
"@deepseek-ai/dsh-client-locale",
|
|
21
|
+
"@deepseek-ai/dsh-client-ui-theme"
|
|
22
|
+
],
|
|
23
|
+
"platform": "web"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"license": "BSD-3-Clause",
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"react": "^18.2.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"jsdom": "29.1.1",
|
|
32
|
+
"lightningcss": "^1.32.0",
|
|
33
|
+
"react": "^18.2.0",
|
|
34
|
+
"tsdown": "^0.22.2",
|
|
35
|
+
"vitest": "^4.1.8",
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
37
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
|
38
|
+
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
|
39
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
|
40
|
+
"@deepseek-ai/dsh-client-ui-theme": "^0.1.0-rc.6",
|
|
41
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"lib",
|
|
45
|
+
"cordis.patch.yml",
|
|
46
|
+
"README.md"
|
|
47
|
+
],
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsdown",
|
|
50
|
+
"watch": "tsdown --watch",
|
|
51
|
+
"test": "vitest run"
|
|
52
|
+
}
|
|
53
|
+
}
|