diff --git a/README.md b/README.md index 5c11dc3..caeb14e 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,15 @@ Multi-app theming CLI and runtime for supported Chromium/Electron desktop applic [中文文档](./README_zh.md) ```bash -npx --yes --package=@codedrobe/core@0.2.0 codedrobe apps -npx --yes --package=@codedrobe/core@0.2.0 codedrobe detect -npx --yes --package=@codedrobe/core@0.2.0 codedrobe apply --app workbuddy --theme /absolute/theme.codedrobe-theme +npx --yes --package=@codedrobe/core@0.3.0 codedrobe apps +npx --yes --package=@codedrobe/core@0.3.0 codedrobe detect +npx --yes --package=@codedrobe/core@0.3.0 codedrobe apply --app workbuddy --theme /absolute/theme.codedrobe-theme ``` Bun is supported as a CLI runtime: ```bash -bunx --package @codedrobe/core@0.2.0 codedrobe apps +bunx --package @codedrobe/core@0.3.0 codedrobe apps ``` Check for or install the latest global CLI version: @@ -33,13 +33,24 @@ npm install @codedrobe/core ``` ```js -import { getAdapter, launchApp, readThemePackage } from "@codedrobe/core"; +import { applySkin, getAdapter, readThemePackage, resolveThemeTarget, restoreSkin } from "@codedrobe/core"; + +const adapter = getAdapter("codex"); +const theme = await readThemePackage("/absolute/theme.codedrobe-theme"); +const targetTheme = resolveThemeTarget(theme, adapter.id); + +await applySkin({ adapter, targetTheme, port: 9335 }); +await restoreSkin({ adapter, port: 9335 }); ``` +Applications should normally use the high-level `applySkin()` and `restoreSkin()` APIs. They coordinate host settings, launch, DOM preflight, renderer injection, verification, and rollback. `watchTheme()` accepts an `AbortSignal`, so an Electron main process can own its lifecycle without relying on process signals. + The package ships TypeScript declarations for the root API and the `@codedrobe/core/adapters` and `@codedrobe/core/theme` subpath exports. Built-in adapters currently include `codex` and `workbuddy`. Existing applications are never restarted unless `--restart-existing` is explicitly provided. +When a Codex theme changes host appearance settings, a running Codex process must restart before the complete skin can load. Core returns `CODEDROBE_RESTART_REQUIRED` and rolls the settings back unless `--restart-existing` is explicit. Renderer-only changes can still apply live. + If an application is installed outside the adapter's default locations, pass its app bundle, installation directory, or executable file explicitly: ```bash @@ -49,6 +60,27 @@ codedrobe apply --app workbuddy --app-path "/custom/WorkBuddy.app" --theme /abso The same `appPath` override is available to applications using the exported `launchApp()` API. +Capture a read-only DOM and computed-style snapshot for AI-assisted theme authoring: + +```bash +codedrobe dom snapshot --app codex --output /absolute/codex-dom.json +codedrobe dom snapshot --app workbuddy --port 9440 --max-nodes 1500 --output /absolute/workbuddy-dom.json +``` + +The snapshot records the active CodeDrobe theme, element relationships, semantic classes, selected safe attributes, stable selector candidates with match counts, bounding boxes, adapter landmark matches, and theme-relevant computed styles. It deliberately excludes text content, form values, accessible names, query/hash data, links, and media sources. Only visible elements are included by default; use `--include-hidden` when a hidden route or dialog must be styled. The command never launches, restarts, or mutates the application. + +Applications can call the same read-only API: + +```js +import { getAdapter, snapshotDom } from "@codedrobe/core"; + +const targets = await snapshotDom({ + adapter: getAdapter("workbuddy"), + port: 9336, + maxNodes: 1500, +}); +``` + Probe an application's current DOM without injecting or removing a theme: ```bash @@ -90,6 +122,28 @@ CodeDrobe probes each adapter's stable cross-route renderer landmarks before inj Missing adapter or theme `required` landmarks stop injection and report the scope, active context, name, attempted selectors, and selector parse errors. Missing `recommended` landmarks are returned as warnings without blocking injection. Context checks run only while one of their `when.any` landmarks is visible. +A source manifest can declare up to 32 named PNG, JPEG, WebP, or GIF images. Packing embeds them under `assets.images`; the renderer creates scoped Blob URLs and exposes one CSS variable per image: + +```json +{ + "images": { + "hero": "assets/hero.webp", + "background": "assets/background.jpg", + "logo": "assets/logo.png", + "texture": "assets/texture.png" + } +} +``` + +```css +.home-hero { background-image: var(--codedrobe-image-hero); } +.app-shell { background-image: var(--codedrobe-image-background); } +.brand::before { background-image: var(--codedrobe-image-logo); } +.panel { background-image: var(--codedrobe-image-texture); } +``` + +Image IDs map directly to `--codedrobe-image-`. The `hero` image also aliases to `--codedrobe-art` and, for converted Codex themes, `--dream-art`. Legacy `assets.art` packages still resolve as `assets.images.hero`, while new packing and conversion output `assets.images`. + `codedrobe theme pack` and `codedrobe theme inspect` also report non-blocking selector lint warnings for positional selectors, deep direct-child chains, localized text attributes, generated classes, and unusually long selectors. Keep adapter files limited to stable cross-route landmarks; put feature- and theme-specific DOM dependencies in the theme target. Convert a legacy Codex-only package without copying JavaScript into a skill: @@ -101,4 +155,8 @@ codedrobe theme convert ./legacy.codex-theme \ The converter validates the old package, preserves its CSS, metadata, copy, artwork, and base-theme options, and creates a declaration-only package with a single `targets.codex` entry. Existing output files require `--force` before they can be replaced. +Converted Codex themes select a trusted renderer profile supplied by Core. It restores the legacy DOM classes, decorative layer, copy, and artwork variables expected by existing CSS while stopping any observer, timer, or style node left by the old Skill runtime. Theme packages remain declaration-only and cannot provide JavaScript. + +For Codex themes with `baseTheme`, `applySkin()` changes only the three managed appearance keys under `[desktop]` in `~/.codex/config.toml`. Restore merges those keys from `config.before-codedrobe.toml` and preserves unrelated edits. The default backup path remains compatible with the old Skill (`~/Library/Application Support/CodeDrobe/` on macOS and `%LOCALAPPDATA%\CodeDrobe\` on Windows), and `restoreSkin()` can recover host settings even while Codex/CDP is offline. + The Codex adapter was last verified against macOS app version `26.707.72221` (build `5307`) and the WorkBuddy adapter against macOS `5.2.6`, both on 2026-07-16. WorkBuddy passed the full launch/probe/apply/verify/screenshot/restore flow. Use `codedrobe apps --json` to read this metadata. diff --git a/README_zh.md b/README_zh.md index 68394a6..b2cc2fb 100644 --- a/README_zh.md +++ b/README_zh.md @@ -22,8 +22,8 @@ codedrobe apps 无需全局安装: ```bash -npx --yes --package=@codedrobe/core@0.2.0 codedrobe apps -bunx --package @codedrobe/core@0.2.0 codedrobe apps +npx --yes --package=@codedrobe/core@0.3.0 codedrobe apps +bunx --package @codedrobe/core@0.3.0 codedrobe apps ``` 在源码仓库中也可以直接通过 Bun 运行: @@ -49,9 +49,18 @@ npm install @codedrobe/core ``` ```js -import { getAdapter, launchApp, readThemePackage } from "@codedrobe/core"; +import { applySkin, getAdapter, readThemePackage, resolveThemeTarget, restoreSkin } from "@codedrobe/core"; + +const adapter = getAdapter("codex"); +const theme = await readThemePackage("/absolute/theme.codedrobe-theme"); +const targetTheme = resolveThemeTarget(theme, adapter.id); + +await applySkin({ adapter, targetTheme, port: 9335 }); +await restoreSkin({ adapter, port: 9335 }); ``` +软件集成应优先使用高层 `applySkin()` / `restoreSkin()` API。它统一处理宿主配置、应用启动、DOM 预检、渲染器注入、注入后验证和失败回滚;`applyTheme()` / `removeTheme()` 只适合明确只操作当前渲染器的底层场景。`watchTheme()` 接受 `AbortSignal`,因此 Electron 主进程可以用自己的生命周期停止监听,不依赖进程信号。 + npm 包内置 TypeScript 类型声明,覆盖根入口以及 `@codedrobe/core/adapters`、`@codedrobe/core/theme` 子路径,不需要另外安装 `@types` 包。 Skill 目录不再复制 JavaScript 运行时,只保留 `SKILL.md`、必要 references 和对 `@codedrobe/core@固定版本` 的调用说明。 @@ -84,6 +93,27 @@ codedrobe apply \ 软件直接使用 `launchApp()` API 时,也可以传入同名的 `appPath` 字段。 +为 AI 动态编写主题采集只读 DOM 与 computed style 快照: + +```bash +codedrobe dom snapshot --app codex --output /absolute/codex-dom.json +codedrobe dom snapshot --app workbuddy --port 9440 --max-nodes 1500 --output /absolute/workbuddy-dom.json +``` + +快照包含当前 CodeDrobe 主题、元素父子关系、语义 class、经过限制的安全属性、带匹配数量的稳定选择器候选、元素尺寸、适配器地标命中情况和主题相关 computed style。它不会读取文本内容、输入值、可访问名称、URL 查询和 hash、链接或媒体地址。默认只记录可见节点;确实需要处理隐藏路由或弹窗时再使用 `--include-hidden`。该命令不会启动、重启或修改应用。 + +应用也可以直接调用同一只读 API: + +```js +import { getAdapter, snapshotDom } from "@codedrobe/core"; + +const targets = await snapshotDom({ + adapter: getAdapter("workbuddy"), + port: 9336, + maxNodes: 1500, +}); +``` + 只检查当前应用 DOM,不注入或移除主题: ```bash @@ -103,6 +133,8 @@ codedrobe apply \ --restart-existing ``` +Codex 主题修改基础外观配置时也需要重新启动 Codex 才能完整生效。如果 Codex 已经通过 CDP 运行且基础配置发生变化,Core 会返回 `CODEDROBE_RESTART_REQUIRED` 并回滚配置;显式使用 `--restart-existing` 才会由 Core 安全重启。基础配置没有变化时仍可直接热切换 CSS。 + 持续覆盖页面重载和新窗口: ```bash @@ -164,6 +196,30 @@ codedrobe restore --app workbuddy 主题包限制为 30MB,拒绝外部 `url(...)` 与 `@import`。主题包只能包含声明式配置、CSS 和内嵌图片,不能携带或执行 JavaScript。 +源清单可以声明最多 32 张命名图片。打包时 Core 会把 PNG、JPEG、WebP 或 GIF 写入 `assets.images`,运行时为每张图片创建独立 Blob URL: + +```json +{ + "images": { + "hero": "assets/hero.webp", + "background": "assets/background.jpg", + "logo": "assets/logo.png", + "texture": "assets/texture.png" + } +} +``` + +主题 CSS 使用对应的命名变量,因此图片不再局限于 Hero: + +```css +.home-hero { background-image: var(--codedrobe-image-hero); } +.app-shell { background-image: var(--codedrobe-image-background); } +.brand::before { background-image: var(--codedrobe-image-logo); } +.panel { background-image: var(--codedrobe-image-texture); } +``` + +图片 ID 会原样映射为 `--codedrobe-image-`。`hero` 还会自动生成 `--codedrobe-art`,旧 Codex 配置会继续生成 `--dream-art`。旧包中的 `assets.art` 读取时等同于 `assets.images.hero`,新打包和旧主题转换统一输出 `assets.images`。 + `targets..verification` 是可选的主题专属 DOM 依赖: - `required`:缺失时阻止注入。 @@ -180,6 +236,10 @@ Core 会在注入前合并适配器基础探针和主题探针。验证结果会 "id": "dream", "displayName": "Dream Multi-App", "version": "1.0.0", + "images": { + "hero": "assets/hero.webp", + "logo": "assets/logo.png" + }, "targets": { "codex": { "css": "codex.css" }, "workbuddy": { @@ -224,6 +284,10 @@ codedrobe theme convert ./legacy.codex-theme \ 转换器会先验证旧主题包,保留 CSS、主题元数据、文案、图片和基础主题选项,再生成只包含 `targets.codex` 的声明式新主题包。输出文件已存在时必须显式传入 `--force` 才会覆盖。 +转换后的旧版 Codex 主题会声明由 Core 提供的受信任渲染配置。Core 会恢复旧 CSS 所依赖的 `.codedrobe-codex-skin`、`.dream-home`、`#codedrobe-codex-skin-chrome`、文案和图片变量,并在接管时停止旧 Skill 遗留的 Observer、定时器和样式节点。主题包本身仍然不能携带 JavaScript。 + +如果主题包含 `baseTheme`,`applySkin()` 只修改 `~/.codex/config.toml` 中 `[desktop]` 下的 `appearanceTheme`、`appearanceLightCodeThemeId` 和 `appearanceLightChromeTheme`。首次应用会保留 `config.before-codedrobe.toml`;恢复时只合并这三个托管项,保留期间产生的其他配置修改。默认备份路径继续兼容旧 Skill:macOS 为 `~/Library/Application Support/CodeDrobe/config.before-codedrobe.toml`,Windows 为 `%LOCALAPPDATA%\CodeDrobe\config.before-codedrobe.toml`。即使 Codex/CDP 当前没有运行,`restoreSkin()` 仍会恢复宿主配置。 + ## 适配器职责 应用适配器只描述宿主差异: @@ -234,8 +298,9 @@ codedrobe theme convert ./legacy.codex-theme \ - CDP 页面目标识别规则。 - 页面根节点、工作区和输入区域的验证探针。 - 每个平台最后一次实机验证的应用版本和日期。 +- 可选的受信任渲染配置与宿主设置处理器;主题只能按 ID 声明使用,不能提供可执行代码。 -CDP 会话、注入前预检、主题探针合并、缺失节点报告、重载监听、截图、主题包校验和恢复由通用运行时负责。新增应用时注册新适配器,不复制整套启动脚本或注入器。 +CDP 会话、注入前预检、主题探针合并、缺失节点报告、重载监听、截图、主题包校验、宿主设置事务和恢复由通用运行时负责。新增应用时注册新适配器,不复制整套启动脚本或注入器。 ## 当前验证状态 diff --git a/examples/doll-sister/README.md b/examples/doll-sister/README.md new file mode 100644 index 0000000..93d4b8f --- /dev/null +++ b/examples/doll-sister/README.md @@ -0,0 +1,52 @@ +# Doll Sister / 玩偶姐姐 + +An original Codex and WorkBuddy theme inspired by the user-provided composition reference. It uses two named theme images: + +- `hero`: the home-page banner and polaroid artwork. +- `texture`: the sidebar, main surface, cards, and composer background. + +Build and apply: + +```bash +bun ./bin/codedrobe.mjs theme pack examples/doll-sister/theme.json \ + --output examples/doll-sister-1.0.0.codedrobe-theme --force +bun ./bin/codedrobe.mjs apply --app codex \ + --theme examples/doll-sister-1.0.0.codedrobe-theme --restart-existing +bun ./bin/codedrobe.mjs apply --app workbuddy \ + --theme examples/doll-sister-1.0.0.codedrobe-theme +``` + +## Asset generation + +The assets were generated with OpenAI image generation on 2026-07-16. The supplied image was used only as a composition, wardrobe, motif, and palette reference. The generated artwork is original and contains no copied UI, typography, logos, or screenshot framing. + +### Hero prompt + +```text +Use case: stylized-concept +Asset type: wide desktop-app theme hero background +Primary request: Create an original premium "Doll Sister" visual inspired by the supplied reference image, for use behind a Codex desktop home screen. +Input images: Image 1 is a composition, wardrobe, subject, and palette reference only; do not reproduce its UI, typography, logos, or screenshot framing. +Scene/backdrop: a dreamy blush-pink bedroom studio with translucent curtains, soft cherry blossoms, floating petals, tiny pearlescent sparkles, ribbons, and subtle lavender gingham details. +Subject: one clearly adult East Asian woman with shoulder-length soft brown hair and wispy bangs, wearing a white face mask, cream knit cardigan, white blouse, and lavender plaid bow; seated naturally on the right third of the frame, friendly expressive eyes. +Style/medium: polished editorial photography with gentle dollhouse romanticism, realistic skin and fabric texture, refined rather than childish. +Composition/framing: wide 16:9 landscape; subject concentrated on the right 40 percent; generous clean low-contrast negative space across the left and center for interface text and cards; no important detail in the bottom 18 percent where the composer will sit. +Lighting/mood: luminous diffused morning light, soft bloom, delicate depth, calm and inspiring. +Color palette: ivory, shell pink, dusty rose, pale lavender, muted plum accents. +Constraints: image only; no text, no letters, no UI, no icons, no borders, no watermark; adult proportions; keep the center-left readable under dark plum text; avoid overexposure and preserve gentle contrast. +``` + +### Texture prompt + +```text +Use case: stylized-concept +Asset type: subtle repeating desktop-app background texture +Primary request: Create an original seamless-looking romantic texture inspired by the supplied Doll Sister reference palette, for use behind sidebars, chat surfaces, and cards. +Input images: Image 1 is a color and motif reference only; do not reproduce its UI, person, text, logos, or screenshot. +Scene/backdrop: flat decorative surface combining extremely subtle lavender gingham, faint blush watercolor paper grain, sparse tiny cherry blossom petals, pearl dots, miniature ribbon linework, and delicate stitched borders. +Style/medium: refined Japanese stationery and couture fabric sample, premium and understated, low visual noise. +Composition/framing: square tile-like composition with balanced motifs and no focal center; edges should blend naturally when cropped or repeated. +Lighting/mood: soft matte finish, calm, airy, elegant. +Color palette: warm ivory, shell pink, dusty lavender, muted plum at very low opacity. +Constraints: no person, no text, no letters, no UI, no icons, no watermark; keep contrast low enough for dark text to remain readable; avoid large objects and avoid strong gradients. +``` diff --git a/examples/doll-sister/assets/hero.png b/examples/doll-sister/assets/hero.png new file mode 100644 index 0000000..b689d1a Binary files /dev/null and b/examples/doll-sister/assets/hero.png differ diff --git a/examples/doll-sister/assets/texture.png b/examples/doll-sister/assets/texture.png new file mode 100644 index 0000000..b667a82 Binary files /dev/null and b/examples/doll-sister/assets/texture.png differ diff --git a/examples/doll-sister/codex.css b/examples/doll-sister/codex.css new file mode 100644 index 0000000..d4b9e1a --- /dev/null +++ b/examples/doll-sister/codex.css @@ -0,0 +1,530 @@ +:root.codedrobe-codex-skin { + color-scheme: light !important; + --doll-ink: #49384f; + --doll-ink-soft: #765f7d; + --doll-plum: #76507e; + --doll-lavender: #b688ba; + --doll-pink: #dc9fb6; + --doll-blush: #f8e8ed; + --doll-cream: #fffaf7; + --doll-line: rgba(145, 99, 151, .24); + --doll-shadow: rgba(86, 55, 91, .14); + + --color-token-bg-primary: #fff9f7 !important; + --color-token-bg-secondary: rgba(255, 250, 248, .94) !important; + --color-token-bg-tertiary: rgba(248, 232, 237, .72) !important; + --color-token-main-surface-primary: #fffaf8 !important; + --color-token-side-bar-background: #fff7f5 !important; + --color-token-foreground: #49384f !important; + --color-token-text-primary: #49384f !important; + --color-token-text-secondary: rgba(73, 56, 79, .72) !important; + --color-token-text-tertiary: rgba(73, 56, 79, .58) !important; + --color-token-description-foreground: #765f7d !important; + --color-token-icon-foreground: #76507e !important; + --color-token-input-background: rgba(255, 253, 251, .95) !important; + --color-token-input-foreground: #49384f !important; + --color-token-input-placeholder-foreground: rgba(73, 56, 79, .48) !important; + --color-token-input-border: rgba(145, 99, 151, .25) !important; + --color-token-border: rgba(145, 99, 151, .18) !important; + --color-token-border-default: rgba(145, 99, 151, .18) !important; + --color-token-border-heavy: rgba(145, 99, 151, .28) !important; + --color-token-border-light: rgba(145, 99, 151, .11) !important; + --color-token-list-hover-background: rgba(220, 159, 182, .16) !important; + --color-token-list-active-selection-background: rgba(203, 166, 207, .28) !important; + --color-token-list-active-selection-foreground: #49384f !important; + --color-token-toolbar-hover-background: rgba(220, 159, 182, .18) !important; + --color-token-button-background: #8e6397 !important; + --color-token-button-foreground: #fff !important; + --color-token-button-border: rgba(118, 80, 126, .28) !important; + --color-token-link: #895b92 !important; + --color-token-text-link-foreground: #895b92 !important; + --color-token-text-link-active-foreground: #6e4078 !important; + --color-token-primary: #895b92 !important; + --color-token-focus-border: rgba(142, 99, 151, .62) !important; + --color-token-dropdown-background: rgba(255, 250, 248, .98) !important; + --color-token-dropdown-foreground: #49384f !important; + --color-token-menu-background: rgba(255, 250, 248, .98) !important; + --color-token-menu-border: rgba(145, 99, 151, .20) !important; + --color-token-checkbox-background: #fffaf8 !important; + --color-token-checkbox-border: rgba(145, 99, 151, .28) !important; + --color-token-checkbox-foreground: #76507e !important; + --color-token-badge-background: rgba(182, 136, 186, .20) !important; + --color-token-badge-foreground: #6f4778 !important; + --color-token-scrollbar-slider-background: rgba(182, 136, 186, .22) !important; + --color-token-scrollbar-slider-hover-background: rgba(182, 136, 186, .34) !important; + --color-token-scrollbar-slider-active-background: rgba(182, 136, 186, .46) !important; + --color-token-conversation-header: rgba(73, 56, 79, .40) !important; + --color-token-conversation-body: rgba(73, 56, 79, .72) !important; + --color-token-conversation-summary-leading: rgba(73, 56, 79, .68) !important; + --color-token-conversation-summary-trailing: rgba(73, 56, 79, .45) !important; + --color-token-non-assistant-body-descendant: rgba(73, 56, 79, .64) !important; + --color-token-text-preformat-foreground: #664d6e !important; + --color-token-text-preformat-background: rgba(236, 215, 231, .54) !important; + --color-token-text-code-block-background: rgba(255, 252, 250, .88) !important; +} + +html.codedrobe-codex-skin body { + background: + linear-gradient(rgba(255, 250, 248, .86), rgba(253, 242, 245, .92)), + var(--codedrobe-image-texture) center / 720px auto repeat !important; + color: var(--doll-ink) !important; + font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei UI", sans-serif !important; +} + +html.codedrobe-codex-skin body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(circle at 86% 7%, rgba(255, 255, 255, .96) 0 2px, transparent 3px), + radial-gradient(circle at 14% 18%, rgba(220, 159, 182, .46) 0 1px, transparent 2px); + background-size: 83px 83px, 67px 67px; + opacity: .44; +} + +html.codedrobe-codex-skin aside.app-shell-left-panel { + background: + linear-gradient(180deg, rgba(255, 251, 249, .94), rgba(249, 233, 239, .91)), + var(--codedrobe-image-texture) center / 620px auto !important; + border: 1px solid rgba(145, 99, 151, .22) !important; + border-left: 0 !important; + border-radius: 0 24px 24px 0 !important; + box-shadow: 12px 0 34px rgba(86, 55, 91, .09), inset -1px 0 rgba(255, 255, 255, .92) !important; + color: var(--doll-ink) !important; + backdrop-filter: blur(18px) saturate(1.06) !important; +} + +html.codedrobe-codex-skin aside.app-shell-left-panel nav { + background: transparent !important; +} + +html.codedrobe-codex-skin aside.app-shell-left-panel :is(a, button, div, p, span, svg) { + color: var(--doll-ink) !important; +} + +html.codedrobe-codex-skin aside.app-shell-left-panel svg { + stroke: currentColor !important; +} + +html.codedrobe-codex-skin aside.app-shell-left-panel button { + color: var(--doll-ink) !important; + border-radius: 12px !important; + transition: transform .18s ease, background .18s ease, box-shadow .18s ease !important; +} + +html.codedrobe-codex-skin aside.app-shell-left-panel button:hover { + background: linear-gradient(90deg, rgba(238, 216, 234, .70), rgba(252, 241, 244, .82)) !important; + box-shadow: inset 0 0 0 1px rgba(145, 99, 151, .14), 0 4px 14px rgba(86, 55, 91, .07) !important; + transform: translateX(2px); +} + +html.codedrobe-codex-skin aside.app-shell-left-panel button[aria-label^="切换模式"] { + background: transparent !important; + border-color: transparent !important; + color: #76507e !important; + font: 700 20px/1.1 "Iowan Old Style", "Songti SC", serif !important; + text-shadow: 0 2px 12px rgba(182, 136, 186, .24); +} + +html.codedrobe-codex-skin aside.app-shell-left-panel button[aria-label^="切换模式"]::after { + content: " ✦"; + color: #d59ab1; +} + +html.codedrobe-codex-skin aside.app-shell-left-panel [class~="bg-token-list-hover-background"], +html.codedrobe-codex-skin aside.app-shell-left-panel [aria-current="page"] { + background: linear-gradient(90deg, rgba(235, 211, 232, .90), rgba(252, 240, 244, .92)) !important; + border: 1px solid rgba(145, 99, 151, .25) !important; + box-shadow: 0 5px 17px rgba(86, 55, 91, .09), inset 0 0 0 2px rgba(255, 255, 255, .46) !important; +} + +html.codedrobe-codex-skin main.main-surface { + position: relative; + background: + linear-gradient(rgba(255, 251, 249, .92), rgba(255, 247, 247, .94)), + var(--codedrobe-image-texture) center / 840px auto !important; + border: 1px solid rgba(145, 99, 151, .18) !important; + border-right: 0 !important; + border-bottom: 0 !important; + border-radius: 24px 0 0 0 !important; + box-shadow: inset 0 1px rgba(255, 255, 255, .96), -8px 0 28px rgba(86, 55, 91, .07) !important; + overflow: hidden !important; +} + +html.codedrobe-codex-skin main.main-surface > header.app-header-tint { + background: + linear-gradient(90deg, rgba(255, 251, 249, .96), rgba(250, 235, 241, .92), rgba(236, 216, 235, .87)) !important; + border-bottom: 1px solid rgba(145, 99, 151, .20) !important; + color: var(--doll-ink) !important; + backdrop-filter: blur(18px) saturate(1.08) !important; +} + +html.codedrobe-codex-skin [role="main"] { + background: transparent !important; + color: var(--doll-ink) !important; + scrollbar-color: rgba(182, 136, 186, .42) transparent; +} + +#codedrobe-codex-skin-chrome { + position: fixed; + z-index: 31; + pointer-events: none; + overflow: hidden; + border-radius: 24px 0 0 0; +} + +.dream-brand { + position: absolute; + left: 24px; + top: 3px; + height: 42px; + display: none; + align-items: center; + gap: 10px; + color: #704779; + text-shadow: 0 1px 0 white; +} + +#codedrobe-codex-skin-chrome.dream-home-shell .dream-brand { display: flex; } +.dream-brand .dream-note { font-size: 28px; color: #bc89c0; filter: drop-shadow(0 3px 5px rgba(86, 55, 91, .18)); } +.dream-brand b { display: block; font: 700 16px/1.05 "Iowan Old Style", "Songti SC", serif; letter-spacing: .07em; } +.dream-brand small { display: block; margin-top: 4px; color: #8f748f; font-size: 10px; letter-spacing: .05em; } + +.dream-signature { + position: absolute; + right: 86px; + top: 7px; + display: none; + color: #8b5f91; + font: italic 19px/1.2 "Snell Roundhand", "Segoe Script", cursive; + text-shadow: 0 0 8px white, 0 2px 8px rgba(86, 55, 91, .15); + transform: rotate(-3deg); +} + +#codedrobe-codex-skin-chrome.dream-home-shell .dream-signature { display: block; } + +.dream-sparkles { position: absolute; inset: 49px 0 0; opacity: .74; } +.dream-sparkles i { position: absolute; width: 5px; height: 5px; border-radius: 50%; background: #fff9dd; box-shadow: 0 0 10px 3px rgba(196, 151, 188, .34); animation: doll-twinkle 3.8s ease-in-out infinite; } +.dream-sparkles i::before, +.dream-sparkles i::after { content: ""; position: absolute; left: 2px; top: -6px; width: 1px; height: 17px; background: linear-gradient(transparent, rgba(255, 255, 255, .98), transparent); } +.dream-sparkles i::after { transform: rotate(90deg); } +.dream-sparkles i:nth-child(1) { left: 7%; top: 12%; } +.dream-sparkles i:nth-child(2) { left: 29%; top: 6%; animation-delay: .8s; } +.dream-sparkles i:nth-child(3) { left: 54%; top: 18%; animation-delay: 1.5s; } +.dream-sparkles i:nth-child(4) { left: 78%; top: 8%; animation-delay: 2.1s; } +.dream-sparkles i:nth-child(5) { left: 92%; top: 28%; animation-delay: 2.8s; } +.dream-sparkles i:nth-child(6) { left: 66%; top: 68%; animation-delay: 1.1s; } + +@keyframes doll-twinkle { + 0%, 100% { opacity: .42; transform: scale(.82); } + 50% { opacity: 1; transform: scale(1.16); } +} + +.dream-polaroid { + position: absolute; + right: 14px; + bottom: 76px; + width: 118px; + height: 152px; + display: none; + background-image: var(--codedrobe-image-hero); + background-size: 285% auto; + background-position: 82% center; + border: 8px solid rgba(255, 253, 252, .97); + border-bottom-width: 22px; + border-radius: 3px; + box-shadow: 0 10px 24px rgba(86, 55, 91, .18), 0 0 0 1px rgba(145, 99, 151, .20); + transform: rotate(-6deg); +} + +#codedrobe-codex-skin-chrome.dream-home-shell .dream-polaroid { display: block; } +.dream-polaroid::before { content: "🎀"; position: absolute; left: -23px; top: -25px; font-size: 29px; filter: drop-shadow(0 3px 5px rgba(86, 55, 91, .22)); } + +.dream-ribbon { + position: absolute; + left: 50%; + bottom: 69px; + display: none; + align-items: center; + gap: 8px; + color: #a876af; + font-size: 27px; + transform: translateX(-50%); + filter: drop-shadow(0 4px 6px rgba(86, 55, 91, .16)); +} + +#codedrobe-codex-skin-chrome.dream-home-shell .dream-ribbon { display: flex; } +.dream-ribbon span { font-size: 14px; color: #d59ab1; } + +html.codedrobe-codex-skin .dream-home { + --thread-content-max-width: min(980px, calc(100cqw - 44px)) !important; + overflow-x: hidden !important; +} + +.dream-home > div:first-child { + padding-top: 15px !important; + min-height: 100% !important; +} + +.dream-home > div:first-child > div:first-child { + flex: 0 0 440px !important; + min-height: 440px !important; + align-items: flex-start !important; + padding-bottom: 0 !important; +} + +.dream-home > div:first-child > div:first-child > div:first-child { + position: relative !important; + isolation: isolate; + width: calc(100% - 42px) !important; + max-width: none !important; + height: 266px !important; + min-height: 266px !important; + flex: 0 1 auto !important; + padding: 0 !important; + border: 1px solid rgba(145, 99, 151, .30) !important; + border-radius: 28px !important; + overflow: visible !important; + background-color: #fff9f7 !important; + background-image: + linear-gradient(90deg, #fff9f7 0%, rgba(255, 249, 247, .99) 46%, rgba(255, 249, 247, .76) 62%, rgba(255, 249, 247, .08) 82%), + var(--codedrobe-image-hero) !important; + background-repeat: no-repeat, no-repeat !important; + background-size: 100% 100%, auto 220% !important; + background-position: center, right 12% !important; + box-shadow: 0 16px 38px rgba(86, 55, 91, .16), inset 0 0 0 4px rgba(255, 255, 255, .34) !important; +} + +.dream-home > div:first-child > div:first-child > div:first-child::before { + content: ""; + position: absolute; + z-index: 0; + inset: 0 auto 0 0; + width: 64%; + border-radius: 27px 0 0 27px; + pointer-events: none; + background: linear-gradient(90deg, rgba(255, 250, 248, .97) 0%, rgba(255, 250, 248, .91) 58%, rgba(255, 250, 248, .62) 82%, transparent 100%); +} + +.dream-home > div:first-child > div:first-child > div:first-child > div:first-child { + position: relative; + z-index: 1; + height: 100%; + align-items: center !important; + justify-content: flex-start !important; + padding-left: 42px; +} + +.dream-home > div:first-child > div:first-child > div:first-child > div:first-child > div:first-child { + width: 55% !important; + align-items: flex-start !important; + gap: 0 !important; +} + +.dream-home [data-testid="home-icon"] { display: none !important; } + +.dream-home [data-feature="game-source"] { + display: flex !important; + flex-direction: column !important; + align-items: flex-start !important; + max-width: 100% !important; + color: #49384f !important; + font: 750 clamp(21px, 2.1vw, 31px)/1.28 "Iowan Old Style", "Songti SC", serif !important; + text-align: left !important; + text-shadow: 0 1px 0 rgba(255, 255, 255, .94); + opacity: 1 !important; + pointer-events: auto !important; +} + +.dream-home [data-feature="game-source"]::after { + content: var(--dream-tagline, "把灵感缝进每一天 ♡"); + display: block; + margin-top: 14px; + color: #765f7d; + font: 500 14px/1.55 "Avenir Next", "PingFang SC", sans-serif; + letter-spacing: .025em; +} + +.dream-home [data-feature="game-source"] button { + margin: 0 5px; + padding: 3px 10px 4px; + border: 1px solid rgba(145, 99, 151, .27); + border-radius: 999px; + background: rgba(255, 253, 251, .74); + color: #76507e !important; + text-decoration-color: rgba(145, 99, 151, .46) !important; + text-underline-offset: 5px; + box-shadow: inset 0 0 14px rgba(255, 255, 255, .72); +} + +.dream-home [data-feature="game-source"] button::before { + content: var(--dream-project-prefix, "灵感衣橱 · "); + font-size: .46em; + font-weight: 650; + vertical-align: middle; + opacity: .88; +} + +.dream-home > div:first-child > div:first-child > div:first-child > div:nth-child(2) { + left: 14px !important; + right: 14px !important; + top: 100% !important; + margin-top: 14px !important; +} + +.dream-home .group\/home-suggestions { overflow: visible !important; } + +.dream-home .group\/home-suggestions button { + position: relative !important; + min-height: 128px !important; + padding: 15px 13px 12px !important; + align-items: stretch !important; + justify-content: flex-start !important; + text-align: center !important; + border: 1px solid rgba(145, 99, 151, .25) !important; + border-radius: 24px !important; + background: + linear-gradient(rgba(255, 253, 251, .91), rgba(251, 239, 244, .92)), + var(--codedrobe-image-texture) center / 520px auto !important; + color: #49384f !important; + font-weight: 650 !important; + line-height: 1.45 !important; + box-shadow: 0 9px 20px rgba(86, 55, 91, .10), inset 0 0 0 3px rgba(255, 255, 255, .56) !important; + transition: transform .20s ease, box-shadow .20s ease !important; +} + +.dream-home .group\/home-suggestions button:hover { + transform: translateY(-5px) rotate(-.3deg) !important; + box-shadow: 0 15px 28px rgba(86, 55, 91, .16), 0 0 0 2px rgba(182, 136, 186, .14) !important; +} + +.dream-home .group\/home-suggestions button > span:first-child > span:first-child { + width: 40px; + height: 40px; + display: grid !important; + place-items: center; + margin: 0 auto; + border-radius: 50%; + color: white !important; + background: linear-gradient(145deg, #d9a4bd, #a976b0); + box-shadow: 0 0 0 7px rgba(236, 215, 231, .70), 0 6px 13px rgba(86, 55, 91, .19); +} + +.dream-home .group\/home-suggestions button svg { + width: 21px !important; + height: 21px !important; + color: white !important; +} + +.dream-home .group\/home-suggestions button > span:first-child { justify-content: center !important; } +.dream-home .group\/home-suggestions button > span:last-child { align-items: center !important; text-align: center !important; } + +html.codedrobe-codex-skin .composer-surface-chrome { + border: 1px solid rgba(145, 99, 151, .30) !important; + border-radius: 27px !important; + background: + linear-gradient(145deg, rgba(255, 253, 251, .96), rgba(248, 231, 238, .93)), + var(--codedrobe-image-texture) center / 620px auto !important; + box-shadow: 0 12px 29px rgba(86, 55, 91, .13), inset 0 0 0 4px rgba(255, 255, 255, .52) !important; + backdrop-filter: blur(18px) saturate(1.06) !important; + overflow: visible !important; + color: var(--doll-ink) !important; +} + +html.codedrobe-codex-skin .composer-surface-chrome::before { + content: "🎀"; + position: absolute; + left: -16px; + top: -18px; + z-index: 20; + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 50%; + font-size: 18px; + background: linear-gradient(145deg, #f7dce6, #c99dce); + box-shadow: 0 5px 14px rgba(86, 55, 91, .18), 0 0 0 3px rgba(255, 255, 255, .72); +} + +html.codedrobe-codex-skin .ProseMirror { + color: #49384f !important; + caret-color: #895b92 !important; +} + +html.codedrobe-codex-skin button[class~="bg-token-foreground"] { + background: linear-gradient(145deg, #bd8fc2, #86578f) !important; + color: white !important; + box-shadow: 0 6px 14px rgba(86, 55, 91, .22) !important; +} + +html.codedrobe-codex-skin :is(article, [data-message-author-role]) { + border-radius: 20px; + color: var(--doll-ink) !important; +} + +html.codedrobe-codex-skin :is(article, [data-message-author-role]) :is(p, span, li, h1, h2, h3, h4, h5, h6) { + color: inherit; +} + +html.codedrobe-codex-skin pre, +html.codedrobe-codex-skin code { + border-color: rgba(145, 99, 151, .16) !important; +} + +.dream-home div:has(> .horizontal-scroll-fade-mask .group\/project-selector) { + position: relative; + padding-top: 29px !important; + border: 1px solid rgba(145, 99, 151, .22); + border-bottom: 0; + background: linear-gradient(180deg, rgba(255, 253, 251, .96), rgba(248, 231, 238, .91)) !important; +} + +.dream-home div:has(> .horizontal-scroll-fade-mask .group\/project-selector)::before { + content: var(--dream-project-label, "🎀 选择今天的创作项目"); + position: absolute; + left: 13px; + top: 6px; + z-index: 2; + color: #76507e; + font-size: 13px; + font-weight: 700; + letter-spacing: .04em; + text-shadow: 0 1px white; + white-space: nowrap; +} + +.dream-home .group\/project-selector > button { + border-color: rgba(145, 99, 151, .25) !important; + background: linear-gradient(135deg, rgba(255, 255, 255, .96), rgba(245, 226, 236, .90)) !important; + color: #6f5175 !important; + box-shadow: 0 4px 11px rgba(86, 55, 91, .08) !important; +} + +@media (max-width: 1120px) { + .dream-polaroid { display: none !important; } + .dream-ribbon { bottom: 70px; font-size: 24px; } + .dream-home { --thread-content-max-width: min(860px, calc(100cqw - 30px)) !important; } + .dream-home > div:first-child > div:first-child > div:first-child { width: calc(100% - 28px) !important; } +} + +@media (max-width: 900px) { + .dream-signature { display: none !important; } + .dream-brand { left: 15px; } + .dream-brand b { font-size: 13px; } + .dream-home > div:first-child > div:first-child { flex-basis: 398px !important; min-height: 398px !important; } + .dream-home > div:first-child > div:first-child > div:first-child { height: 226px !important; min-height: 226px !important; } + .dream-home > div:first-child > div:first-child > div:first-child > div:first-child { padding-left: 26px; } + .dream-home > div:first-child > div:first-child > div:first-child > div:first-child > div:first-child { width: 66% !important; } + .dream-home [data-feature="game-source"] { font-size: 18px !important; } + .dream-home [data-feature="game-source"]::after { font-size: 12px; margin-top: 10px; } + .dream-home .group\/home-suggestions button { min-height: 114px !important; font-size: 12px !important; } +} + +@media (prefers-reduced-motion: reduce) { + .dream-sparkles i { animation: none !important; } + html.codedrobe-codex-skin aside.app-shell-left-panel button, + .dream-home .group\/home-suggestions button { transition: none !important; } +} diff --git a/examples/doll-sister/theme.json b/examples/doll-sister/theme.json new file mode 100644 index 0000000..2af7780 --- /dev/null +++ b/examples/doll-sister/theme.json @@ -0,0 +1,120 @@ +{ + "schemaVersion": 1, + "id": "doll-sister", + "displayName": "Doll Sister / 玩偶姐姐", + "version": "1.0.0", + "images": { + "hero": "assets/hero.png", + "texture": "assets/texture.png" + }, + "copy": { + "brandTitle": "玩偶姐姐专属定制皮肤", + "brandSubtitle": "Codex App 娃娃屋限定版 ✦", + "signature": "Doll Sister ♡", + "tagline": "与玩偶姐姐一起,把灵感缝进每一行代码 ♡", + "projectPrefix": "灵感衣橱 · ", + "projectLabel": "🎀 选择今天的创作项目", + "ribbon": "🎀" + }, + "targets": { + "codex": { + "css": "codex.css", + "options": { + "rendererProfile": "codex-theme-v1", + "baseTheme": { + "mode": "light", + "codeTheme": "codex", + "accent": "#B688BA", + "contrast": 76, + "ink": "#4C3658", + "surface": "#FFF9F7", + "opaqueWindows": true, + "fonts": { + "windowsCode": "Cascadia Code", + "windowsUi": "Microsoft YaHei UI", + "macCode": "SF Mono", + "macUi": "PingFang SC" + }, + "semanticColors": { + "diffAdded": "#CDE8D7", + "diffRemoved": "#F2CCD7", + "skill": "#C39AC9" + } + } + }, + "verification": { + "contexts": [ + { + "name": "home", + "when": { + "any": ["[role='main']:has([data-testid='home-icon'])"] + }, + "required": [ + { + "name": "home-hero-shell", + "any": ["[role='main']:has([data-testid='home-icon']) > div:first-child > div:first-child > div:first-child"] + } + ], + "recommended": [ + { + "name": "home-suggestions", + "any": [".group\\/home-suggestions"] + } + ] + } + ] + } + }, + "workbuddy": { + "css": "workbuddy.css", + "verification": { + "required": [ + { + "name": "chat-surface", + "any": [".chat-container", ".wb-cb-chat"] + } + ], + "contexts": [ + { + "name": "home", + "when": { + "any": [".wb-home-page"] + }, + "required": [ + { + "name": "home-hero", + "any": [".wb-home-header"] + }, + { + "name": "home-composer", + "any": [".wb-home-composer"] + } + ], + "recommended": [ + { + "name": "scene-tabs", + "any": [".wb-scene-tabs"] + }, + { + "name": "quick-actions", + "any": [".quick-actions"] + } + ] + }, + { + "name": "conversation", + "when": { + "any": [".chat-container:not(.chat-container--welcome)"] + }, + "required": [ + { + "name": "conversation-composer-shell", + "any": [".chat-container:not(.chat-container--welcome) section:has([role='textbox'][contenteditable='true'])"] + } + ] + } + ] + } + } + } +} diff --git a/examples/doll-sister/workbuddy.css b/examples/doll-sister/workbuddy.css new file mode 100644 index 0000000..0912fa2 --- /dev/null +++ b/examples/doll-sister/workbuddy.css @@ -0,0 +1,521 @@ +:root.codedrobe-host-workbuddy { + color-scheme: light !important; + --doll-wb-ink: #4b3851; + --doll-wb-muted: #806b84; + --doll-wb-plum: #76507e; + --doll-wb-lavender: #b688ba; + --doll-wb-pink: #d99ab3; + --doll-wb-cream: #fffaf7; + --doll-wb-line: rgba(145, 99, 151, .23); + --doll-wb-shadow: rgba(86, 55, 91, .13); +} + +html.codedrobe-host-workbuddy, +html.codedrobe-host-workbuddy body, +html.codedrobe-host-workbuddy #root, +html.codedrobe-host-workbuddy .teams-container { + background: + linear-gradient(rgba(255, 250, 248, .88), rgba(251, 239, 244, .92)), + var(--codedrobe-image-texture) center / 720px auto repeat !important; + color: var(--doll-wb-ink) !important; + font-family: "Avenir Next", "PingFang SC", "Microsoft YaHei UI", sans-serif !important; +} + +html.codedrobe-host-workbuddy body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(circle at 86% 8%, rgba(255, 255, 255, .98) 0 2px, transparent 3px), + radial-gradient(circle at 16% 19%, rgba(220, 159, 182, .42) 0 1px, transparent 2px); + background-size: 83px 83px, 67px 67px; + opacity: .42; +} + +html.codedrobe-host-workbuddy .conversation-sidebar, +html.codedrobe-host-workbuddy .conversation-list { + background: + linear-gradient(180deg, rgba(255, 252, 250, .95), rgba(248, 231, 238, .92)), + var(--codedrobe-image-texture) center / 590px auto repeat !important; + border-right: 1px solid var(--doll-wb-line) !important; + box-shadow: 12px 0 32px rgba(86, 55, 91, .08), inset -1px 0 rgba(255, 255, 255, .92) !important; + color: var(--doll-wb-ink) !important; + backdrop-filter: blur(18px) saturate(1.06) !important; +} + +html.codedrobe-host-workbuddy .conversation-sidebar :is(a, button, div, p, span, svg) { + color: var(--doll-wb-ink) !important; +} + +html.codedrobe-host-workbuddy .conversation-sidebar svg { + stroke: currentColor !important; +} + +html.codedrobe-host-workbuddy :is( + .conversation-list-topbar, + .conversation-list-header, + .conversation-list-content, + .conversation-list-footer +) { + background: transparent !important; +} + +html.codedrobe-host-workbuddy .conversation-list-logo { + color: #754e7d !important; + font-family: "Iowan Old Style", "Songti SC", serif !important; + font-weight: 750 !important; + letter-spacing: .02em; + text-shadow: 0 1px 0 white; +} + +html.codedrobe-host-workbuddy .conversation-list-logo::after { + content: " ♡"; + color: #d391aa; +} + +html.codedrobe-host-workbuddy :is( + .conversation-list-version-badge, + .conversation-section-label, + .conversation-show-more-button, + .conversation-list-tab-button-sub +) { + color: rgba(75, 56, 81, .52) !important; +} + +html.codedrobe-host-workbuddy .conversation-list-tab-button, +html.codedrobe-host-workbuddy .conversation-agent-card, +html.codedrobe-host-workbuddy .conversation-list-topbar button { + border: 1px solid transparent !important; + border-radius: 12px !important; + background: transparent !important; + transition: transform .18s ease, background .18s ease, box-shadow .18s ease !important; +} + +html.codedrobe-host-workbuddy :is( + .conversation-list-tab-button:hover, + .conversation-agent-card:hover, + .conversation-list-topbar button:hover +) { + background: linear-gradient(90deg, rgba(237, 214, 232, .75), rgba(253, 242, 246, .88)) !important; + border-color: rgba(145, 99, 151, .15) !important; + box-shadow: 0 4px 13px rgba(86, 55, 91, .07) !important; + transform: translateX(2px); +} + +html.codedrobe-host-workbuddy :is( + .conversation-list-tab-button.active, + .conversation-agent-card[aria-selected="true"], + .conversation-agent-card.active +) { + background: linear-gradient(90deg, rgba(231, 204, 226, .93), rgba(251, 237, 242, .94)) !important; + border-color: rgba(145, 99, 151, .27) !important; + box-shadow: 0 5px 16px rgba(86, 55, 91, .09), inset 0 0 0 2px rgba(255, 255, 255, .46) !important; +} + +html.codedrobe-host-workbuddy :is( + .teams-content-wrapper, + .teams-main-content, + .main-content, + .chat-container, + .wb-cb-chat +) { + background: transparent !important; + color: var(--doll-wb-ink) !important; +} + +html.codedrobe-host-workbuddy .teams-content-wrapper { + position: relative; + border-radius: 26px 0 0 0; + box-shadow: inset 1px 1px rgba(255, 255, 255, .92), -7px 0 25px rgba(86, 55, 91, .06); + overflow: hidden; +} + +html.codedrobe-host-workbuddy .main-content--welcome { + background: + linear-gradient(rgba(255, 251, 249, .90), rgba(255, 247, 248, .94)), + var(--codedrobe-image-texture) center / 820px auto repeat !important; +} + +html.codedrobe-host-workbuddy .wb-home-page { + width: min(860px, calc(100% - 32px)) !important; + max-width: none !important; + min-height: 638px !important; + height: 638px !important; + display: flex !important; + flex-direction: column !important; + gap: 13px !important; + justify-content: flex-start !important; + overflow: visible !important; +} + +html.codedrobe-host-workbuddy .wb-home-header { + position: relative !important; + isolation: isolate; + box-sizing: border-box !important; + width: 100% !important; + height: 244px !important; + min-height: 244px !important; + display: flex !important; + flex: 0 0 244px !important; + flex-direction: column !important; + align-items: flex-start !important; + justify-content: center !important; + padding: 0 0 0 42px !important; + overflow: hidden !important; + border: 1px solid rgba(145, 99, 151, .30) !important; + border-radius: 28px !important; + background-color: #fff9f7 !important; + background-image: + linear-gradient(90deg, #fff9f7 0%, rgba(255, 249, 247, .99) 45%, rgba(255, 249, 247, .75) 62%, rgba(255, 249, 247, .06) 83%), + var(--codedrobe-image-hero) !important; + background-repeat: no-repeat, no-repeat !important; + background-size: 100% 100%, auto 222% !important; + background-position: center, right 12% !important; + box-shadow: 0 16px 38px rgba(86, 55, 91, .15), inset 0 0 0 4px rgba(255, 255, 255, .34) !important; +} + +html.codedrobe-host-workbuddy .wb-home-header::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; + background: + radial-gradient(circle at 55% 27%, rgba(255, 249, 219, .95) 0 2px, transparent 3px), + radial-gradient(circle at 28% 75%, rgba(218, 158, 182, .50) 0 1px, transparent 2px); + background-size: 73px 73px, 91px 91px; + opacity: .72; +} + +html.codedrobe-host-workbuddy .wb-home-header::after { + content: "Doll Sister ♡ WorkBuddy"; + position: absolute; + left: 42px; + bottom: 28px; + color: #9a719f; + font: italic 15px/1.2 "Snell Roundhand", "Segoe Script", cursive; + letter-spacing: .04em; + text-shadow: 0 1px white; +} + +html.codedrobe-host-workbuddy .wb-home-header__title, +html.codedrobe-host-workbuddy .wb-home-header__subtitle { + width: 56% !important; + height: auto !important; + margin: 0 !important; + text-align: left !important; + color: var(--doll-wb-ink) !important; + text-shadow: 0 1px rgba(255, 255, 255, .96) !important; +} + +html.codedrobe-host-workbuddy .wb-home-header__title { + font-size: 0 !important; + line-height: 1 !important; +} + +html.codedrobe-host-workbuddy .wb-home-header__title::before { + content: "玩偶姐姐 × WorkBuddy"; + display: block; + font: 760 clamp(25px, 3vw, 34px)/1.22 "Iowan Old Style", "Songti SC", serif; + letter-spacing: .025em; +} + +html.codedrobe-host-workbuddy .wb-home-header__subtitle { + margin-top: 13px !important; + font-size: 0 !important; + line-height: 1 !important; +} + +html.codedrobe-host-workbuddy .wb-home-header__subtitle::before { + content: "把灵感缝进今天的每一项工作 ♡"; + display: block; + color: #765f7d; + font: 540 14px/1.55 "Avenir Next", "PingFang SC", sans-serif; + letter-spacing: .035em; +} + +html.codedrobe-host-workbuddy .wb-home-page > div:has(> .wb-scene-tabs) { + box-sizing: border-box !important; + width: 100% !important; + height: 52px !important; + min-height: 52px !important; + display: flex !important; + align-items: center !important; + padding: 6px 10px !important; + border: 1px solid rgba(145, 99, 151, .20) !important; + border-radius: 18px !important; + background: linear-gradient(90deg, rgba(255, 253, 251, .91), rgba(245, 225, 235, .86)) !important; + box-shadow: 0 7px 18px rgba(86, 55, 91, .08), inset 0 0 0 3px rgba(255, 255, 255, .44) !important; +} + +html.codedrobe-host-workbuddy .wb-scene-tabs { + width: auto !important; + height: 38px !important; + padding: 3px !important; + border: 0 !important; + border-radius: 14px !important; + background: rgba(235, 211, 231, .68) !important; +} + +html.codedrobe-host-workbuddy .wb-scene-tabs__pill { + height: 32px !important; + border: 1px solid transparent !important; + border-radius: 11px !important; + background: transparent !important; + color: #735778 !important; + transition: transform .18s ease, box-shadow .18s ease !important; +} + +html.codedrobe-host-workbuddy .wb-scene-tabs__pill:hover { + transform: translateY(-1px); +} + +html.codedrobe-host-workbuddy .wb-scene-tabs__pill--active { + border-color: rgba(145, 99, 151, .21) !important; + background: linear-gradient(145deg, #d9a5bd, #a976b0) !important; + color: white !important; + box-shadow: 0 5px 13px rgba(86, 55, 91, .18), inset 0 1px rgba(255, 255, 255, .35) !important; +} + +html.codedrobe-host-workbuddy .wb-scene-tabs__pill--active :is(span, svg) { + color: white !important; + stroke: currentColor !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer { + position: relative !important; + box-sizing: border-box !important; + width: 100% !important; + height: 303px !important; + min-height: 303px !important; + display: flex !important; + flex: 0 0 303px !important; + flex-direction: column !important; + gap: 10px !important; + padding: 0 !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer__chips { + width: calc(100% - 126px) !important; + height: 36px !important; + min-height: 36px !important; + overflow: visible !important; +} + +html.codedrobe-host-workbuddy .quick-actions, +html.codedrobe-host-workbuddy .quick-actions-container, +html.codedrobe-host-workbuddy .quick-actions__list { + width: 100% !important; + height: 36px !important; + overflow: visible !important; +} + +html.codedrobe-host-workbuddy .quick-actions__item { + min-height: 34px !important; + border: 1px solid rgba(145, 99, 151, .23) !important; + border-radius: 13px !important; + background: + linear-gradient(rgba(255, 253, 251, .92), rgba(249, 232, 240, .90)), + var(--codedrobe-image-texture) center / 480px auto !important; + color: #66506c !important; + box-shadow: 0 5px 13px rgba(86, 55, 91, .08), inset 0 1px rgba(255, 255, 255, .85) !important; + transition: transform .18s ease, box-shadow .18s ease !important; +} + +html.codedrobe-host-workbuddy .quick-actions__item:hover { + transform: translateY(-3px) rotate(-.25deg) !important; + box-shadow: 0 10px 19px rgba(86, 55, 91, .13) !important; +} + +html.codedrobe-host-workbuddy .quick-actions__item :is(span, svg) { + color: #76507e !important; + stroke: currentColor !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer__input-slot { + position: relative !important; + box-sizing: border-box !important; + width: 100% !important; + height: 247px !important; + min-height: 247px !important; + padding: 0 !important; + overflow: visible !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer__input-slot > section { + box-sizing: border-box !important; + width: 100% !important; + height: 236px !important; + min-height: 236px !important; + border: 1px solid rgba(145, 99, 151, .30) !important; + border-radius: 27px !important; + background: + linear-gradient(145deg, rgba(255, 253, 251, .96), rgba(248, 231, 238, .93)), + var(--codedrobe-image-texture) center / 620px auto !important; + color: var(--doll-wb-ink) !important; + box-shadow: 0 12px 29px rgba(86, 55, 91, .13), inset 0 0 0 4px rgba(255, 255, 255, .52) !important; + backdrop-filter: blur(18px) saturate(1.06) !important; + overflow: hidden !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer__input-slot::before { + content: "🎀"; + position: absolute; + left: -15px; + top: -15px; + z-index: 20; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border-radius: 50%; + font-size: 19px; + background: linear-gradient(145deg, #f7dce6, #c99dce); + box-shadow: 0 5px 14px rgba(86, 55, 91, .18), 0 0 0 3px rgba(255, 255, 255, .72); + pointer-events: none; +} + +html.codedrobe-host-workbuddy .wb-home-composer__input-slot::after { + content: ""; + position: absolute; + right: 19px; + top: -95px; + z-index: 10; + width: 91px; + height: 116px; + border: 7px solid rgba(255, 253, 252, .98); + border-bottom-width: 19px; + border-radius: 3px; + background-image: var(--codedrobe-image-hero); + background-repeat: no-repeat; + background-size: 285% auto; + background-position: 82% center; + box-shadow: 0 10px 24px rgba(86, 55, 91, .18), 0 0 0 1px rgba(145, 99, 151, .20); + transform: rotate(5deg); + pointer-events: none; +} + +html.codedrobe-host-workbuddy .wb-home-composer__input-slot img { + opacity: 0 !important; + pointer-events: none !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer [role="textbox"][contenteditable="true"] { + color: var(--doll-wb-ink) !important; + caret-color: #895b92 !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer [role="textbox"][contenteditable="true"] :is(p, span) { + color: rgba(75, 56, 81, .56) !important; +} + +html.codedrobe-host-workbuddy .wb-home-composer button, +html.codedrobe-host-workbuddy .wb-input-footer button { + border-color: rgba(145, 99, 151, .18) !important; + border-radius: 11px !important; + color: #66506c !important; +} + +html.codedrobe-host-workbuddy .wb-input-footer { + border-top-color: rgba(145, 99, 151, .12) !important; + background: rgba(255, 250, 248, .35) !important; +} + +html.codedrobe-host-workbuddy .main-content:not(.main-content--welcome), +html.codedrobe-host-workbuddy .chat-container:not(.chat-container--welcome) { + background: + linear-gradient(rgba(255, 251, 249, .90), rgba(255, 247, 248, .94)), + var(--codedrobe-image-texture) center / 820px auto repeat !important; +} + +html.codedrobe-host-workbuddy .chat-container:not(.chat-container--welcome) [role="textbox"][contenteditable="true"] { + color: var(--doll-wb-ink) !important; + caret-color: #895b92 !important; +} + +html.codedrobe-host-workbuddy .chat-container:not(.chat-container--welcome) section:has([role="textbox"][contenteditable="true"]) { + position: relative !important; + border: 1px solid rgba(145, 99, 151, .28) !important; + border-radius: 24px !important; + background: + linear-gradient(145deg, rgba(255, 253, 251, .96), rgba(248, 231, 238, .92)), + var(--codedrobe-image-texture) center / 620px auto !important; + color: var(--doll-wb-ink) !important; + box-shadow: 0 11px 27px rgba(86, 55, 91, .12), inset 0 0 0 3px rgba(255, 255, 255, .50) !important; + overflow: visible !important; +} + +html.codedrobe-host-workbuddy .chat-container:not(.chat-container--welcome) section:has([role="textbox"][contenteditable="true"])::before { + content: "🎀"; + position: absolute; + left: -13px; + top: -14px; + z-index: 10; + width: 29px; + height: 29px; + display: grid; + place-items: center; + border-radius: 50%; + font-size: 16px; + background: linear-gradient(145deg, #f7dce6, #c99dce); + box-shadow: 0 4px 12px rgba(86, 55, 91, .16), 0 0 0 3px rgba(255, 255, 255, .70); + pointer-events: none; +} + +html.codedrobe-host-workbuddy .chat-container:not(.chat-container--welcome) :is(table, th, td) { + border-color: rgba(145, 99, 151, .16) !important; +} + +html.codedrobe-host-workbuddy .chat-container:not(.chat-container--welcome) th { + background: rgba(240, 221, 234, .55) !important; + color: #5e4864 !important; +} + +@media (max-width: 1050px) { + html.codedrobe-host-workbuddy .wb-home-page { + width: calc(100% - 24px) !important; + } + + html.codedrobe-host-workbuddy .wb-home-header { + background-size: 100% 100%, auto 205% !important; + } +} + +@media (max-height: 720px) { + html.codedrobe-host-workbuddy .wb-home-page { + min-height: 566px !important; + height: 566px !important; + } + + html.codedrobe-host-workbuddy .wb-home-header { + height: 195px !important; + min-height: 195px !important; + flex-basis: 195px !important; + background-size: 100% 100%, auto 230% !important; + } + + html.codedrobe-host-workbuddy .wb-home-composer { + height: 254px !important; + min-height: 254px !important; + flex-basis: 254px !important; + } + + html.codedrobe-host-workbuddy .wb-home-composer__input-slot, + html.codedrobe-host-workbuddy .wb-home-composer__input-slot > section { + height: 198px !important; + min-height: 198px !important; + } +} + +@media (prefers-reduced-motion: reduce) { + html.codedrobe-host-workbuddy :is( + .conversation-list-tab-button, + .conversation-agent-card, + .wb-scene-tabs__pill, + .quick-actions__item + ) { + transition: none !important; + } +} diff --git a/package-lock.json b/package-lock.json index b70b0d8..b34df04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@codedrobe/core", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@codedrobe/core", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "bin": { "codedrobe": "bin/codedrobe.mjs" diff --git a/package.json b/package.json index 0205df4..dc94973 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codedrobe/core", - "version": "0.2.0", + "version": "0.3.0", "description": "Cross-platform CodeDrobe runtime, CLI, app adapters, and .codedrobe-theme tooling.", "type": "module", "license": "Apache-2.0", diff --git a/src/adapters/codex.mjs b/src/adapters/codex.mjs index cb86c55..568c97a 100644 --- a/src/adapters/codex.mjs +++ b/src/adapters/codex.mjs @@ -1,3 +1,6 @@ +import codexThemeV1Profile from "../runtime/profiles/codex-theme-v1.mjs"; +import codexHostSettings from "../host/codex-settings.mjs"; + const codex = { id: "codex", displayName: "OpenAI Codex", @@ -5,6 +8,10 @@ const codex = { lastVerified: { darwin: { appVersion: "26.707.72221", build: "5307", verifiedAt: "2026-07-16" }, }, + rendererProfiles: { + [codexThemeV1Profile.id]: codexThemeV1Profile, + }, + hostSettings: codexHostSettings, platforms: { darwin: { bundleId: "com.openai.codex", diff --git a/src/cli.mjs b/src/cli.mjs index 36efe07..93411db 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -1,7 +1,10 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { getAdapter, listAdapters } from "./adapters/index.mjs"; import { discoverApp, findRunningPids, launchApp } from "./runtime/launcher.mjs"; -import { applyTheme, captureScreenshot, probeApp, removeTheme, verifyTheme, watchTheme } from "./runtime/injector.mjs"; +import { DOM_SNAPSHOT_DEFAULT_MAX_NODES, DOM_SNAPSHOT_MAX_NODES } from "./runtime/dom-snapshot.mjs"; +import { captureScreenshot, probeApp, snapshotDom, verifyTheme, watchTheme } from "./runtime/injector.mjs"; +import { applySkin, restoreSkin } from "./runtime/skin.mjs"; import { lintThemePackage, readThemePackage, resolveThemeTarget, writeThemePackage } from "./theme/package.mjs"; import { convertLegacyThemeFile } from "./theme/legacy.mjs"; import { checkForUpdate, detectPackageManager, formatCommand, getUpdateCommand, maybeNotifyUpdate, updateCodeDrobe } from "./update.mjs"; @@ -13,8 +16,9 @@ Usage: codedrobe apps [--json] codedrobe detect [--app ] [--app-path ] [--json] codedrobe launch --app [--app-path ] [--port ] [--restart-existing] [--profile ] + codedrobe dom snapshot --app [--port ] [--output ] [--max-nodes ] [--include-hidden] [--timeout-ms ] codedrobe probe --app [--theme ] [--port ] [--timeout-ms ] - codedrobe apply --app --theme [--app-path ] [--port ] [--watch] [--restart-existing] + codedrobe apply --app --theme [--app-path ] [--port ] [--profile ] [--watch] [--restart-existing] [--no-launch] codedrobe verify --app [--theme ] [--port ] [--screenshot ] codedrobe restore --app [--port ] codedrobe update [--check] [--json] @@ -25,12 +29,13 @@ Usage: Safety: Existing apps are never restarted unless --restart-existing is provided. CDP is always bound to 127.0.0.1 by the launcher. + DOM snapshots exclude text, input values, accessible names, links, and media sources. --app-path accepts an app bundle, installation directory, or executable file.`; function parseArguments(argv) { const options = {}; const positional = []; - const boolean = new Set(["json", "watch", "restart-existing", "no-launch", "force", "check", "help", "version"]); + const boolean = new Set(["json", "watch", "restart-existing", "no-launch", "force", "check", "help", "version", "include-hidden"]); for (let index = 0; index < argv.length; index += 1) { const value = argv[index]; if (!value.startsWith("--")) { @@ -67,6 +72,14 @@ function parseTimeout(value, fallback) { return timeoutMs; } +function parseMaxNodes(value) { + const maxNodes = value === undefined ? DOM_SNAPSHOT_DEFAULT_MAX_NODES : Number(value); + if (!Number.isInteger(maxNodes) || maxNodes < 50 || maxNodes > DOM_SNAPSHOT_MAX_NODES) { + throw new Error(`Invalid max nodes '${value}'. Use an integer from 50 to ${DOM_SNAPSHOT_MAX_NODES}.`); + } + return maxNodes; +} + function requireOption(options, name) { if (!options[name]) throw new Error(`Missing required option --${name}.`); return options[name]; @@ -139,18 +152,16 @@ async function runApply(options) { const adapter = getAdapter(requireOption(options, "app")); const port = parsePort(options.port, adapter.defaultPort); const { targetTheme } = await loadTargetTheme(requireOption(options, "theme"), adapter.id); - if (!options["no-launch"]) { - await launchApp({ - adapter, - port, - appPath: options["app-path"], - profilePath: options.profile, - restartExisting: Boolean(options["restart-existing"]), - }); - } - const results = await applyTheme({ adapter, targetTheme, port }); - output({ action: "apply", appId: adapter.id, port, theme: targetTheme.theme, targets: results }, options.json); - ensurePassing(results, "Theme application"); + const result = await applySkin({ + adapter, + targetTheme, + port, + launch: !options["no-launch"], + appPath: options["app-path"], + profilePath: options.profile, + restartExisting: Boolean(options["restart-existing"]), + }); + output(result, options.json); if (options.watch) { await watchTheme({ adapter, @@ -182,6 +193,56 @@ async function runProbe(options) { ensureCompatible(results, `${adapter.displayName}`); } +async function runDomCommand(positional, options) { + if (positional[1] !== "snapshot") throw new Error("DOM command must be 'snapshot'."); + const adapter = getAdapter(requireOption(options, "app")); + const port = parsePort(options.port, adapter.defaultPort); + const timeoutMs = parseTimeout(options["timeout-ms"], 5000); + const maxNodes = parseMaxNodes(options["max-nodes"]); + if (!options.json) { + console.error(`[codedrobe] Reading ${adapter.displayName} DOM on 127.0.0.1:${port} (timeout ${timeoutMs}ms). Snapshot does not launch or mutate the app.`); + } + let targets; + try { + targets = await snapshotDom({ + adapter, + port, + timeoutMs, + maxNodes, + includeHidden: Boolean(options["include-hidden"]), + }); + } catch (cause) { + const error = new Error(`${cause.message}\nDOM snapshot only inspects an existing CDP session. Start it first with: codedrobe launch --app ${adapter.id} --port ${port}`); + error.code = cause.code; + error.cause = cause; + throw error; + } + const result = { + action: "dom-snapshot", + appId: adapter.id, + port, + targets, + }; + if (!options.output) { + output(result, true); + return; + } + const filename = path.resolve(options.output); + await fs.mkdir(path.dirname(filename), { recursive: true }); + await fs.writeFile(filename, `${JSON.stringify(result, null, 2)}\n`, "utf8"); + const nodeCount = targets.reduce((sum, item) => sum + (item.result?.nodes?.length ?? 0), 0); + const truncated = targets.some((item) => item.result?.summary?.truncated); + output(options.json ? { ...result, output: filename } : { + action: result.action, + appId: result.appId, + port, + output: filename, + targetCount: targets.length, + nodeCount, + truncated, + }, options.json); +} + async function runVerify(options) { const adapter = getAdapter(requireOption(options, "app")); const port = parsePort(options.port, adapter.defaultPort); @@ -200,12 +261,16 @@ async function runThemeCommand(positional, options) { if (action === "inspect") { if (!filename) throw new Error("Theme inspect requires a .codedrobe-theme file."); const bundle = await readThemePackage(path.resolve(filename)); + const imageNames = Object.keys(bundle.assets?.images ?? {}); + if (bundle.assets?.art && !imageNames.includes("hero")) imageNames.unshift("hero"); output({ format: bundle.format, schemaVersion: bundle.schemaVersion, theme: bundle.theme, targets: Object.keys(bundle.targets), - hasArt: Boolean(bundle.assets?.art), + images: imageNames, + imageCount: imageNames.length, + hasArt: imageNames.includes("hero"), exportedAt: bundle.exportedAt, warnings: lintThemePackage(bundle), }, options.json); @@ -278,6 +343,7 @@ async function dispatchCli(positional, options) { return; } if (command === "detect") return runDetect(options); + if (command === "dom") return runDomCommand(positional, options); if (command === "theme") return runThemeCommand(positional, options); if (command === "update") return runUpdate(options); @@ -298,8 +364,7 @@ async function dispatchCli(positional, options) { if (command === "apply") return runApply(options); if (command === "verify") return runVerify(options); if (command === "restore" || command === "remove") { - const results = await removeTheme({ adapter, port }); - output({ action: "restore", appId: adapter.id, port, targets: results }, options.json); + output(await restoreSkin({ adapter, port }), options.json); return; } throw new Error(`Unknown command '${command}'. Run 'codedrobe help'.`); diff --git a/src/host/codex-settings.mjs b/src/host/codex-settings.mjs new file mode 100644 index 0000000..628d6b3 --- /dev/null +++ b/src/host/codex-settings.mjs @@ -0,0 +1,221 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const CODEX_MANAGED_SETTINGS = [ + "appearanceTheme", + "appearanceLightCodeThemeId", + "appearanceLightChromeTheme", +]; + +function tomlString(value) { + return JSON.stringify(String(value)); +} + +export function buildCodexBaseThemeSettings(baseTheme = {}, platform = process.platform) { + const fonts = baseTheme.fonts && typeof baseTheme.fonts === "object" ? baseTheme.fonts : {}; + const semantic = baseTheme.semanticColors && typeof baseTheme.semanticColors === "object" + ? baseTheme.semanticColors + : {}; + const isMac = platform === "darwin"; + const codeFont = isMac ? fonts.macCode : fonts.windowsCode; + const uiFont = isMac ? fonts.macUi : fonts.windowsUi; + const chromeParts = [ + `accent = ${tomlString(baseTheme.accent ?? "#B65CFF")}`, + `contrast = ${Number.isFinite(baseTheme.contrast) ? baseTheme.contrast : 64}`, + `fonts = { code = ${tomlString(codeFont ?? (isMac ? "SF Mono" : "Cascadia Code"))}, ui = ${tomlString(uiFont ?? (isMac ? "PingFang SC" : "Microsoft YaHei UI"))} }`, + `ink = ${tomlString(baseTheme.ink ?? "#4A235F")}`, + `opaqueWindows = ${baseTheme.opaqueWindows === false ? "false" : "true"}`, + `semanticColors = { diffAdded = ${tomlString(semantic.diffAdded ?? "#BCE8CF")}, diffRemoved = ${tomlString(semantic.diffRemoved ?? "#F7B8CE")}, skill = ${tomlString(semantic.skill ?? "#C47BFF")} }`, + `surface = ${tomlString(baseTheme.surface ?? "#FFF4FA")}`, + ]; + return { + appearanceTheme: `appearanceTheme = ${tomlString(baseTheme.mode ?? "light")}`, + appearanceLightCodeThemeId: `appearanceLightCodeThemeId = ${tomlString(baseTheme.codeTheme ?? "codex")}`, + appearanceLightChromeTheme: `appearanceLightChromeTheme = { ${chromeParts.join(", ")} }`, + }; +} + +function desktopSection(content) { + const match = /^\[desktop\]\s*\r?\n(?.*?)(?=^\[|(?![\s\S]))/ms.exec(content); + if (match) return { content, match }; + const next = `${content.trimEnd()}\n\n[desktop]\n`; + return { content: next, match: /^\[desktop\]\s*\r?\n(?.*?)(?=^\[|(?![\s\S]))/ms.exec(next) }; +} + +function replaceSectionBody(content, match, body) { + const index = match.index + match[0].length - match.groups.body.length; + return content.slice(0, index) + body + content.slice(index + match.groups.body.length); +} + +function settingPattern(key) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`^${escaped}\\s*=.*(?:\\r?\\n|$)`, "gm"); +} + +function replaceUniqueSetting(body, key, line) { + const withoutDuplicates = body.replace(settingPattern(key), "").trimEnd(); + return `${withoutDuplicates}${withoutDuplicates ? "\n" : ""}${line}\n`; +} + +function managedChromeTablePattern() { + return /^\[desktop\.appearanceLightChromeTheme(?:\.[^\]]+)?\]\s*\r?\n.*?(?=^\[|(?![\s\S]))/gms; +} + +function removeManagedChromeTables(content) { + return content.replace(managedChromeTablePattern(), ""); +} + +function extractManagedChromeTables(content) { + return [...content.matchAll(managedChromeTablePattern())] + .map((match) => match[0].trimEnd()) + .filter(Boolean) + .join("\n\n"); +} + +function removeMisplacedRootSettings(content, keys) { + const firstTable = content.search(/^\[/m); + const rootEnd = firstTable === -1 ? content.length : firstTable; + let root = content.slice(0, rootEnd); + for (const key of keys) root = root.replace(settingPattern(key), ""); + return root + content.slice(rootEnd); +} + +function mergeDesktopSections(content) { + const pattern = /^\[desktop\]\s*\r?\n(?.*?)(?=^\[|(?![\s\S]))/gms; + const matches = [...content.matchAll(pattern)]; + if (matches.length <= 1) return content; + const body = matches.map((match) => match.groups.body.trim()).filter(Boolean).join("\n"); + let result = ""; + let cursor = 0; + for (const [index, match] of matches.entries()) { + result += content.slice(cursor, match.index); + if (index === 0) result += `[desktop]\n${body}${body ? "\n" : ""}`; + cursor = match.index + match[0].length; + } + return result + content.slice(cursor); +} + +export function applyCodexSettings(content, settings) { + const keys = Object.keys(settings); + const cleaned = mergeDesktopSections(removeMisplacedRootSettings(removeManagedChromeTables(content), keys)); + const section = desktopSection(cleaned); + let body = section.match.groups.body; + for (const [key, line] of Object.entries(settings)) body = replaceUniqueSetting(body, key, line); + return replaceSectionBody(section.content, section.match, body); +} + +export function restoreCodexSettings(current, backup, keys = CODEX_MANAGED_SETTINGS) { + const savedChromeTables = extractManagedChromeTables(backup); + const currentSection = desktopSection(mergeDesktopSections( + removeMisplacedRootSettings(removeManagedChromeTables(current), keys), + )); + const backupMatch = /^\[desktop\]\s*\r?\n(?.*?)(?=^\[|(?![\s\S]))/ms.exec(backup); + let body = currentSection.match.groups.body; + const savedBody = backupMatch?.groups.body ?? ""; + for (const key of keys) { + const saved = [...savedBody.matchAll(settingPattern(key))].at(-1)?.[0]?.trimEnd(); + body = body.replace(settingPattern(key), "").trimEnd(); + if (key === "appearanceLightChromeTheme" && savedChromeTables) continue; + if (saved) body = `${body}${body ? "\n" : ""}${saved}\n`; + } + const restored = replaceSectionBody(currentSection.content, currentSection.match, body); + if (!savedChromeTables) return restored; + return `${restored.trimEnd()}\n\n${savedChromeTables}\n`; +} + +export function defaultCodexSettingsPaths({ + platform = process.platform, + home = os.homedir(), + env = process.env, + stateRoot = null, +} = {}) { + let root = stateRoot; + if (!root && platform === "darwin") root = path.join(home, "Library", "Application Support", "CodeDrobe"); + if (!root && platform === "win32") root = path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "CodeDrobe"); + if (!root) root = path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "codedrobe", "codex"); + return { + configPath: path.join(home, ".codex", "config.toml"), + backupPath: path.join(root, "config.before-codedrobe.toml"), + }; +} + +async function writeAtomic(filename, content) { + const directory = path.dirname(filename); + const temporary = path.join(directory, `.${path.basename(filename)}.${process.pid}.${Date.now()}.tmp`); + const mode = (await fs.stat(filename)).mode; + try { + await fs.writeFile(temporary, content, { encoding: "utf8", mode }); + await fs.rename(temporary, filename); + } catch (error) { + await fs.rm(temporary, { force: true }).catch(() => {}); + throw error; + } +} + +function publicResult(value) { + const { rollback: _rollback, ...result } = value; + return result; +} + +export async function applyCodexBaseTheme({ targetTheme, platform = process.platform, options = {} }) { + const baseTheme = targetTheme?.options?.baseTheme; + if (!baseTheme) return { supported: true, applied: false, changed: false, restartRequired: false, rollback: async () => {} }; + if (typeof baseTheme !== "object" || Array.isArray(baseTheme)) { + throw new Error("Codex baseTheme must be an object."); + } + const defaults = defaultCodexSettingsPaths({ platform, ...options }); + const configPath = path.resolve(options.configPath ?? defaults.configPath); + const backupPath = path.resolve(options.backupPath ?? defaults.backupPath); + const before = await fs.readFile(configPath, "utf8"); + await fs.mkdir(path.dirname(backupPath), { recursive: true }); + let backupCreated = false; + try { + await fs.copyFile(configPath, backupPath, fs.constants.COPYFILE_EXCL); + backupCreated = true; + } catch (error) { + if (error.code !== "EEXIST") throw error; + } + const updated = applyCodexSettings(before, buildCodexBaseThemeSettings(baseTheme, platform)); + if (updated !== before) await writeAtomic(configPath, updated); + const transaction = { + supported: true, + applied: true, + changed: updated !== before, + restartRequired: updated !== before, + configPath, + backupPath, + backupCreated, + rollback: async () => { + if (updated !== before) await writeAtomic(configPath, before); + if (backupCreated) await fs.rm(backupPath, { force: true }); + }, + }; + return transaction; +} + +export async function restoreCodexBaseTheme({ platform = process.platform, options = {} } = {}) { + const defaults = defaultCodexSettingsPaths({ platform, ...options }); + const configPath = path.resolve(options.configPath ?? defaults.configPath); + const backupPath = path.resolve(options.backupPath ?? defaults.backupPath); + let backup; + try { + backup = await fs.readFile(backupPath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return { supported: true, restored: false, configPath, backupPath, reason: "missing-backup" }; + throw error; + } + const current = await fs.readFile(configPath, "utf8"); + const restored = restoreCodexSettings(current, backup); + if (restored !== current) await writeAtomic(configPath, restored); + await fs.rm(backupPath, { force: true }); + return { supported: true, restored: true, changed: restored !== current, configPath, backupPath }; +} + +export const codexHostSettings = { + apply: applyCodexBaseTheme, + restore: restoreCodexBaseTheme, + publicResult, +}; + +export default codexHostSettings; diff --git a/src/index.mjs b/src/index.mjs index 50eab56..2a4e9ea 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -1,9 +1,13 @@ export { getAdapter, listAdapters, registerAdapter } from "./adapters/index.mjs"; export { CdpSession, listCdpTargets } from "./cdp/session.mjs"; export { discoverApp, findRunningPids, launchApp } from "./runtime/launcher.mjs"; -export { applyTheme, captureScreenshot, findTargets, probeApp, removeTheme, verifyTheme, waitForTargets, watchTheme } from "./runtime/injector.mjs"; +export { DOM_SNAPSHOT_DEFAULT_MAX_NODES, DOM_SNAPSHOT_MAX_NODES } from "./runtime/dom-snapshot.mjs"; +export { applyTheme, captureScreenshot, findTargets, probeApp, removeTheme, snapshotDom, verifyTheme, waitForTargets, watchTheme } from "./runtime/injector.mjs"; +export { applySkin, restoreSkin } from "./runtime/skin.mjs"; +export { prepareHostSettings, restoreHostSettings } from "./runtime/host-settings.mjs"; export { MAX_THEME_PACKAGE_BYTES, + MAX_THEME_IMAGES, THEME_EXTENSION, THEME_FORMAT, THEME_SCHEMA_VERSION, diff --git a/src/runtime/dom-snapshot.mjs b/src/runtime/dom-snapshot.mjs new file mode 100644 index 0000000..df2a0b8 --- /dev/null +++ b/src/runtime/dom-snapshot.mjs @@ -0,0 +1,199 @@ +export const DOM_SNAPSHOT_DEFAULT_MAX_NODES = 1000; +export const DOM_SNAPSHOT_MAX_NODES = 5000; + +function landmarkProfile(adapter) { + const verification = adapter.verification ?? {}; + return [ + { name: "root", kind: "root", any: verification.rootAny ?? ["body"] }, + ...(verification.required ?? []).map((item) => ({ name: item.name, kind: "required", any: item.any })), + ...(verification.recommended ?? []).map((item) => ({ name: item.name, kind: "recommended", any: item.any })), + ]; +} + +export function buildDomSnapshotExpression(adapter, { + maxNodes = DOM_SNAPSHOT_DEFAULT_MAX_NODES, + includeHidden = false, +} = {}) { + if (!Number.isInteger(maxNodes) || maxNodes < 1 || maxNodes > DOM_SNAPSHOT_MAX_NODES) { + throw new Error(`DOM snapshot maxNodes must be an integer from 1 to ${DOM_SNAPSHOT_MAX_NODES}.`); + } + const config = JSON.stringify({ + appId: adapter.id, + maxNodes, + includeHidden: Boolean(includeHidden), + landmarks: landmarkProfile(adapter), + }); + + return `(() => { + const config = ${config}; + const round = (value) => Number.isFinite(value) ? Math.round(value * 10) / 10 : 0; + const limit = (value, length = 160) => String(value ?? '').slice(0, length); + const cssEscape = (value) => { + if (globalThis.CSS?.escape) return globalThis.CSS.escape(String(value)); + return String(value).replace(/(^-?\\d)|[^a-zA-Z0-9_-]/g, (character) => + '\\\\' + character.codePointAt(0).toString(16) + ' '); + }; + const attributeEscape = (value) => String(value) + .replace(/\\\\/g, '\\\\\\\\') + .replace(/"/g, '\\\\"') + .replace(/[\\n\\r\\f]/g, ' '); + const generatedClass = (name) => + name.length > 80 || /^css-[a-z0-9]{6,}$/i.test(name) || + /^_[a-z][a-z0-9]*_[a-z0-9]{5,}_\\d+$/i.test(name) || + /(?:^|[-_])[a-f0-9]{8,}(?:$|[-_])/i.test(name); + const elementVisible = (element, style, rect) => + rect.width > 0 && rect.height > 0 && style.display !== 'none' && + style.visibility !== 'hidden' && style.contentVisibility !== 'hidden'; + const selectorCount = (selector) => { + try { return { selector, count: document.querySelectorAll(selector).length, valid: true }; } + catch (error) { return { selector, count: 0, valid: false, error: limit(error?.message ?? error) }; } + }; + const candidateSelectors = (element, tag, semanticClasses, attributes) => { + const candidates = []; + if (element.id) candidates.push('#' + cssEscape(element.id)); + for (const name of ['data-testid', 'data-test-id', 'data-qa', 'data-component', 'data-feature']) { + if (attributes[name]) candidates.push('[' + name + '="' + attributeEscape(attributes[name]) + '"]'); + } + if (attributes.role) { + const role = '[role="' + attributeEscape(attributes.role) + '"]'; + candidates.push(attributes.contenteditable ? role + '[contenteditable="' + attributeEscape(attributes.contenteditable) + '"]' : role); + } + for (const name of semanticClasses.slice(0, 4)) candidates.push('.' + cssEscape(name)); + if (semanticClasses[0]) candidates.push(tag + '.' + cssEscape(semanticClasses[0])); + if (semanticClasses.length > 1) { + candidates.push('.' + cssEscape(semanticClasses[0]) + '.' + cssEscape(semanticClasses[1])); + } + if (!candidates.length && ['main', 'aside', 'nav', 'header', 'footer', 'dialog'].includes(tag)) candidates.push(tag); + return [...new Set(candidates)].slice(0, 8).map(selectorCount); + }; + const safeAttributeNames = [ + 'role', 'data-testid', 'data-test-id', 'data-qa', 'data-component', 'data-feature', + 'data-state', 'aria-current', 'aria-expanded', 'aria-selected', 'contenteditable', + 'type', 'name', 'tabindex', + ]; + const styleFields = [ + 'display', 'position', 'zIndex', 'boxSizing', 'flexDirection', 'alignItems', + 'justifyContent', 'gap', 'padding', 'margin', 'width', 'height', 'minWidth', + 'maxWidth', 'minHeight', 'maxHeight', 'overflowX', 'overflowY', 'color', + 'backgroundColor', 'backgroundImage', 'border', 'borderRadius', 'boxShadow', + 'opacity', 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'textAlign', + ]; + const elements = document.querySelectorAll('*'); + const recorded = new Map(); + const nodes = []; + let eligibleNodes = 0; + let openShadowRoots = 0; + let truncated = false; + for (const element of elements) { + if (element.shadowRoot) openShadowRoots += 1; + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const visible = elementVisible(element, style, rect); + if (!config.includeHidden && !visible) continue; + eligibleNodes += 1; + if (nodes.length >= config.maxNodes) { + truncated = true; + break; + } + const tag = element.tagName.toLowerCase(); + const classes = [...element.classList].map((name) => limit(name, 120)); + const semanticClasses = classes.filter((name) => !generatedClass(name)); + const attributes = {}; + for (const name of safeAttributeNames) { + const value = element.getAttribute(name); + if (value !== null && value !== '') attributes[name] = limit(value); + } + let parent = element.parentElement; + while (parent && !recorded.has(parent)) parent = parent.parentElement; + let depth = 0; + for (let current = element.parentElement; current; current = current.parentElement) depth += 1; + const styles = {}; + for (const field of styleFields) { + const value = field === 'backgroundImage' + ? String(style[field] ?? '').replace(/url\\((?:"[^"]*"|'[^']*'|[^)]*)\\)/gi, 'url()') + : style[field]; + styles[field] = limit(value, 240); + } + const index = nodes.length; + nodes.push({ + index, + parentIndex: parent ? recorded.get(parent) : null, + depth, + tag, + id: element.id ? limit(element.id, 160) : null, + classes, + semanticClasses, + attributes, + selectors: candidateSelectors(element, tag, semanticClasses, attributes), + rect: { + x: round(rect.x), y: round(rect.y), width: round(rect.width), height: round(rect.height), + }, + states: { + visible, + interactive: element.matches('a[href],button,input,textarea,select,[role="button"],[role="link"],[role="textbox"],[contenteditable="true"]'), + editable: element.matches('input,textarea,[role="textbox"],[contenteditable="true"]'), + }, + styles, + }); + recorded.set(element, index); + } + const landmarkResults = config.landmarks.map((landmark) => ({ + name: landmark.name, + kind: landmark.kind, + selectors: landmark.any.map((selector) => { + const result = selectorCount(selector); + return { ...result, visibleCount: result.valid ? [...document.querySelectorAll(selector)].filter((element) => { + const rect = element.getBoundingClientRect(); + return elementVisible(element, getComputedStyle(element), rect); + }).length : 0 }; + }), + })); + const redactSegment = (segment) => { + if (/^[a-f0-9]{16,}$/i.test(segment) || /^[0-9]{6,}$/.test(segment) || + /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(segment) || segment.length > 48) return ':id'; + return segment; + }; + const pathname = String(globalThis.location?.pathname ?? '') + .split('/').map(redactSegment).join('/').slice(0, 300); + const activeState = globalThis.__CODEDROBE__?.hosts?.[config.appId]; + const activeThemeId = activeState?.themeId ?? document.documentElement?.dataset?.codedrobeTheme ?? null; + const activeThemeVersion = activeState?.version ?? document.documentElement?.dataset?.codedrobeThemeVersion ?? null; + return { + schemaVersion: 1, + appId: config.appId, + capturedAt: new Date().toISOString(), + activeTheme: { + installed: Boolean(activeState || activeThemeId), + id: activeThemeId, + version: activeThemeVersion, + }, + route: { + protocol: limit(globalThis.location?.protocol ?? '', 32), + origin: globalThis.location?.origin && globalThis.location.origin !== 'null' ? limit(globalThis.location.origin, 160) : null, + pathname, + }, + viewport: { + width: innerWidth, + height: innerHeight, + devicePixelRatio: globalThis.devicePixelRatio ?? 1, + }, + privacy: { + textContent: false, + formValues: false, + accessibleNames: false, + linksAndMediaSources: false, + queryAndHash: false, + }, + limits: { maxNodes: config.maxNodes, includeHidden: config.includeHidden }, + summary: { + documentElements: elements.length, + eligibleNodes, + recordedNodes: nodes.length, + truncated, + openShadowRoots, + }, + landmarks: landmarkResults, + nodes, + }; + })()`; +} diff --git a/src/runtime/host-settings.mjs b/src/runtime/host-settings.mjs new file mode 100644 index 0000000..0821998 --- /dev/null +++ b/src/runtime/host-settings.mjs @@ -0,0 +1,23 @@ +function noOpTransaction() { + return { supported: false, applied: false, changed: false, restartRequired: false, rollback: async () => {} }; +} + +export async function prepareHostSettings({ adapter, targetTheme, platform = process.platform, options = {} }) { + if (typeof adapter.hostSettings?.apply !== "function") return noOpTransaction(); + return adapter.hostSettings.apply({ targetTheme, platform, options }); +} + +export async function restoreHostSettings({ adapter, platform = process.platform, options = {} }) { + if (typeof adapter.hostSettings?.restore !== "function") { + return { supported: false, restored: false, changed: false }; + } + return adapter.hostSettings.restore({ platform, options }); +} + +export function publicHostSettingsResult(adapter, transaction) { + if (typeof adapter.hostSettings?.publicResult === "function") { + return adapter.hostSettings.publicResult(transaction); + } + const { rollback: _rollback, ...result } = transaction; + return result; +} diff --git a/src/runtime/injector.mjs b/src/runtime/injector.mjs index 8c1fb66..a9c7684 100644 --- a/src/runtime/injector.mjs +++ b/src/runtime/injector.mjs @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { CdpSession, listCdpTargets } from "../cdp/session.mjs"; +import { buildDomSnapshotExpression, DOM_SNAPSHOT_DEFAULT_MAX_NODES } from "./dom-snapshot.mjs"; import { buildApplyExpression, buildProbeExpression, buildRemoveExpression, buildVerifyExpression } from "./renderer-payload.mjs"; const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); @@ -32,10 +33,10 @@ export async function waitForTargets(adapter, port, timeoutMs = 30000) { throw error; } -async function withSessions(targets, callback) { +async function withSessions(targets, callback, sessionTimeoutMs = 10000) { const results = []; for (const target of targets) { - const session = await new CdpSession(target).open(); + const session = await new CdpSession(target, sessionTimeoutMs).open(); try { results.push({ targetId: target.id, title: target.title, url: target.url, result: await callback(session, target) }); } finally { @@ -79,6 +80,19 @@ export async function probeApp({ adapter, targetTheme = null, port, timeoutMs = return withSessions(targets, (session) => waitForCompatibility(session, expression, Math.min(timeoutMs, 5000))); } +export async function snapshotDom({ + adapter, + port, + timeoutMs = 5000, + maxNodes = DOM_SNAPSHOT_DEFAULT_MAX_NODES, + includeHidden = false, +}) { + const targets = await waitForTargets(adapter, port, timeoutMs); + const expression = buildDomSnapshotExpression(adapter, { maxNodes, includeHidden }); + const results = await withSessions(targets, (session) => session.evaluate(expression), timeoutMs); + return results.map(({ targetId, result }) => ({ targetId, result })); +} + export async function applyTheme({ adapter, targetTheme, port, timeoutMs = 30000 }) { const targets = await waitForTargets(adapter, port, timeoutMs); const preflightExpression = buildProbeExpression(adapter, targetTheme.verification); @@ -88,11 +102,18 @@ export async function applyTheme({ adapter, targetTheme, port, timeoutMs = 30000 ); ensureCompatible(adapter, preflight); const expression = buildApplyExpression({ adapter, targetTheme }); - return withSessions(targets, async (session) => { - await session.evaluate(expression); - await delay(500); - return session.evaluate(buildVerifyExpression(adapter, targetTheme.theme, targetTheme.verification)); - }); + let rendererMutated = false; + try { + return await withSessions(targets, async (session) => { + await session.evaluate(expression); + rendererMutated = true; + await delay(500); + return session.evaluate(buildVerifyExpression(adapter, targetTheme.theme, targetTheme.verification, targetTheme)); + }); + } catch (error) { + error.rendererMutated = rendererMutated; + throw error; + } } export async function verifyTheme({ adapter, targetTheme, port, timeoutMs = 30000 }) { @@ -101,6 +122,7 @@ export async function verifyTheme({ adapter, targetTheme, port, timeoutMs = 3000 adapter, targetTheme?.theme ?? null, targetTheme?.verification ?? null, + targetTheme, ))); } @@ -127,57 +149,64 @@ export async function captureScreenshot({ adapter, port, output, timeoutMs = 300 } } -export async function watchTheme({ adapter, targetTheme, port, timeoutMs = 30000, onEvent = () => {} }) { +export async function watchTheme({ adapter, targetTheme, port, timeoutMs = 30000, onEvent = () => {}, signal = null }) { const expression = buildApplyExpression({ adapter, targetTheme }); const preflightExpression = buildProbeExpression(adapter, targetTheme.verification); const sessions = new Map(); - let stopping = false; + let stopping = Boolean(signal?.aborted); const stop = () => { stopping = true; }; process.once("SIGINT", stop); process.once("SIGTERM", stop); + signal?.addEventListener("abort", stop, { once: true }); - while (!stopping) { - let targets = []; - try { - targets = await waitForTargets(adapter, port, Math.min(timeoutMs, 2000)); - } catch (error) { - onEvent({ type: "waiting", message: error.message }); - await delay(900); - continue; - } - const activeIds = new Set(targets.map((target) => target.id)); - for (const [id, session] of sessions) { - if (!activeIds.has(id) || session.closed) { - session.close(); - sessions.delete(id); - } - } - for (const target of targets) { - if (sessions.has(target.id)) continue; - let session; + try { + while (!stopping) { + let targets = []; try { - session = await new CdpSession(target).open(); - const applyCompatible = async () => { - const result = await waitForCompatibility(session, preflightExpression, Math.min(timeoutMs, 5000)); - ensureCompatible(adapter, [{ targetId: target.id, title: target.title, url: target.url, result }]); - await session.evaluate(expression); - }; - session.on("Page.loadEventFired", () => { - setTimeout(() => applyCompatible().catch((error) => { - onEvent({ type: "error", code: error.code, message: error.message, missing: error.missing ?? [] }); - session.close(); - sessions.delete(target.id); - }), 250); - }); - await applyCompatible(); - sessions.set(target.id, session); - onEvent({ type: "injected", targetId: target.id, title: target.title }); + targets = await waitForTargets(adapter, port, Math.min(timeoutMs, 2000)); } catch (error) { - session?.close(); - onEvent({ type: "error", targetId: target.id, code: error.code, message: error.message, missing: error.missing ?? [] }); + onEvent({ type: "waiting", message: error.message }); + await delay(900); + continue; } + const activeIds = new Set(targets.map((target) => target.id)); + for (const [id, session] of sessions) { + if (!activeIds.has(id) || session.closed) { + session.close(); + sessions.delete(id); + } + } + for (const target of targets) { + if (sessions.has(target.id)) continue; + let session; + try { + session = await new CdpSession(target).open(); + const applyCompatible = async () => { + const result = await waitForCompatibility(session, preflightExpression, Math.min(timeoutMs, 5000)); + ensureCompatible(adapter, [{ targetId: target.id, title: target.title, url: target.url, result }]); + await session.evaluate(expression); + }; + session.on("Page.loadEventFired", () => { + setTimeout(() => applyCompatible().catch((error) => { + onEvent({ type: "error", code: error.code, message: error.message, missing: error.missing ?? [] }); + session.close(); + sessions.delete(target.id); + }), 250); + }); + await applyCompatible(); + sessions.set(target.id, session); + onEvent({ type: "injected", targetId: target.id, title: target.title }); + } catch (error) { + session?.close(); + onEvent({ type: "error", targetId: target.id, code: error.code, message: error.message, missing: error.missing ?? [] }); + } + } + await delay(900); } - await delay(900); + } finally { + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + signal?.removeEventListener("abort", stop); + for (const session of sessions.values()) session.close(); } - for (const session of sessions.values()) session.close(); } diff --git a/src/runtime/launcher.mjs b/src/runtime/launcher.mjs index 3f545ba..c313fb0 100644 --- a/src/runtime/launcher.mjs +++ b/src/runtime/launcher.mjs @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import net from "node:net"; import os from "node:os"; import path from "node:path"; import { execFile, spawn } from "node:child_process"; @@ -8,6 +9,20 @@ import { findTargets } from "./injector.mjs"; const execFileAsync = promisify(execFile); const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); +async function isPortOccupied(port) { + return new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + const finish = (occupied) => { + socket.destroy(); + resolve(occupied); + }; + socket.setTimeout(800); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} + function expandPath(value) { return value .replace(/^~(?=\/|$)/, os.homedir()) @@ -132,11 +147,21 @@ async function stopExisting(adapter, pids, platform = process.platform, executab } export async function launchApp({ adapter, port = adapter.defaultPort, appPath = null, profilePath = null, restartExisting = false, timeoutMs = 30000 }) { + let readyTargets = []; try { - const targets = await findTargets(adapter, port); - if (targets.length) return { appId: adapter.id, port, alreadyReady: true, targets: targets.length }; + readyTargets = await findTargets(adapter, port); + if (readyTargets.length && !restartExisting) { + return { appId: adapter.id, port, alreadyReady: true, targets: readyTargets.length }; + } } catch { /* Launch when the endpoint is absent. */ } + if (!readyTargets.length && await isPortOccupied(port)) { + const error = new Error(`Port ${port} is already occupied by another process.`); + error.code = "CODEDROBE_PORT_OCCUPIED"; + error.port = port; + throw error; + } + const discovered = await discoverApp(adapter, process.platform, appPath); if (!discovered) { if (appPath) { @@ -153,6 +178,13 @@ export async function launchApp({ adapter, port = adapter.defaultPort, appPath = await stopExisting(adapter, runningPids, process.platform, discovered.executable); } + if (await isPortOccupied(port)) { + const error = new Error(`Port ${port} is still occupied after stopping ${adapter.displayName}.`); + error.code = "CODEDROBE_PORT_OCCUPIED"; + error.port = port; + throw error; + } + const args = [`--remote-debugging-address=127.0.0.1`, `--remote-debugging-port=${port}`]; if (profilePath) { const resolved = path.resolve(profilePath); diff --git a/src/runtime/profiles/codex-theme-v1.mjs b/src/runtime/profiles/codex-theme-v1.mjs new file mode 100644 index 0000000..a3b7e61 --- /dev/null +++ b/src/runtime/profiles/codex-theme-v1.mjs @@ -0,0 +1,156 @@ +export const CODEX_THEME_V1_PROFILE = "codex-theme-v1"; + +function runtime({ theme, imageDataUrls = {}, imageUrls = {}, artDataUrl, artUrl = imageUrls.hero ?? artDataUrl }) { + const CHROME_ID = "codedrobe-codex-skin-chrome"; + const LEGACY_STYLE_ID = "codedrobe-codex-skin-style"; + const LEGACY_STATE_KEY = "__CODEDROBE_CODEX_SKIN_STATE__"; + const copy = { + brandTitle: theme.displayName ?? "CodeDrobe", + brandSubtitle: "", + signature: "", + tagline: "", + projectPrefix: "", + projectLabel: "", + ribbon: "✦", + ...(theme.copy ?? {}), + }; + const cssString = (value) => JSON.stringify(String(value ?? "")); + + const stopLegacyRuntime = () => { + window.__CODEDROBE_CODEX_SKIN_DISABLED__ = true; + const legacyState = window[LEGACY_STATE_KEY]; + try { legacyState?.cleanup?.(); } catch { /* Fall through to static cleanup. */ } + legacyState?.observer?.disconnect?.(); + if (legacyState?.timer) clearInterval(legacyState.timer); + if (legacyState?.scheduler?.timeout) clearTimeout(legacyState.scheduler.timeout); + if (legacyState?.artUrl) globalThis.URL?.revokeObjectURL?.(legacyState.artUrl); + document.getElementById(LEGACY_STYLE_ID)?.remove(); + delete window[LEGACY_STATE_KEY]; + }; + + stopLegacyRuntime(); + + const updateChromeCopy = (chrome) => { + chrome.querySelector(".dream-brand-title").textContent = String(copy.brandTitle ?? ""); + chrome.querySelector(".dream-brand-subtitle").textContent = String(copy.brandSubtitle ?? ""); + chrome.querySelector(".dream-signature").textContent = String(copy.signature ?? ""); + chrome.querySelector(".dream-ribbon-emoji").textContent = String(copy.ribbon ?? ""); + }; + + const ensure = () => { + const root = document.documentElement; + if (!root) return false; + root.classList.add("codedrobe-codex-skin"); + root.dataset.codexSkinTheme = theme.id; + if (artUrl) root.style.setProperty("--dream-art", `url("${artUrl}")`); + else root.style.removeProperty("--dream-art"); + root.style.setProperty("--dream-tagline", cssString(copy.tagline)); + root.style.setProperty("--dream-project-prefix", cssString(copy.projectPrefix)); + root.style.setProperty("--dream-project-label", cssString(copy.projectLabel)); + + const shellMain = document.querySelector("main.main-surface") || document.querySelector("main"); + const home = document.querySelector('[role="main"]:has([data-testid="home-icon"])'); + for (const candidate of document.querySelectorAll('[role="main"].dream-home')) { + if (candidate !== home) candidate.classList.remove("dream-home"); + } + if (home) home.classList.add("dream-home"); + if (!shellMain || !document.body) return true; + + shellMain.classList.toggle("dream-home-shell", Boolean(home)); + let chrome = document.getElementById(CHROME_ID); + if (!chrome || chrome.parentElement !== document.body || !chrome.querySelector(".dream-brand-title")) { + chrome?.remove(); + chrome = document.createElement("div"); + chrome.id = CHROME_ID; + chrome.setAttribute("aria-hidden", "true"); + chrome.innerHTML = ` +
+
+
+
+
`; + document.body.appendChild(chrome); + } + updateChromeCopy(chrome); + const shellBox = shellMain.getBoundingClientRect(); + chrome.style.left = `${Math.round(shellBox.left)}px`; + chrome.style.top = `${Math.round(shellBox.top)}px`; + chrome.style.width = `${Math.round(shellBox.width)}px`; + chrome.style.height = `${Math.round(shellBox.height)}px`; + chrome.classList.toggle("dream-home-shell", Boolean(home)); + return true; + }; + + const cleanup = () => { + const root = document.documentElement; + root?.classList.remove("codedrobe-codex-skin"); + if (root) delete root.dataset.codexSkinTheme; + root?.style.removeProperty("--dream-art"); + root?.style.removeProperty("--dream-tagline"); + root?.style.removeProperty("--dream-project-prefix"); + root?.style.removeProperty("--dream-project-label"); + document.querySelectorAll(".dream-home").forEach((node) => node.classList.remove("dream-home")); + document.querySelectorAll(".dream-home-shell").forEach((node) => node.classList.remove("dream-home-shell")); + document.getElementById(CHROME_ID)?.remove(); + }; + + const verify = () => { + const root = document.documentElement; + const chrome = document.getElementById(CHROME_ID); + const nativeHome = document.querySelector('[role="main"]:has([data-testid="home-icon"])'); + const markedHome = document.querySelector('[role="main"].dream-home'); + const missing = []; + if (!root?.classList.contains("codedrobe-codex-skin")) { + missing.push({ name: "legacy-root-class", selectors: ["html.codedrobe-codex-skin"] }); + } + if (!chrome) missing.push({ name: "legacy-chrome", selectors: ["#codedrobe-codex-skin-chrome"] }); + if (chrome && getComputedStyle(chrome).pointerEvents !== "none") { + missing.push({ name: "noninteractive-chrome", selectors: ["#codedrobe-codex-skin-chrome { pointer-events: none }"] }); + } + if (artDataUrl && !root?.style.getPropertyValue("--dream-art")) { + missing.push({ name: "legacy-art-variable", selectors: ["--dream-art"] }); + } + if (nativeHome && markedHome !== nativeHome) { + missing.push({ name: "legacy-home-marker", selectors: ['[role="main"].dream-home'] }); + } + return { + id: "codex-theme-v1", + pass: missing.length === 0, + missing, + rootClassPresent: Boolean(root?.classList.contains("codedrobe-codex-skin")), + chromePresent: Boolean(chrome), + homeContextActive: Boolean(nativeHome), + homeMarked: !nativeHome || markedHome === nativeHome, + }; + }; + + return { ensure, cleanup, verify }; +} + +function cleanup() { + window.__CODEDROBE_CODEX_SKIN_DISABLED__ = true; + const legacyState = window.__CODEDROBE_CODEX_SKIN_STATE__; + try { legacyState?.cleanup?.(); } catch { /* Fall through to static cleanup. */ } + legacyState?.observer?.disconnect?.(); + if (legacyState?.timer) clearInterval(legacyState.timer); + if (legacyState?.scheduler?.timeout) clearTimeout(legacyState.scheduler.timeout); + if (legacyState?.artUrl) globalThis.URL?.revokeObjectURL?.(legacyState.artUrl); + delete window.__CODEDROBE_CODEX_SKIN_STATE__; + const root = document.documentElement; + root?.classList.remove("codedrobe-codex-skin"); + if (root) delete root.dataset.codexSkinTheme; + root?.style.removeProperty("--dream-art"); + root?.style.removeProperty("--dream-tagline"); + root?.style.removeProperty("--dream-project-prefix"); + root?.style.removeProperty("--dream-project-label"); + document.querySelectorAll(".dream-home").forEach((node) => node.classList.remove("dream-home")); + document.querySelectorAll(".dream-home-shell").forEach((node) => node.classList.remove("dream-home-shell")); + document.getElementById("codedrobe-codex-skin-style")?.remove(); + document.getElementById("codedrobe-codex-skin-chrome")?.remove(); +} + +export default { + id: CODEX_THEME_V1_PROFILE, + runtime, + cleanup, +}; diff --git a/src/runtime/renderer-payload.mjs b/src/runtime/renderer-payload.mjs index 8d6229f..a5920ea 100644 --- a/src/runtime/renderer-payload.mjs +++ b/src/runtime/renderer-payload.mjs @@ -2,6 +2,26 @@ function safeHostClass(appId) { return `codedrobe-host-${String(appId).replace(/[^a-z0-9_-]/gi, "-")}`; } +function resolveRendererProfile(adapter, targetTheme) { + const profileId = targetTheme?.options?.rendererProfile; + if (profileId === undefined) return null; + if (typeof profileId !== "string" || !profileId.trim()) { + throw new Error(`Theme renderer profile for '${adapter.id}' must be a non-empty string.`); + } + const profile = adapter.rendererProfiles?.[profileId]; + if (!profile || typeof profile.runtime !== "function") { + throw new Error(`Adapter '${adapter.id}' does not support renderer profile '${profileId}'.`); + } + return profile; +} + +function fallbackCleanupSource(adapter) { + return Object.values(adapter.rendererProfiles ?? {}) + .filter((profile) => typeof profile.cleanup === "function") + .map((profile) => `try { (${profile.cleanup.toString()})(); } catch {}`) + .join("\n"); +} + function buildCompatibilityProfile(adapter, themeVerification = null) { const adapterProfile = adapter.verification ?? { rootAny: ["body"], required: [] }; const checks = (verification, scope, context = null) => [ @@ -32,7 +52,7 @@ function buildCompatibilityPrelude(adapter, themeVerification = null) { const appId = JSON.stringify(adapter.id); return ` const appId = ${appId}; - const profile = ${profile}; + const compatibilityProfile = ${profile}; const inspect = (selector) => { try { return { selector, node: document.querySelector(selector), valid: true, error: null }; } catch (error) { return { selector, node: null, valid: false, error: error?.message ?? String(error) }; } @@ -50,8 +70,8 @@ function buildCompatibilityPrelude(adapter, themeVerification = null) { invalidSelectors: inspected.filter((item) => !item.valid).map((item) => ({ selector: item.selector, error: item.error })), }; }; - const root = evaluateSelectors(profile.rootAny); - const contexts = profile.contexts.map((context) => { + const root = evaluateSelectors(compatibilityProfile.rootAny); + const contexts = compatibilityProfile.contexts.map((context) => { const trigger = evaluateSelectors(context.whenAny); return { scope: context.scope, @@ -64,8 +84,8 @@ function buildCompatibilityPrelude(adapter, themeVerification = null) { }); const activeContexts = new Set(contexts.filter((context) => context.active).map((context) => context.scope + ':' + context.name)); const checks = [ - ...profile.checks, - ...profile.contexts.flatMap((context) => activeContexts.has(context.scope + ':' + context.name) ? context.checks : []), + ...compatibilityProfile.checks, + ...compatibilityProfile.contexts.flatMap((context) => activeContexts.has(context.scope + ':' + context.name) ? context.checks : []), ]; const requirements = checks.map((item) => { const evaluated = evaluateSelectors(item.any); @@ -92,7 +112,7 @@ function buildCompatibilityPrelude(adapter, themeVerification = null) { const warnings = []; if (!root.matches.length) missing.push({ scope: 'adapter', context: null, severity: 'required', name: 'root', - selectors: profile.rootAny, invalidSelectors: root.invalidSelectors, + selectors: compatibilityProfile.rootAny, invalidSelectors: root.invalidSelectors, }); for (const item of requirements) { if (!item.pass && item.severity === 'required') missing.push(diagnostic(item)); @@ -112,18 +132,57 @@ function buildCompatibilityPrelude(adapter, themeVerification = null) { } export function buildApplyExpression({ adapter, targetTheme }) { + const profile = resolveRendererProfile(adapter, targetTheme); const host = JSON.stringify({ id: adapter.id, className: safeHostClass(adapter.id) }); const theme = JSON.stringify(targetTheme.theme); const css = JSON.stringify(targetTheme.css); - const art = JSON.stringify(targetTheme.artDataUrl); + const images = JSON.stringify({ + ...(targetTheme.imageDataUrls ?? {}), + ...(!targetTheme.imageDataUrls?.hero && targetTheme.artDataUrl ? { hero: targetTheme.artDataUrl } : {}), + }); + const profileId = JSON.stringify(profile?.id ?? null); + const profileFactory = profile ? `(${profile.runtime.toString()})` : "null"; return `(() => { const host = ${host}; const theme = ${theme}; const cssText = ${css}; - const artDataUrl = ${art}; + const imageDataUrls = ${images}; + const profileId = ${profileId}; + const profileFactory = ${profileFactory}; const rootState = window.__CODEDROBE__ ||= { hosts: {} }; rootState.hosts ||= {}; rootState.hosts[host.id]?.cleanup?.(); + const imageUrls = {}; + const ownedImageUrls = new Set(); + const resolveImageUrl = (dataUrl) => { + if (!dataUrl?.startsWith('data:')) return null; + try { + const comma = dataUrl.indexOf(','); + const mimeType = /^data:([^;,]+)/.exec(dataUrl)?.[1] || 'application/octet-stream'; + const binary = globalThis.atob(dataUrl.slice(comma + 1)); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + const objectUrl = globalThis.URL.createObjectURL(new Blob([bytes], { type: mimeType })); + ownedImageUrls.add(objectUrl); + return objectUrl; + } catch { /* Small data URLs remain a safe fallback when object URLs are unavailable. */ } + return dataUrl; + }; + for (const [name, dataUrl] of Object.entries(imageDataUrls)) { + const imageUrl = resolveImageUrl(dataUrl); + if (imageUrl && /^[a-z0-9][a-z0-9_-]*$/i.test(name)) imageUrls[name] = imageUrl; + } + const artDataUrl = imageDataUrls.hero ?? null; + const artUrl = imageUrls.hero ?? null; + let profileRuntime; + try { + profileRuntime = profileFactory ? profileFactory({ + theme, imageDataUrls, imageUrls, artDataUrl, artUrl, + }) : null; + } catch (error) { + for (const objectUrl of ownedImageUrls) globalThis.URL?.revokeObjectURL?.(objectUrl); + throw error; + } const styleId = 'codedrobe-theme-style-' + host.id; const ensure = () => { @@ -133,7 +192,10 @@ export function buildApplyExpression({ adapter, targetTheme }) { root.dataset.codedrobeHost = host.id; root.dataset.codedrobeTheme = theme.id; root.dataset.codedrobeThemeVersion = theme.version; - if (artDataUrl) root.style.setProperty('--codedrobe-art', 'url("' + artDataUrl + '")'); + for (const [name, imageUrl] of Object.entries(imageUrls)) { + root.style.setProperty('--codedrobe-image-' + name, 'url("' + imageUrl + '")'); + } + if (artUrl) root.style.setProperty('--codedrobe-art', 'url("' + artUrl + '")'); else root.style.removeProperty('--codedrobe-art'); let style = document.getElementById(styleId); if (!style) { @@ -145,6 +207,7 @@ export function buildApplyExpression({ adapter, targetTheme }) { style.textContent = cssText; style.dataset.themeVersion = theme.id + '@' + theme.version; } + profileRuntime?.ensure?.(); return true; }; @@ -159,10 +222,14 @@ export function buildApplyExpression({ adapter, targetTheme }) { observer.disconnect(); clearTimeout(timer); clearInterval(interval); + profileRuntime?.cleanup?.(); + for (const objectUrl of ownedImageUrls) globalThis.URL?.revokeObjectURL?.(objectUrl); + ownedImageUrls.clear(); document.getElementById(styleId)?.remove(); const root = document.documentElement; root?.classList.remove(host.className); root?.style.removeProperty('--codedrobe-art'); + for (const name of Object.keys(imageUrls)) root?.style.removeProperty('--codedrobe-image-' + name); if (root?.dataset.codedrobeHost === host.id) { delete root.dataset.codedrobeHost; delete root.dataset.codedrobeTheme; @@ -172,7 +239,12 @@ export function buildApplyExpression({ adapter, targetTheme }) { if (!Object.keys(rootState.hosts).length) root?.classList.remove('codedrobe-theme'); return true; }; - rootState.hosts[host.id] = { cleanup, ensure, observer, interval, themeId: theme.id, version: theme.version }; + rootState.hosts[host.id] = { + cleanup, ensure, observer, interval, + themeId: theme.id, version: theme.version, + imageNames: Object.keys(imageUrls), + profileId, verifyProfile: profileRuntime?.verify ?? null, + }; ensure(); return { installed: true, appId: host.id, themeId: theme.id, version: theme.version }; })()`; @@ -180,12 +252,31 @@ export function buildApplyExpression({ adapter, targetTheme }) { export function buildRemoveExpression(adapter) { const appId = JSON.stringify(adapter.id); + const hostClass = JSON.stringify(safeHostClass(adapter.id)); + const fallbackCleanup = fallbackCleanupSource(adapter); return `(() => { const appId = ${appId}; const state = window.__CODEDROBE__?.hosts?.[appId]; if (state?.cleanup) return state.cleanup(); + ${fallbackCleanup} document.getElementById('codedrobe-theme-style-' + appId)?.remove(); - document.documentElement?.classList.remove('codedrobe-host-' + appId); + const root = document.documentElement; + root?.classList.remove(${hostClass}); + root?.style.removeProperty('--codedrobe-art'); + if (root?.style) { + for (let index = root.style.length - 1; index >= 0; index -= 1) { + const name = root.style.item(index); + if (name.startsWith('--codedrobe-image-')) root.style.removeProperty(name); + } + } + if (root?.dataset.codedrobeHost === appId) { + delete root.dataset.codedrobeHost; + delete root.dataset.codedrobeTheme; + delete root.dataset.codedrobeThemeVersion; + } + if (root && ![...root.classList].some((name) => name.startsWith('codedrobe-host-'))) { + root.classList.remove('codedrobe-theme'); + } return true; })()`; } @@ -197,12 +288,20 @@ export function buildProbeExpression(adapter, themeVerification = null) { })()`; } -export function buildVerifyExpression(adapter, expectedTheme = null, themeVerification = null) { +export function buildVerifyExpression(adapter, expectedTheme = null, themeVerification = null, targetTheme = null) { + const profile = resolveRendererProfile(adapter, targetTheme); const expected = JSON.stringify(expectedTheme); + const expectedProfileId = JSON.stringify(profile?.id ?? null); return `(() => { ${buildCompatibilityPrelude(adapter, themeVerification)} const expected = ${expected}; + const expectedProfileId = ${expectedProfileId}; const state = window.__CODEDROBE__?.hosts?.[appId]; + const profile = state?.verifyProfile?.() ?? null; + const profileMissing = (profile?.missing ?? []).map((item) => ({ + scope: 'profile', context: profile.id ?? state?.profileId ?? null, severity: 'required', + name: item.name, selectors: item.selectors ?? [], invalidSelectors: [], + })); const result = { ...compatibility, installed: Boolean(state), @@ -210,9 +309,14 @@ export function buildVerifyExpression(adapter, expectedTheme = null, themeVerifi version: state?.version ?? null, stylePresent: Boolean(document.getElementById('codedrobe-theme-style-' + appId)), horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + images: state?.imageNames ?? [], + profile, }; + result.missing = [...result.missing, ...profileMissing]; const themeMatches = !expected || (result.themeId === expected.id && result.version === expected.version); - result.pass = result.compatible && result.installed && result.stylePresent && themeMatches && !result.horizontalOverflow; + const profileMatches = !expectedProfileId || (state?.profileId === expectedProfileId && profile?.pass === true); + result.pass = result.compatible && result.installed && result.stylePresent && themeMatches && + profileMatches && (profile?.pass ?? true) && !result.horizontalOverflow; return result; })()`; } diff --git a/src/runtime/skin.mjs b/src/runtime/skin.mjs new file mode 100644 index 0000000..8b17b6f --- /dev/null +++ b/src/runtime/skin.mjs @@ -0,0 +1,97 @@ +import { launchApp } from "./launcher.mjs"; +import { applyTheme, removeTheme } from "./injector.mjs"; +import { prepareHostSettings, publicHostSettingsResult, restoreHostSettings } from "./host-settings.mjs"; + +function verificationError(results) { + const failures = results.filter((item) => item.result?.pass === false); + const missing = failures.flatMap((item) => item.result?.missing ?? []); + const detail = missing + .map((item) => `${item.scope}${item.context ? `:${item.context}` : ""}:${item.name} (${item.selectors.join(" | ")})`) + .join("; "); + const error = new Error(`Theme application verification failed for ${failures.length} renderer target(s)${detail ? `: ${detail}` : "."}`); + error.code = "CODEDROBE_VERIFY_FAILED"; + error.missing = missing; + error.results = results; + return error; +} + +function restartRequiredError(adapter, port) { + const error = new Error(`${adapter.displayName} is already running, but its host appearance settings changed. Close it or pass --restart-existing so the complete skin can load.`); + error.code = "CODEDROBE_RESTART_REQUIRED"; + error.appId = adapter.id; + error.port = port; + return error; +} + +export async function applySkin({ + adapter, + targetTheme, + port = adapter.defaultPort, + launch = true, + appPath = null, + profilePath = null, + restartExisting = false, + timeoutMs = 30000, + hostOptions = {}, +} = {}) { + const hostTransaction = await prepareHostSettings({ adapter, targetTheme, options: hostOptions }); + let rendererMutated = false; + try { + const launchResult = launch + ? await launchApp({ adapter, port, appPath, profilePath, restartExisting, timeoutMs }) + : null; + if (hostTransaction.restartRequired && launchResult?.alreadyReady) { + throw restartRequiredError(adapter, port); + } + const targets = await applyTheme({ adapter, targetTheme, port, timeoutMs }); + rendererMutated = true; + if (targets.some((item) => item.result?.pass === false)) throw verificationError(targets); + return { + action: "apply", + appId: adapter.id, + port, + theme: targetTheme.theme, + launch: launchResult, + host: publicHostSettingsResult(adapter, hostTransaction), + targets, + }; + } catch (error) { + if (rendererMutated || error.rendererMutated) { + try { + error.rendererRollback = await removeTheme({ + adapter, + port, + timeoutMs: Math.min(timeoutMs, 3000), + }); + } catch (rendererRollbackError) { + error.rendererRollbackError = rendererRollbackError; + } + } + try { + await hostTransaction.rollback(); + } catch (rollbackError) { + error.rollbackError = rollbackError; + } + throw error; + } +} + +export async function restoreSkin({ + adapter, + port = adapter.defaultPort, + timeoutMs = 3000, + hostOptions = {}, +} = {}) { + let renderer; + try { + renderer = { restored: true, targets: await removeTheme({ adapter, port, timeoutMs }) }; + } catch (error) { + renderer = { + restored: false, + code: error.code ?? null, + message: error.message, + }; + } + const host = await restoreHostSettings({ adapter, options: hostOptions }); + return { action: "restore", appId: adapter.id, port, renderer, host }; +} diff --git a/src/theme/legacy.mjs b/src/theme/legacy.mjs index e6488d0..304d8d0 100644 --- a/src/theme/legacy.mjs +++ b/src/theme/legacy.mjs @@ -7,6 +7,7 @@ import { THEME_SCHEMA_VERSION, validateThemePackage, } from "./package.mjs"; +import { CODEX_THEME_V1_PROFILE } from "../runtime/profiles/codex-theme-v1.mjs"; export const LEGACY_THEME_FORMAT = "codex-theme"; export const LEGACY_THEME_EXTENSION = ".codex-theme"; @@ -81,6 +82,7 @@ export function convertLegacyThemePackage(legacyBundle) { const legacy = validateLegacyThemePackage(legacyBundle); const { manifest } = legacy; const options = { + rendererProfile: CODEX_THEME_V1_PROFILE, legacy: { format: LEGACY_THEME_FORMAT, schemaVersion: LEGACY_THEME_SCHEMA_VERSION, @@ -106,10 +108,12 @@ export function convertLegacyThemePackage(legacyBundle) { }, ...(legacy.art ? { assets: { - art: { - filename: legacy.art.filename, - mimeType: legacy.art.mimeType, - base64: legacy.art.base64, + images: { + hero: { + filename: legacy.art.filename, + mimeType: legacy.art.mimeType, + base64: legacy.art.base64, + }, }, }, } : {}), diff --git a/src/theme/package.mjs b/src/theme/package.mjs index 33a7ae5..b646cbf 100644 --- a/src/theme/package.mjs +++ b/src/theme/package.mjs @@ -5,8 +5,11 @@ export const THEME_FORMAT = "codedrobe-theme"; export const THEME_EXTENSION = ".codedrobe-theme"; export const THEME_SCHEMA_VERSION = 1; export const MAX_THEME_PACKAGE_BYTES = 30 * 1024 * 1024; +export const MAX_THEME_IMAGES = 32; const SAFE_ID = /^[a-z0-9][a-z0-9_-]*$/i; +const SAFE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]); +const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const REMOTE_CSS = /@import\s|url\(\s*["']?(?!data:)/i; const MAX_VERIFICATION_REQUIREMENTS = 32; const MAX_VERIFICATION_CONTEXTS = 16; @@ -26,10 +29,33 @@ function mimeTypeFor(filename) { case ".jpeg": return "image/jpeg"; case ".webp": return "image/webp"; case ".gif": return "image/gif"; - default: return "image/png"; + case ".png": return "image/png"; + default: return null; } } +function validateImageAsset(image, label) { + if (!image || typeof image !== "object" || Array.isArray(image)) { + throw new Error(`${label} must be an image object.`); + } + assertString(image.filename, `${label}.filename`); + assertString(image.mimeType, `${label}.mimeType`); + assertString(image.base64, `${label}.base64`); + if (path.basename(image.filename) !== image.filename) { + throw new Error(`${label}.filename must be a safe basename.`); + } + if (!SAFE_IMAGE_TYPES.has(image.mimeType)) { + throw new Error(`${label}.mimeType '${image.mimeType}' is not supported.`); + } + if (!BASE64.test(image.base64)) throw new Error(`${label}.base64 must contain valid Base64 data.`); +} + +function resolvedImageAssets(bundle) { + const images = { ...(bundle.assets?.images ?? {}) }; + if (bundle.assets?.art && !images.hero) images.hero = bundle.assets.art; + return images; +} + function validateSelectorArray(selectors, label) { if (!Array.isArray(selectors) || !selectors.length) { throw new Error(`${label} must be a non-empty selector array.`); @@ -188,6 +214,22 @@ function validateTarget(target, appId) { if (REMOTE_CSS.test(target.css)) { throw new Error(`Target '${appId}' contains an external CSS resource.`); } + if (target.options !== undefined && (!target.options || typeof target.options !== "object" || Array.isArray(target.options))) { + throw new Error(`targets.${appId}.options must be an object.`); + } + if (target.options?.rendererProfile !== undefined) { + assertString(target.options.rendererProfile, `targets.${appId}.options.rendererProfile`); + if (!SAFE_ID.test(target.options.rendererProfile)) { + throw new Error(`targets.${appId}.options.rendererProfile must be a safe id.`); + } + } + if (target.options?.baseTheme !== undefined && ( + !target.options.baseTheme + || typeof target.options.baseTheme !== "object" + || Array.isArray(target.options.baseTheme) + )) { + throw new Error(`targets.${appId}.options.baseTheme must be an object.`); + } validateVerification(target.verification, `targets.${appId}.verification`); } @@ -201,6 +243,9 @@ export function validateThemePackage(bundle) { assertString(bundle.theme?.displayName, "theme.displayName"); assertString(bundle.theme?.version, "theme.version"); if (!SAFE_ID.test(bundle.theme.id)) throw new Error(`Invalid theme id '${bundle.theme.id}'.`); + if (bundle.theme.copy !== undefined && (!bundle.theme.copy || typeof bundle.theme.copy !== "object" || Array.isArray(bundle.theme.copy))) { + throw new Error("theme.copy must be an object."); + } if (!bundle.targets || typeof bundle.targets !== "object" || Array.isArray(bundle.targets)) { throw new Error("Theme package requires a targets object."); } @@ -208,12 +253,25 @@ export function validateThemePackage(bundle) { if (!entries.length) throw new Error("Theme package must support at least one app target."); for (const [appId, target] of entries) validateTarget(target, appId); + if (bundle.assets !== undefined && (!bundle.assets || typeof bundle.assets !== "object" || Array.isArray(bundle.assets))) { + throw new Error("assets must be an object."); + } + if (bundle.assets?.images !== undefined) { + if (!bundle.assets.images || typeof bundle.assets.images !== "object" || Array.isArray(bundle.assets.images)) { + throw new Error("assets.images must be an object."); + } + const images = Object.entries(bundle.assets.images); + if (!images.length) throw new Error("assets.images must not be empty when provided."); + if (images.length > MAX_THEME_IMAGES) throw new Error(`assets.images exceeds ${MAX_THEME_IMAGES} entries.`); + for (const [name, image] of images) { + if (!SAFE_ID.test(name)) throw new Error(`assets.images contains invalid image id '${name}'.`); + validateImageAsset(image, `assets.images.${name}`); + } + } if (bundle.assets?.art) { - assertString(bundle.assets.art.filename, "assets.art.filename"); - assertString(bundle.assets.art.mimeType, "assets.art.mimeType"); - assertString(bundle.assets.art.base64, "assets.art.base64"); - if (path.basename(bundle.assets.art.filename) !== bundle.assets.art.filename) { - throw new Error("assets.art.filename must be a safe basename."); + validateImageAsset(bundle.assets.art, "assets.art"); + if (bundle.assets.images?.hero) { + throw new Error("assets.art cannot be combined with assets.images.hero."); } } return bundle; @@ -235,13 +293,18 @@ export function resolveThemeTarget(bundle, appId) { if (!target) { throw new Error(`Theme '${bundle.theme.id}' does not support app '${appId}'.`); } - const art = bundle.assets?.art; + const imageAssets = resolvedImageAssets(bundle); + const imageDataUrls = Object.fromEntries(Object.entries(imageAssets).map(([name, image]) => [ + name, + `data:${image.mimeType};base64,${image.base64}`, + ])); return { theme: bundle.theme, css: target.css, options: target.options ?? {}, verification: target.verification ?? null, - artDataUrl: art ? `data:${art.mimeType};base64,${art.base64}` : null, + imageDataUrls, + artDataUrl: imageDataUrls.hero ?? null, }; } @@ -263,18 +326,33 @@ export async function buildThemePackage(manifestFilename) { }; } - let assets; - if (source.art) { - const artPath = path.resolve(base, source.art); - const filename = path.basename(source.art).replace(/[^a-z0-9._-]/gi, "-") || "art.png"; - assets = { - art: { - filename, - mimeType: mimeTypeFor(artPath), - base64: (await fs.readFile(artPath)).toString("base64"), - }, + if (source.images !== undefined && (!source.images || typeof source.images !== "object" || Array.isArray(source.images))) { + throw new Error("images must be an object."); + } + if (source.art && source.images?.hero) { + throw new Error("Source manifest art cannot be combined with images.hero."); + } + const sourceImages = { + ...(source.art ? { hero: source.art } : {}), + ...(source.images ?? {}), + }; + if (Object.keys(sourceImages).length > MAX_THEME_IMAGES) { + throw new Error(`Source manifest images exceeds ${MAX_THEME_IMAGES} entries.`); + } + const images = {}; + for (const [name, sourceFilename] of Object.entries(sourceImages)) { + if (!SAFE_ID.test(name)) throw new Error(`Source manifest contains invalid image id '${name}'.`); + assertString(sourceFilename, `images.${name}`); + const imagePath = path.resolve(base, sourceFilename); + const mimeType = mimeTypeFor(imagePath); + if (!mimeType) throw new Error(`images.${name} uses an unsupported image file type.`); + images[name] = { + filename: path.basename(sourceFilename).replace(/[^a-z0-9._-]/gi, "-") || `${name}.png`, + mimeType, + base64: (await fs.readFile(imagePath)).toString("base64"), }; } + const assets = Object.keys(images).length ? { images } : null; const bundle = validateThemePackage({ format: THEME_FORMAT, diff --git a/src/version.mjs b/src/version.mjs index edbab61..387b59e 100644 --- a/src/version.mjs +++ b/src/version.mjs @@ -1 +1 @@ -export const VERSION = "0.2.0"; +export const VERSION = "0.3.0"; diff --git a/tests/cli.test.mjs b/tests/cli.test.mjs index 0e5e6a4..f930a15 100644 --- a/tests/cli.test.mjs +++ b/tests/cli.test.mjs @@ -9,3 +9,12 @@ test("probe documents and validates its configurable timeout", async () => { /integer from 250 to 300000 milliseconds/, ); }); + +test("DOM snapshot is documented and validates its node limit", async () => { + assert.match(HELP, /dom snapshot.+--max-nodes .+--include-hidden/); + assert.match(HELP, /exclude text, input values, accessible names, links, and media sources/); + await assert.rejects( + runCli(["dom", "snapshot", "--app", "workbuddy", "--max-nodes", "10"]), + /integer from 50 to 5000/, + ); +}); diff --git a/tests/codex-renderer-profile.test.mjs b/tests/codex-renderer-profile.test.mjs new file mode 100644 index 0000000..0d33146 --- /dev/null +++ b/tests/codex-renderer-profile.test.mjs @@ -0,0 +1,202 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { getAdapter } from "../src/adapters/index.mjs"; +import { buildApplyExpression, buildRemoveExpression, buildVerifyExpression } from "../src/runtime/renderer-payload.mjs"; + +class ClassList extends Set { + add(...names) { + for (const name of names) super.add(name); + return this; + } + remove(...names) { for (const name of names) this.delete(name); } + contains(name) { return this.has(name); } + toggle(name, force) { + const enabled = force === undefined ? !this.has(name) : Boolean(force); + if (enabled) this.add(name); else this.delete(name); + return enabled; + } +} + +class Style { + values = new Map(); + get length() { return this.values.size; } + item(index) { return [...this.values.keys()][index] ?? ""; } + setProperty(name, value) { this.values.set(name, value); } + removeProperty(name) { this.values.delete(name); } + getPropertyValue(name) { return this.values.get(name) ?? ""; } +} + +class Element { + constructor(document, tag = "div") { + this.document = document; + this.tag = tag; + this.id = ""; + this.dataset = {}; + this.style = new Style(); + this.classList = new ClassList(); + this.parentElement = null; + this.childrenBySelector = new Map(); + this.textContent = ""; + } + setAttribute() {} + set innerHTML(_value) { + for (const selector of [".dream-brand-title", ".dream-brand-subtitle", ".dream-signature", ".dream-ribbon-emoji"]) { + this.childrenBySelector.set(selector, new Element(this.document)); + } + } + querySelector(selector) { return this.childrenBySelector.get(selector) ?? null; } + appendChild(child) { + child.parentElement = this; + if (child.id) this.document.ids.set(child.id, child); + return child; + } + remove() { + if (this.id) this.document.ids.delete(this.id); + this.parentElement = null; + } + getBoundingClientRect() { return { left: 80, top: 0, width: 1120, height: 800 }; } +} + +function browserContext() { + const ids = new Map(); + const revokedUrls = []; + let objectUrlSequence = 0; + const document = { ids }; + const root = new Element(document, "html"); + root.scrollWidth = 1200; + root.clientWidth = 1200; + const body = new Element(document, "body"); + const head = new Element(document, "head"); + const shell = new Element(document, "main"); + const home = new Element(document, "main"); + const sidebar = new Element(document, "aside"); + const composer = new Element(document, "div"); + document.documentElement = root; + document.body = body; + document.head = head; + document.createElement = (tag) => new Element(document, tag); + document.getElementById = (id) => ids.get(id) ?? null; + document.querySelector = (selector) => { + if (selector === "main.main-surface" || selector === "main") return shell; + if (selector === "aside.app-shell-left-panel") return sidebar; + if (selector === ".composer-surface-chrome") return composer; + if (selector === '[role="main"]:has([data-testid="home-icon"])') return home; + if (selector === '[role="main"].dream-home') return home.classList.contains("dream-home") ? home : null; + return null; + }; + document.querySelectorAll = (selector) => { + if (selector === '[role="main"].dream-home' || selector === ".dream-home") { + return home.classList.contains("dream-home") ? [home] : []; + } + if (selector === ".dream-home-shell") { + return [shell, ids.get("codedrobe-codex-skin-chrome")].filter((node) => node?.classList.contains("dream-home-shell")); + } + return []; + }; + const context = { + document, + window: {}, + innerWidth: 1200, + innerHeight: 800, + getComputedStyle: (node) => ({ + display: "block", + visibility: "visible", + pointerEvents: node?.id === "codedrobe-codex-skin-chrome" ? "none" : "auto", + }), + MutationObserver: class { observe() {} disconnect() {} }, + setTimeout: () => 1, + clearTimeout() {}, + setInterval: () => 1, + clearInterval() {}, + atob: globalThis.atob, + Blob: globalThis.Blob, + URL: { + createObjectURL: () => `blob:codedrobe-image-${++objectUrlSequence}`, + revokeObjectURL: (url) => revokedUrls.push(url), + }, + }; + return { context, root, home, ids, revokedUrls }; +} + +test("Codex legacy renderer profile restores old classes, copy, art, verification, and cleanup", () => { + const adapter = getAdapter("codex"); + const targetTheme = { + theme: { + id: "legacy-dream", + displayName: "Legacy Dream", + version: "1.0.0", + copy: { brandTitle: "Dream title", tagline: "Dream tagline", ribbon: "🌹" }, + }, + css: ":root.codedrobe-codex-skin { color: #432; } #codedrobe-codex-skin-chrome { pointer-events: none; }", + options: { rendererProfile: "codex-theme-v1" }, + verification: null, + imageDataUrls: { + hero: "data:image/png;base64,aGVsbG8=", + logo: "data:image/png;base64,d29ybGQ=", + }, + artDataUrl: "data:image/png;base64,aGVsbG8=", + }; + const { context, root, home, ids, revokedUrls } = browserContext(); + const oldStyle = context.document.createElement("style"); + oldStyle.id = "codedrobe-codex-skin-style"; + context.document.head.appendChild(oldStyle); + let oldRuntimeStopped = false; + context.window.__CODEDROBE_CODEX_SKIN_STATE__ = { + cleanup() { oldRuntimeStopped = true; }, + }; + + vm.runInNewContext(buildApplyExpression({ adapter, targetTheme }), context); + assert.equal(oldRuntimeStopped, true); + assert.equal(ids.has("codedrobe-codex-skin-style"), false); + assert.equal(context.window.__CODEDROBE_CODEX_SKIN_STATE__, undefined); + assert.equal(root.classList.contains("codedrobe-host-codex"), true); + assert.equal(root.classList.contains("codedrobe-codex-skin"), true); + assert.equal(root.style.getPropertyValue("--dream-art"), 'url("blob:codedrobe-image-1")'); + assert.equal(root.style.getPropertyValue("--codedrobe-image-hero"), 'url("blob:codedrobe-image-1")'); + assert.equal(root.style.getPropertyValue("--codedrobe-image-logo"), 'url("blob:codedrobe-image-2")'); + assert.equal(home.classList.contains("dream-home"), true); + const chrome = ids.get("codedrobe-codex-skin-chrome"); + assert.ok(chrome); + assert.equal(chrome.querySelector(".dream-brand-title").textContent, "Dream title"); + assert.equal(chrome.querySelector(".dream-ribbon-emoji").textContent, "🌹"); + + const verified = vm.runInNewContext(buildVerifyExpression(adapter, targetTheme.theme, null, targetTheme), context); + assert.equal(verified.pass, true); + assert.equal(verified.profile.id, "codex-theme-v1"); + assert.equal(verified.profile.homeMarked, true); + assert.deepEqual(JSON.parse(JSON.stringify(verified.images)), ["hero", "logo"]); + + vm.runInNewContext(buildRemoveExpression(adapter), context); + assert.equal(root.classList.contains("codedrobe-host-codex"), false); + assert.equal(root.classList.contains("codedrobe-codex-skin"), false); + assert.equal(home.classList.contains("dream-home"), false); + assert.equal(ids.has("codedrobe-codex-skin-chrome"), false); + assert.equal(root.style.getPropertyValue("--codedrobe-image-hero"), ""); + assert.equal(root.style.getPropertyValue("--codedrobe-image-logo"), ""); + assert.deepEqual(revokedUrls, ["blob:codedrobe-image-1", "blob:codedrobe-image-2"]); +}); + +test("Codex removal cleans a renderer that is still owned by the old Skill runtime", () => { + const adapter = getAdapter("codex"); + const { context, root, ids } = browserContext(); + root.classList.add("codedrobe-codex-skin"); + root.style.setProperty("--codedrobe-image-logo", 'url("blob:old-logo")'); + const oldStyle = context.document.createElement("style"); + oldStyle.id = "codedrobe-codex-skin-style"; + context.document.head.appendChild(oldStyle); + let disconnected = false; + context.window.__CODEDROBE_CODEX_SKIN_STATE__ = { + observer: { disconnect() { disconnected = true; } }, + timer: 1, + scheduler: { timeout: 1 }, + }; + + vm.runInNewContext(buildRemoveExpression(adapter), context); + + assert.equal(disconnected, true); + assert.equal(root.classList.contains("codedrobe-codex-skin"), false); + assert.equal(ids.has("codedrobe-codex-skin-style"), false); + assert.equal(root.style.getPropertyValue("--codedrobe-image-logo"), ""); + assert.equal(context.window.__CODEDROBE_CODEX_SKIN_STATE__, undefined); +}); diff --git a/tests/codex-settings.test.mjs b/tests/codex-settings.test.mjs new file mode 100644 index 0000000..bcee7a8 --- /dev/null +++ b/tests/codex-settings.test.mjs @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + applyCodexBaseTheme, + applyCodexSettings, + buildCodexBaseThemeSettings, + defaultCodexSettingsPaths, + restoreCodexBaseTheme, + restoreCodexSettings, +} from "../src/host/codex-settings.mjs"; + +const original = `model = "gpt"\n\n[desktop]\nunrelated = true\nappearanceTheme = "dark"\n\n[other]\nkeep = "yes"\n`; +const targetTheme = { + options: { + baseTheme: { + mode: "light", + codeTheme: "codex", + accent: "#b65cff", + contrast: 72, + fonts: { macCode: "SF Mono", macUi: "PingFang SC" }, + semanticColors: { diffAdded: "#bce8cf", diffRemoved: "#f7b8ce", skill: "#c47bff" }, + }, + }, +}; + +test("Codex settings keep the legacy CodeDrobe backup location for migration", () => { + assert.equal( + defaultCodexSettingsPaths({ platform: "darwin", home: "/Users/example" }).backupPath, + "/Users/example/Library/Application Support/CodeDrobe/config.before-codedrobe.toml", + ); + assert.equal( + defaultCodexSettingsPaths({ platform: "win32", home: "C:\\Users\\example", env: { LOCALAPPDATA: "C:\\Local" } }).backupPath, + path.join("C:\\Local", "CodeDrobe", "config.before-codedrobe.toml"), + ); +}); + +test("Codex settings update only managed desktop keys and restore them", () => { + const settings = buildCodexBaseThemeSettings(targetTheme.options.baseTheme, "darwin"); + const duplicated = original.replace( + 'appearanceTheme = "dark"', + 'appearanceTheme = "dark"\nappearanceTheme = "light"\nappearanceLightCodeThemeId = "old"', + ); + const applied = applyCodexSettings(duplicated, settings); + for (const key of Object.keys(settings)) { + assert.equal((applied.match(new RegExp(`^${key}\\s*=`, "gm")) ?? []).length, 1); + } + assert.match(applied, /unrelated = true/); + assert.match(applied, /\[other\]\nkeep = "yes"/); + assert.match(applied, /accent = "#b65cff"/); + + const restored = restoreCodexSettings(applied, original); + assert.match(restored, /appearanceTheme = "dark"/); + assert.doesNotMatch(restored, /appearanceLightCodeThemeId/); + assert.doesNotMatch(restored, /appearanceLightChromeTheme/); + assert.match(restored, /unrelated = true/); + assert.match(restored, /\[other\]\nkeep = "yes"/); +}); + +test("Codex settings preserve unrelated tables and expanded Chrome theme tables", () => { + const expanded = `appearanceTheme = "misplaced"\n\n[desktop]\nappearanceTheme = "dark"\nunrelated = true\n\n[desktop.appearanceLightChromeTheme]\naccent = "#123456"\ncontrast = 55\n\n[desktop.appearanceLightChromeTheme.fonts]\ncode = "Original Mono"\nui = "Original UI"\n\n[other]\nappearanceTheme = "other-table-value"\nkeep = "yes"\n`; + const settings = buildCodexBaseThemeSettings(targetTheme.options.baseTheme, "darwin"); + const applied = applyCodexSettings(expanded, settings); + + assert.doesNotMatch(applied, /^appearanceTheme = "misplaced"/m); + assert.doesNotMatch(applied, /^\[desktop\.appearanceLightChromeTheme/m); + assert.match(applied, /\[other\]\nappearanceTheme = "other-table-value"\nkeep = "yes"/); + + const restored = restoreCodexSettings(applied, expanded); + assert.match(restored, /\[desktop\.appearanceLightChromeTheme\]\naccent = "#123456"\ncontrast = 55/); + assert.match(restored, /\[desktop\.appearanceLightChromeTheme\.fonts\]\ncode = "Original Mono"\nui = "Original UI"/); + assert.match(restored, /\[other\]\nappearanceTheme = "other-table-value"\nkeep = "yes"/); + assert.match(restored, /appearanceTheme = "dark"/); + assert.doesNotMatch(restored, /appearanceLightChromeTheme = \{/); +}); + +test("Codex base theme apply is transactional and restore consumes the backup", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-codex-settings-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const configPath = path.join(directory, "config.toml"); + const backupPath = path.join(directory, "state", "config.before-codedrobe.toml"); + await fs.writeFile(configPath, original, "utf8"); + + const transaction = await applyCodexBaseTheme({ + targetTheme, + platform: "darwin", + options: { configPath, backupPath }, + }); + assert.equal(transaction.applied, true); + assert.equal(transaction.backupCreated, true); + assert.equal(await fs.readFile(backupPath, "utf8"), original); + assert.match(await fs.readFile(configPath, "utf8"), /appearanceTheme = "light"/); + + await transaction.rollback(); + assert.equal(await fs.readFile(configPath, "utf8"), original); + await assert.rejects(() => fs.access(backupPath), /ENOENT/); + + await applyCodexBaseTheme({ targetTheme, platform: "darwin", options: { configPath, backupPath } }); + const result = await restoreCodexBaseTheme({ platform: "darwin", options: { configPath, backupPath } }); + assert.equal(result.restored, true); + assert.match(await fs.readFile(configPath, "utf8"), /appearanceTheme = "dark"/); + assert.match(await fs.readFile(configPath, "utf8"), /unrelated = true/); + await assert.rejects(() => fs.access(backupPath), /ENOENT/); +}); diff --git a/tests/dom-snapshot.test.mjs b/tests/dom-snapshot.test.mjs new file mode 100644 index 0000000..254543d --- /dev/null +++ b/tests/dom-snapshot.test.mjs @@ -0,0 +1,143 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; +import { buildDomSnapshotExpression } from "../src/runtime/dom-snapshot.mjs"; + +function createElement({ tag = "div", id = "", classes = [], attributes = {}, parent = null, hidden = false } = {}) { + const element = { + tagName: tag.toUpperCase(), + id, + classList: classes, + parentElement: parent, + shadowRoot: null, + textContent: "TOP_SECRET_CONVERSATION", + value: "TOP_SECRET_INPUT", + getAttribute(name) { + if (name === "id") return id || null; + if (name === "class") return classes.join(" ") || null; + return Object.hasOwn(attributes, name) ? attributes[name] : null; + }, + getBoundingClientRect() { + return hidden + ? { x: 0, y: 0, width: 0, height: 0 } + : { x: 10, y: 20, width: 320, height: 80 }; + }, + matches(selector) { + if (tag === "button" && selector.includes("button")) return true; + if (attributes.role === "textbox" && selector.includes("[role=\"textbox\"]")) return true; + return false; + }, + }; + return element; +} + +function matchesSimple(element, selector) { + if (selector === "*") return true; + if (selector === "body") return element.tagName === "BODY"; + if (selector.startsWith("#")) return element.id === selector.slice(1); + const attributes = [...selector.matchAll(/\[([^=\]]+)="([^"]*)"\]/g)]; + if (attributes.some(([, name, value]) => element.getAttribute(name) !== value)) return false; + const withoutAttributes = selector.replace(/\[[^\]]+\]/g, ""); + const classes = [...withoutAttributes.matchAll(/\.([a-zA-Z0-9_/-]+)/g)].map((match) => match[1]); + if (classes.some((name) => !element.classList.includes(name))) return false; + const tag = withoutAttributes.match(/^[a-z]+/i)?.[0]; + return !tag || element.tagName.toLowerCase() === tag.toLowerCase(); +} + +test("DOM snapshot exposes selector and style evidence without renderer content", () => { + const html = createElement({ tag: "html", id: "root" }); + const body = createElement({ tag: "body", parent: html }); + const sidebar = createElement({ + tag: "aside", + classes: ["conversation-sidebar", "css-a1b2c3d4", "_grid_48kdk_4"], + attributes: { role: "navigation", "aria-label": "TOP_SECRET_LABEL" }, + parent: body, + }); + const button = createElement({ + tag: "button", + classes: ["new-task-button"], + attributes: { "data-testid": "new-task", "aria-label": "TOP_SECRET_BUTTON" }, + parent: sidebar, + }); + const hidden = createElement({ tag: "div", classes: ["hidden-panel"], parent: body, hidden: true }); + const elements = [html, body, sidebar, button, hidden]; + const context = { + document: { + documentElement: { dataset: { codedrobeTheme: "existing-theme", codedrobeThemeVersion: "2.0.0" } }, + querySelectorAll(selector) { + return elements.filter((element) => matchesSimple(element, selector)); + }, + }, + getComputedStyle(element) { + const rect = element.getBoundingClientRect(); + return { + display: rect.width ? "flex" : "none", + visibility: "visible", + contentVisibility: "visible", + position: "relative", + color: "rgb(73, 56, 79)", + backgroundColor: "rgb(255, 250, 248)", + backgroundImage: "url(\"TOP_SECRET_MEDIA\")", + }; + }, + innerWidth: 1280, + innerHeight: 800, + devicePixelRatio: 2, + location: { + protocol: "workbuddy:", + origin: "null", + pathname: "/chat/1234567890123456", + search: "?token=TOP_SECRET_QUERY", + hash: "#TOP_SECRET_HASH", + }, + CSS: { escape: (value) => value.replace(/\//g, "\\/") }, + }; + const adapter = { + id: "workbuddy", + verification: { + rootAny: ["#root"], + required: [{ name: "sidebar", any: [".conversation-sidebar"] }], + }, + }; + + const result = vm.runInNewContext(buildDomSnapshotExpression(adapter, { maxNodes: 50 }), context); + const plain = JSON.parse(JSON.stringify(result)); + assert.equal(plain.summary.documentElements, 5); + assert.deepEqual(plain.activeTheme, { installed: true, id: "existing-theme", version: "2.0.0" }); + assert.equal(plain.summary.eligibleNodes, 4); + assert.equal(plain.summary.recordedNodes, 4); + assert.equal(plain.summary.truncated, false); + assert.equal(plain.route.pathname, "/chat/:id"); + assert.equal(plain.nodes[2].parentIndex, 1); + assert.deepEqual(plain.nodes[2].semanticClasses, ["conversation-sidebar"]); + assert.equal(plain.nodes[2].selectors.find((item) => item.selector === ".conversation-sidebar").count, 1); + assert.equal(plain.nodes[3].states.interactive, true); + assert.equal(plain.landmarks[1].selectors[0].visibleCount, 1); + assert.equal(plain.privacy.textContent, false); + const serialized = JSON.stringify(plain); + assert.doesNotMatch(serialized, /TOP_SECRET/); + assert.doesNotMatch(serialized, /hidden-panel/); +}); + +test("DOM snapshot reports truncation and validates its node limit", () => { + assert.throws( + () => buildDomSnapshotExpression({ id: "test" }, { maxNodes: 5001 }), + /integer from 1 to 5000/, + ); + const elements = Array.from({ length: 4 }, (_, index) => createElement({ id: `node-${index}` })); + const context = { + document: { + documentElement: { dataset: {} }, + querySelectorAll: (selector) => selector === "*" ? elements : [], + }, + getComputedStyle: () => ({ display: "block", visibility: "visible", contentVisibility: "visible" }), + innerWidth: 800, + innerHeight: 600, + location: {}, + CSS: { escape: (value) => value }, + }; + const result = vm.runInNewContext(buildDomSnapshotExpression({ id: "test" }, { maxNodes: 3 }), context); + assert.equal(result.summary.recordedNodes, 3); + assert.equal(result.summary.eligibleNodes, 4); + assert.equal(result.summary.truncated, true); +}); diff --git a/tests/injector.test.mjs b/tests/injector.test.mjs index e874b00..0f6efcc 100644 --- a/tests/injector.test.mjs +++ b/tests/injector.test.mjs @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; import { getAdapter } from "../src/adapters/index.mjs"; -import { waitForTargets } from "../src/runtime/injector.mjs"; +import { waitForTargets, watchTheme } from "../src/runtime/injector.mjs"; test("target wait respects a short timeout and returns structured diagnostics", async (t) => { const server = http.createServer((_request, response) => { @@ -26,3 +26,25 @@ test("target wait respects a short timeout and returns structured diagnostics", ); assert.ok(Date.now() - startedAt < 1000); }); + +test("theme watcher can be owned and stopped by an AbortSignal", async () => { + const controller = new AbortController(); + controller.abort(); + const adapter = getAdapter("codex"); + const startedAt = Date.now(); + + await watchTheme({ + adapter, + targetTheme: { + theme: { id: "signal-test", displayName: "Signal test", version: "1.0.0" }, + css: "", + options: {}, + verification: null, + artDataUrl: null, + }, + port: adapter.defaultPort, + signal: controller.signal, + }); + + assert.ok(Date.now() - startedAt < 250); +}); diff --git a/tests/launcher.test.mjs b/tests/launcher.test.mjs index 2143d06..6bf9f2a 100644 --- a/tests/launcher.test.mjs +++ b/tests/launcher.test.mjs @@ -1,10 +1,11 @@ import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs/promises"; +import http from "node:http"; import os from "node:os"; import path from "node:path"; import { getAdapter } from "../src/adapters/index.mjs"; -import { discoverApp } from "../src/runtime/launcher.mjs"; +import { discoverApp, launchApp } from "../src/runtime/launcher.mjs"; test("custom app path accepts a macOS app bundle or executable", async (t) => { const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-app-path-")); @@ -32,3 +33,28 @@ test("custom app path does not fall back to default discovery", async () => { const adapter = getAdapter("workbuddy"); assert.equal(await discoverApp(adapter, "darwin", "/missing/WorkBuddy.app"), null); }); + +test("launcher reports an occupied custom CDP port before spawning", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-port-conflict-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const executable = path.join(directory, "WorkBuddy"); + await fs.writeFile(executable, "test executable"); + + const server = http.createServer((_request, response) => { + response.writeHead(404); + response.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + const port = server.address().port; + + await assert.rejects( + launchApp({ adapter: getAdapter("workbuddy"), appPath: executable, port, timeoutMs: 500 }), + (error) => { + assert.equal(error.code, "CODEDROBE_PORT_OCCUPIED"); + assert.equal(error.port, port); + assert.match(error.message, new RegExp(`Port ${port} is already occupied`)); + return true; + }, + ); +}); diff --git a/tests/legacy-theme.test.mjs b/tests/legacy-theme.test.mjs index 4a85317..90e7bed 100644 --- a/tests/legacy-theme.test.mjs +++ b/tests/legacy-theme.test.mjs @@ -37,7 +37,9 @@ test("converts a legacy Codex package into a single-target CodeDrobe package", ( assert.deepEqual(Object.keys(converted.targets), ["codex"]); assert.equal(converted.theme.copy.tagline, "Converted safely"); assert.equal(converted.targets.codex.options.baseTheme.accent, "#b65cff"); - assert.equal(converted.assets.art.filename, "cover.png"); + assert.equal(converted.targets.codex.options.rendererProfile, "codex-theme-v1"); + assert.equal(converted.assets.images.hero.filename, "cover.png"); + assert.match(resolveThemeTarget(converted, "codex").imageDataUrls.hero, /^data:image\/png;base64,/); assert.match(resolveThemeTarget(converted, "codex").css, /#432/); }); diff --git a/tests/skin.test.mjs b/tests/skin.test.mjs new file mode 100644 index 0000000..db2bcf1 --- /dev/null +++ b/tests/skin.test.mjs @@ -0,0 +1,135 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { getAdapter } from "../src/adapters/index.mjs"; +import { applyCodexBaseTheme } from "../src/host/codex-settings.mjs"; +import { applySkin, restoreSkin } from "../src/runtime/skin.mjs"; + +const original = `model = "gpt"\n\n[desktop]\nappearanceTheme = "dark"\nunrelated = true\n`; +const targetTheme = { + theme: { id: "legacy-dream", displayName: "Legacy Dream", version: "1.0.0" }, + css: ":root.codedrobe-codex-skin { color: #432; }", + options: { + rendererProfile: "codex-theme-v1", + baseTheme: { mode: "light", accent: "#b65cff" }, + }, + verification: null, + artDataUrl: null, +}; + +async function emptyCdpServer(t) { + const server = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end("[]"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + return server.address().port; +} + +async function readyCdpServer(t) { + const server = http.createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify([{ + id: "ready-target", + type: "page", + title: "Ready", + url: "app://-/index.html", + webSocketDebuggerUrl: "ws://127.0.0.1:1/devtools/page/ready-target", + }])); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + t.after(() => server.close()); + return server.address().port; +} + +test("skin application requires an explicit restart when live host settings changed", async (t) => { + let rolledBack = false; + const port = await readyCdpServer(t); + const adapter = { + id: "restart-test", + displayName: "Restart Test", + defaultPort: port, + platforms: {}, + matchTarget: () => true, + hostSettings: { + async apply() { + return { + supported: true, + applied: true, + changed: true, + restartRequired: true, + async rollback() { rolledBack = true; }, + }; + }, + async restore() { return { supported: true, restored: false }; }, + }, + }; + + await assert.rejects( + applySkin({ adapter, targetTheme, port }), + (error) => { + assert.equal(error.code, "CODEDROBE_RESTART_REQUIRED"); + assert.equal(error.port, port); + return true; + }, + ); + assert.equal(rolledBack, true); +}); + +test("skin application rolls back Codex host settings when renderer injection fails", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-skin-rollback-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const configPath = path.join(directory, "config.toml"); + const backupPath = path.join(directory, "state", "config.before-codedrobe.toml"); + await fs.writeFile(configPath, original, "utf8"); + const port = await emptyCdpServer(t); + + await assert.rejects( + applySkin({ + adapter: getAdapter("codex"), + targetTheme, + port, + launch: false, + timeoutMs: 250, + hostOptions: { configPath, backupPath }, + }), + (error) => error.code === "CODEDROBE_TARGET_TIMEOUT", + ); + + assert.equal(await fs.readFile(configPath, "utf8"), original); + await assert.rejects(() => fs.access(backupPath), /ENOENT/); +}); + +test("skin restore recovers Codex settings even when no renderer is available", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-skin-restore-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + const configPath = path.join(directory, "config.toml"); + const backupPath = path.join(directory, "state", "config.before-codedrobe.toml"); + await fs.writeFile(configPath, original, "utf8"); + await applyCodexBaseTheme({ + targetTheme, + platform: "darwin", + options: { configPath, backupPath }, + }); + const port = await emptyCdpServer(t); + + const result = await restoreSkin({ + adapter: getAdapter("codex"), + port, + timeoutMs: 250, + hostOptions: { configPath, backupPath }, + }); + + assert.equal(result.renderer.restored, false); + assert.equal(result.host.restored, true); + const restored = await fs.readFile(configPath, "utf8"); + assert.match(restored, /appearanceTheme = "dark"/); + assert.match(restored, /unrelated = true/); + assert.doesNotMatch(restored, /appearanceTheme = "light"/); + assert.doesNotMatch(restored, /appearanceLightChromeTheme/); + await assert.rejects(() => fs.access(backupPath), /ENOENT/); +}); diff --git a/tests/theme-package.test.mjs b/tests/theme-package.test.mjs index 2454679..cb3bba8 100644 --- a/tests/theme-package.test.mjs +++ b/tests/theme-package.test.mjs @@ -40,6 +40,60 @@ test("writes, reads, and resolves a .codedrobe-theme package", async () => { await assert.rejects(() => writeThemePackage(exampleManifest.pathname, output), /already exists/); }); +test("packs and resolves multiple named images with a backward-compatible hero alias", async (t) => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "codedrobe-theme-images-")); + t.after(() => fs.rm(directory, { recursive: true, force: true })); + await fs.writeFile(path.join(directory, "theme.css"), ".hero { background: var(--codedrobe-image-hero); }", "utf8"); + await fs.writeFile(path.join(directory, "hero.png"), Buffer.from("hero")); + await fs.writeFile(path.join(directory, "logo.webp"), Buffer.from("logo")); + await fs.writeFile(path.join(directory, "theme.json"), JSON.stringify({ + schemaVersion: 1, + id: "image-theme", + displayName: "Image Theme", + version: "1.0.0", + images: { hero: "hero.png", logo: "logo.webp" }, + targets: { codex: { css: "theme.css" } }, + }), "utf8"); + + const { bundle } = await buildThemePackage(path.join(directory, "theme.json")); + assert.deepEqual(Object.keys(bundle.assets.images), ["hero", "logo"]); + assert.equal(bundle.assets.images.logo.mimeType, "image/webp"); + assert.equal(bundle.assets.art, undefined); + const resolved = resolveThemeTarget(bundle, "codex"); + assert.deepEqual(Object.keys(resolved.imageDataUrls), ["hero", "logo"]); + assert.equal(resolved.artDataUrl, resolved.imageDataUrls.hero); + assert.match(resolved.imageDataUrls.logo, /^data:image\/webp;base64,/); +}); + +test("reads legacy assets.art as images.hero and rejects unsafe named images", () => { + const base = { + format: "codedrobe-theme", + schemaVersion: 1, + theme: { id: "images", displayName: "Images", version: "1.0.0" }, + targets: { codex: { css: ":root { color: red; }" } }, + }; + const legacy = { + ...base, + assets: { art: { filename: "hero.png", mimeType: "image/png", base64: "aGVsbG8=" } }, + }; + assert.match(resolveThemeTarget(legacy, "codex").imageDataUrls.hero, /^data:image\/png;base64,/); + assert.throws(() => validateThemePackage({ + ...base, + assets: { images: { "../logo": { filename: "logo.png", mimeType: "image/png", base64: "aGVsbG8=" } } }, + }), /invalid image id/); + assert.throws(() => validateThemePackage({ + ...base, + assets: { images: { logo: { filename: "logo.svg", mimeType: "image\/svg+xml", base64: "aGVsbG8=" } } }, + }), /not supported/); + assert.throws(() => validateThemePackage({ + ...base, + assets: { + art: { filename: "old.png", mimeType: "image/png", base64: "aGVsbG8=" }, + images: { hero: { filename: "new.png", mimeType: "image/png", base64: "aGVsbG8=" } }, + }, + }), /cannot be combined/); +}); + test("rejects external CSS resources and executable-looking package variants", () => { const base = { format: "codedrobe-theme", diff --git a/tests/types/consumer.ts b/tests/types/consumer.ts index 45fe550..492178b 100644 --- a/tests/types/consumer.ts +++ b/tests/types/consumer.ts @@ -1,8 +1,12 @@ import { + applySkin, convertLegacyThemePackage, getAdapter, launchApp, probeApp, + restoreSkin, + snapshotDom, + watchTheme, type LegacyThemePackage, type ThemePackage, } from "@codedrobe/core"; @@ -34,7 +38,14 @@ const converted: ThemePackage = convertLegacyThemePackage(legacy); const convertedFromThemeEntry: ThemePackage = convertLegacyFromThemeEntry(legacy); void convertedFromThemeEntry; const target = resolveThemeTarget(converted, adapter.id); +const imageUrls: Record = target.imageDataUrls; +void imageUrls; const warnings: ThemeLintWarning[] = []; void warnings; void launchApp({ adapter, port: 9444, appPath: "/Applications/ChatGPT.app" }); void probeApp({ adapter, port: 9444, targetTheme: target, timeoutMs: 5000 }); +void snapshotDom({ adapter, port: 9444, timeoutMs: 5000, maxNodes: 800 }); +void applySkin({ adapter, targetTheme: target, port: 9444, launch: false }); +void restoreSkin({ adapter, port: 9444 }); +const controller = new AbortController(); +void watchTheme({ adapter, targetTheme: target, port: 9444, signal: controller.signal }); diff --git a/types/adapters.d.ts b/types/adapters.d.ts index 0c2be93..2e9e3f0 100644 --- a/types/adapters.d.ts +++ b/types/adapters.d.ts @@ -4,6 +4,12 @@ export type { AdapterVerificationRecord, AppAdapter, CdpTarget, + HostSettingsAdapter, + HostSettingsResult, + HostSettingsTransaction, + RendererProfile, + RendererProfileRuntime, + RendererProfileVerification, SupportedPlatform, VerificationContext, VerificationProfile, diff --git a/types/index.d.ts b/types/index.d.ts index 563a3bf..f1063a8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -44,6 +44,61 @@ export interface AdapterVerificationRecord { verifiedAt: string; } +export interface RendererProfileVerification { + id: string; + pass: boolean; + missing?: Array<{ name: string; selectors?: string[] }>; + [key: string]: unknown; +} + +export interface RendererProfileRuntime { + ensure?: () => unknown; + cleanup?: () => unknown; + verify?: () => RendererProfileVerification; +} + +export interface RendererProfile { + id: string; + runtime(context: { + theme: ThemeIdentity; + imageDataUrls: Record; + imageUrls: Record; + artDataUrl: string | null; + artUrl: string | null; + }): RendererProfileRuntime; + cleanup?: () => unknown; +} + +export interface HostSettingsTransaction { + supported: boolean; + applied: boolean; + changed: boolean; + restartRequired?: boolean; + rollback(): Promise; + [key: string]: unknown; +} + +export interface HostSettingsResult { + supported: boolean; + applied?: boolean; + restored?: boolean; + changed?: boolean; + [key: string]: unknown; +} + +export interface HostSettingsAdapter { + apply(context: { + targetTheme: ResolvedThemeTarget; + platform: string; + options: Record; + }): Promise; + restore(context: { + platform: string; + options: Record; + }): Promise; + publicResult?(transaction: HostSettingsTransaction): HostSettingsResult; +} + export interface AppAdapter { id: string; displayName: string; @@ -51,6 +106,8 @@ export interface AppAdapter { platforms: Partial> & Record; lastVerified?: Partial>; verification?: VerificationProfile; + rendererProfiles?: Record; + hostSettings?: HostSettingsAdapter; matchTarget(target: CdpTarget): boolean; } @@ -61,15 +118,20 @@ export interface ThemeIdentity { copy?: Record; } -export interface ThemeArt { +export interface ThemeImage { filename: string; mimeType: string; base64: string; } +export interface ThemeArt extends ThemeImage {} + export interface ThemeTarget { css: string; - options?: Record; + options?: Record & { + rendererProfile?: string; + baseTheme?: Record; + }; verification?: VerificationProfile; } @@ -79,7 +141,11 @@ export interface ThemePackage { exportedAt?: string; theme: ThemeIdentity; targets: Record; - assets?: { art?: ThemeArt }; + assets?: { + images?: Record; + /** @deprecated Use images.hero for new theme packages. */ + art?: ThemeArt; + }; } export interface LegacyThemeManifest extends ThemeIdentity { @@ -111,6 +177,8 @@ export interface ResolvedThemeTarget { css: string; options: Record; verification: VerificationProfile | null; + imageDataUrls: Record; + /** Backward-compatible alias for imageDataUrls.hero. */ artDataUrl: string | null; } @@ -120,7 +188,7 @@ export interface SelectorParseError { } export interface CompatibilityIssue { - scope: "adapter" | "theme"; + scope: "adapter" | "theme" | "profile"; context: string | null; severity: "required" | "recommended"; name: string; @@ -157,6 +225,8 @@ export interface CompatibilityResult { version?: string | null; stylePresent?: boolean; horizontalOverflow?: boolean; + images?: string[]; + profile?: RendererProfileVerification | null; pass?: boolean; } @@ -167,6 +237,66 @@ export interface TargetResult { result: T; } +export interface DomSnapshotSelector { + selector: string; + count: number; + valid: boolean; + error?: string; +} + +export interface DomSnapshotNode { + index: number; + parentIndex: number | null; + depth: number; + tag: string; + id: string | null; + classes: string[]; + semanticClasses: string[]; + attributes: Record; + selectors: DomSnapshotSelector[]; + rect: { x: number; y: number; width: number; height: number }; + states: { visible: boolean; interactive: boolean; editable: boolean }; + styles: Record; +} + +export interface DomSnapshotResult { + schemaVersion: 1; + appId: string; + capturedAt: string; + activeTheme: { installed: boolean; id: string | null; version: string | null }; + route: { protocol: string; origin: string | null; pathname: string }; + viewport: { width: number; height: number; devicePixelRatio: number }; + privacy: { + textContent: false; + formValues: false; + accessibleNames: false; + linksAndMediaSources: false; + queryAndHash: false; + }; + limits: { maxNodes: number; includeHidden: boolean }; + summary: { + documentElements: number; + eligibleNodes: number; + recordedNodes: number; + truncated: boolean; + openShadowRoots: number; + }; + landmarks: Array<{ + name: string; + kind: "root" | "required" | "recommended"; + selectors: Array; + }>; + nodes: DomSnapshotNode[]; +} + +export interface DomSnapshotOptions { + adapter: AppAdapter; + port: number; + timeoutMs?: number; + maxNodes?: number; + includeHidden?: boolean; +} + export interface AppInstallation { appId: string; appPath: string; @@ -198,6 +328,57 @@ export interface ThemeRuntimeOptions { timeoutMs?: number; } +export interface HostSettingsOptions { + configPath?: string; + backupPath?: string; + home?: string; + stateRoot?: string; + env?: Record; + [key: string]: unknown; +} + +export interface ApplySkinOptions { + adapter: AppAdapter; + targetTheme: ResolvedThemeTarget; + port?: number; + launch?: boolean; + appPath?: string | null; + profilePath?: string | null; + restartExisting?: boolean; + timeoutMs?: number; + hostOptions?: HostSettingsOptions; +} + +export interface ApplySkinResult { + action: "apply"; + appId: string; + port: number; + theme: ThemeIdentity; + launch: LaunchResult | null; + host: HostSettingsResult; + targets: Array>; +} + +export interface RestoreSkinOptions { + adapter: AppAdapter; + port?: number; + timeoutMs?: number; + hostOptions?: HostSettingsOptions; +} + +export interface RestoreSkinResult { + action: "restore"; + appId: string; + port: number; + renderer: { + restored: boolean; + targets?: Array>; + code?: string | null; + message?: string; + }; + host: HostSettingsResult; +} + export interface ThemeLintWarning { code: string; appId: string; @@ -223,6 +404,9 @@ export const THEME_FORMAT: "codedrobe-theme"; export const THEME_EXTENSION: ".codedrobe-theme"; export const THEME_SCHEMA_VERSION: 1; export const MAX_THEME_PACKAGE_BYTES: number; +export const MAX_THEME_IMAGES: number; +export const DOM_SNAPSHOT_DEFAULT_MAX_NODES: number; +export const DOM_SNAPSHOT_MAX_NODES: number; export const LEGACY_THEME_FORMAT: "codex-theme"; export const LEGACY_THEME_EXTENSION: ".codex-theme"; export const LEGACY_THEME_SCHEMA_VERSION: 1; @@ -237,6 +421,7 @@ export function launchApp(options: LaunchOptions): Promise; export function findTargets(adapter: AppAdapter, port: number, timeoutMs?: number): Promise; export function waitForTargets(adapter: AppAdapter, port: number, timeoutMs?: number): Promise; export function probeApp(options: ThemeRuntimeOptions): Promise>>; +export function snapshotDom(options: DomSnapshotOptions): Promise>>; export function applyTheme(options: ThemeRuntimeOptions & { targetTheme: ResolvedThemeTarget }): Promise>>; export function verifyTheme(options: ThemeRuntimeOptions): Promise>>; export function removeTheme(options: Omit): Promise>>; @@ -244,8 +429,23 @@ export function captureScreenshot(options: Omit) => void; + signal?: AbortSignal | null; }): Promise; +export function applySkin(options: ApplySkinOptions): Promise; +export function restoreSkin(options: RestoreSkinOptions): Promise; +export function prepareHostSettings(options: { + adapter: AppAdapter; + targetTheme: ResolvedThemeTarget; + platform?: string; + options?: HostSettingsOptions; +}): Promise; +export function restoreHostSettings(options: { + adapter: AppAdapter; + platform?: string; + options?: HostSettingsOptions; +}): Promise; + export function validateThemePackage(bundle: unknown): ThemePackage; export function readThemePackage(filename: string): Promise; export function resolveThemeTarget(bundle: ThemePackage, appId: string): ResolvedThemeTarget; diff --git a/types/theme.d.ts b/types/theme.d.ts index f47774a..4fecd31 100644 --- a/types/theme.d.ts +++ b/types/theme.d.ts @@ -1,5 +1,6 @@ export { MAX_THEME_PACKAGE_BYTES, + MAX_THEME_IMAGES, THEME_EXTENSION, THEME_FORMAT, THEME_SCHEMA_VERSION, @@ -24,6 +25,7 @@ export type { LegacyThemePackage, ResolvedThemeTarget, ThemeArt, + ThemeImage, ThemeIdentity, ThemeLintWarning, ThemePackage,