# 示例：Electron / 桌面 GUI 应用

Electron 应用有窗口。未来在无头容器中的代理看不到窗口。所以你在这里的交付物不是一个说"`npm start` 打开一个窗口"的 markdown 文件——它是一个**驱动程序脚本**，在 xvfb 下启动应用，暴露一个命令 REPL（click、type、screenshot），让代理可以通过发送文本行来操控 UI。

技能的 `SKILL.md` 然后成为该驱动程序的简短手册。

## 你要构建的

```
apps/desktop/
  .claude/skills/run-desktop/
    SKILL.md               ← 简短。"运行驱动程序，以下是命令"
    driver.mjs             ← REPL：stdin 命令 → Playwright 操作
```

驱动程序就是产品。没有它，技能描述的只是一个代理永远无法触摸的 GUI。

**升级路径：** 如果驱动程序成长到项目的真实 e2e 套件想要共享的启动辅助功能，将其移到 `e2e-playwright/driver.mjs`（或 `scripts/drive.mjs`）并更新技能的路径。技能保留在 `.claude/skills/run-desktop/`；驱动程序找到一个更好的归宿。

## 步骤 1 — 让应用在 xvfb 下启动起来

这通常是最难的部分，产生最多的陷阱。README 会说"仅限 macOS/Windows"。忽略它。安装 xvfb + Chromium 共享库，找到 Electron 二进制文件，并启动它：

```bash
apt-get install -y xvfb libnss3 libgbm1 libasound2t64 libgtk-3-0 \
  libxss1 libxkbcommon0 libatk-bridge2.0-0 libcups2 libdrm2

# 首先构建应用。通常"dev"脚本是 electron-forge，它
# 先进行 Vite/webpack 构建，然后启动。你只需要构建：
npm install
npx electron-forge start &   # 构建到 .vite/build/ 或 dist/
sleep 20 && kill %1          # 构建完成后杀死它——你将自己启动

# 现在尝试原始启动
xvfb-run -a node -e "
  const { _electron } = require('playwright-core');
  _electron.launch({
    executablePath: './node_modules/electron/dist/electron',
    args: ['--no-sandbox', '.'],
    timeout: 30000,
  }).then(app => {
    console.log('已启动，窗口：', app.windows().map(w => w.url()));
    return app.close();
  });
"
```

迭代直到它启动。每个缺失的 `.so` → 再一个 `apt-get` 包 → 再一行 Prerequisites。每个启动超时 → 检查 `nodeCliInspect` 保险丝未被禁用，检查构建输出存在。

**`--no-sandbox` 在容器中几乎总是需要的。** Electron 的沙盒需要 CAP_SYS_ADMIN 或用户命名空间。默认情况下两者都没有。

## 步骤 2 — 构建 REPL 驱动程序

一旦你能够启动它，将那个一次性的脚本转换为 REPL。从最小化开始——你将根据需要添加命令。**REPL 是正确的形态**，因为代理可以在 tmux 内运行它，并在每次交互时无需重新启动（慢速的）应用即可迭代。

```javascript
// .claude/skills/run-<unit>/driver.mjs
// <app> 的 REPL 驱动程序。在无头 Linux 上通过 xvfb 运行。
// 为代理设计：包装在 tmux 中，通过 send-keys 发送命令，通过 capture-pane 捕获输出。
import { _electron as electron } from 'playwright-core';
import * as readline from 'node:readline';
import * as fs from 'node:fs';
import * as path from 'node:path';

const APP_DIR = path.resolve(import.meta.dirname, '../../..');
const SHOT_DIR = process.env.SCREENSHOT_DIR || '/tmp/shots';
fs.mkdirSync(SHOT_DIR, { recursive: true });

let app = null;
let page = null;   // 你实际交互的窗口/页面

const electronBin = process.platform === 'darwin'
  ? path.join(APP_DIR, 'node_modules/electron/dist/Electron.app/Contents/MacOS/Electron')
  : path.join(APP_DIR, 'node_modules/electron/dist/electron');

const COMMANDS = {
  async launch() {
    if (app) return console.log('已经启动');
    app = await electron.launch({
      executablePath: electronBin,
      args: ['--no-sandbox', APP_DIR],
      env: { ...process.env, DISPLAY: process.env.DISPLAY || ':99' },
      timeout: 30_000,
    });
    // Electron 没有干净的"已加载"信号——这个 sleep 是一个盲猜。
    // 一旦你知道此应用的就绪标志是什么，替换为轮询：
    // 等待 windows() 包含预期的 URL，或在 firstWindow() 上使用 waitForSelector。
    await new Promise(r => setTimeout(r, 8_000));
    // 找到真正的 UI 页面。通常不是 firstWindow()——可能是
    // 闪屏，或者真实内容在 BrowserView 覆盖层中。
    page = app.windows().find(w => !w.url().startsWith('devtools://'))
        ?? await app.firstWindow();
    console.log('已启动。', app.windows().length, '个窗口：');
    for (const w of app.windows()) console.log(' ', w.url());
  },

  async ss(name) {
    if (!page) return console.log('错误：先启动');
    const f = path.join(SHOT_DIR, (name || `ss-${Date.now()}`) + '.png');
    await page.screenshot({ path: f });
    console.log('截图：', f);
  },

  // 通过 evaluate() 点击，而不是 locator.click()。如果内容驻留在
  // 覆盖主窗口的 BrowserView 中，Playwright 的坐标计算会命中错误的层。
  // DOM .click() 总是有效的。
  async click(sel) {
    if (!page) return console.log('错误：先启动');
    const r = await page.evaluate(s => {
      const el = document.querySelector(s);
      if (!el) return 'NOT_FOUND';
      el.click(); return 'OK';
    }, sel);
    console.log('点击', sel, '→', r);
  },

  async 'click-text'(text) {
    if (!page) return console.log('错误：先启动');
    const r = await page.evaluate(t => {
      const els = [...document.querySelectorAll('button, a, [role="button"]')];
      const el = els.find(e => e.textContent?.trim() === t)
              ?? els.find(e => e.textContent?.includes(t));
      if (!el) return 'NOT_FOUND';
      el.click(); return 'OK: ' + el.tagName;
    }, text);
    console.log('click-text', JSON.stringify(text), '→', r);
  },

  async type(text)  { if (page) await page.keyboard.type(text, { delay: 30 }); },
  async press(key)  { if (page) await page.keyboard.press(key); },

  async wait(sel) {
    if (!page) return console.log('错误：先启动');
    try { await page.waitForSelector(sel, { timeout: 10_000 }); console.log('找到：', sel); }
    catch { console.log('超时：', sel); }
  },

  async eval(expr) {
    if (!page) return console.log('错误：先启动');
    try { console.log(JSON.stringify(await page.evaluate(expr))); }
    catch (e) { console.log('错误：', e.message); }
  },

  async text(sel) {
    if (!page) return console.log('错误：先启动');
    console.log(await page.evaluate(
      s => (s ? document.querySelector(s) : document.body)?.innerText ?? '(null)',
      sel || null));
  },

  // 内省：对于确定哪个窗口/webContents 实际拥有 UI 至关重要。
  // Electron 应用通常生成多个。
  async windows() {
    if (!app) return console.log('错误：先启动');
    for (const w of app.windows()) console.log(' ', w.url());
    const wcs = await app.evaluate(({ webContents }) =>
      webContents.getAllWebContents().map(w => ({ id: w.id, type: w.getType(), url: w.getURL() })));
    console.log('webContents:');
    for (const w of wcs) console.log(` [${w.id}] ${w.type}: ${w.url}`);
  },

  async quit() { if (app) await app.close().catch(()=>{}); app = null; page = null; },
  help() { console.log('命令：', Object.keys(COMMANDS).join(', ')); },
};

// 阻止 Electron 夺取 stdin——使用原始 fd。
const stdin = fs.createReadStream(null, { fd: fs.openSync('/dev/stdin', 'r') });
const rl = readline.createInterface({ input: stdin, output: process.stdout, prompt: 'driver> ' });

rl.on('line', async line => {
  const [cmd, ...rest] = line.trim().split(/\s+/);
  if (!cmd) return rl.prompt();
  const fn = COMMANDS[cmd];
  if (!fn) { console.log('未知：', cmd, '— 尝试：help'); return rl.prompt(); }
  try { await fn(rest.join(' ')); } catch (e) { console.log('错误：', e.message); }
  if (cmd === 'quit') { rl.close(); process.exit(0); }
  rl.prompt();
});
rl.on('close', async () => { await COMMANDS.quit(); process.exit(0); });

console.log('<app> 驱动程序 — "help" 查看命令，"launch" 开始');
rl.prompt();
```

**这是一个起始骨干。** 当你试图触达应用的有趣部分时，你将添加特定于应用的命令：导航到特定视图、聚焦奇怪的输入类型、绕过认证门控，无论是什么。这些命令编码了来之不易的知识——保留它们。

## 步骤 3 — 自己通过 tmux 使用它

以未来代理将使用的相同方式运行驱动程序：

```bash
tmux new-session -d -s app -x 200 -y 50
tmux send-keys -t app 'cd /workspace/apps/desktop && xvfb-run -a node .claude/skills/run-desktop/driver.mjs' Enter
timeout 20 bash -c 'until tmux capture-pane -t app -p | grep -q "driver>"; do sleep 0.2; done'
tmux send-keys -t app 'launch' Enter
timeout 60 bash -c 'until tmux capture-pane -t app -p | grep -q "已启动"; do sleep 0.2; done'
tmux send-keys -t app 'ss 01-landing' Enter
timeout 10 bash -c 'until tmux capture-pane -t app -p | grep -q "截图:"; do sleep 0.2; done'
tmux send-keys -t app 'windows' Enter    # 哪个页面有真正的 UI？
tmux capture-pane -t app -p
```

然后实际打开 `/tmp/shots/01-landing.png`。这是应用吗？是空白的吗？是登录屏幕吗？每一种情况都告诉你下一步做什么。

继续——点击进入主要功能，填写表单，看到结果显示出来，截图。驱动程序会增长你需要的任何命令（`focus-input`、`goto-settings`、`login-as-test-user`…）。当一个真实流程端到端工作时，你就完成了构建并准备好编写文档。

## 步骤 4 — 编写 SKILL.md

保持简短。驱动程序是主要部分；`SKILL.md` 是手册。有效的结构：

> ---
> name: run-desktop
> description: 构建、运行和驱动 <app> Electron 桌面应用。当要求启动桌面应用、为其截图、构建它或与其 UI 交互时使用。
> ---
>
> <App> 是一个 Electron 桌面应用。对于代理/自动化使用，通过 `.claude/skills/run-desktop/driver.mjs` 在 xvfb 下的 Playwright REPL 驱动它。启动较慢（约 10s），有趣的 UI 驻留在 BrowserView 中，而不是主窗口中——驱动程序处理这两者。
>
> 所有路径相对于 `apps/desktop/`。
>
> ## 前置条件
>
> ```bash
> apt-get install -y xvfb libnss3 libgbm1 libasound2t64 libgtk-3-0 \
>   libxss1 libxkbcommon0 libatk-bridge2.0-0 libcups2 libdrm2
> ```
>
> ## 构建
>
> ```bash
> npm install
> npx electron-forge start   # 构建到 .vite/build/ — 构建完成后 Ctrl-C
> # <any patch you had to apply: sed a feature gate, etc.>
> ```
>
> ## 运行（代理路径）
>
> ```bash
> cd apps/desktop
> xvfb-run -a node .claude/skills/run-desktop/driver.mjs
> ```
>
> 包装在 tmux 中以供交互使用：
>
> ```bash
> tmux new-session -d -s app -x 200 -y 50
> tmux send-keys -t app 'cd apps/desktop && xvfb-run -a node .claude/skills/run-desktop/driver.mjs' Enter
> timeout 20 bash -c 'until tmux capture-pane -t app -p | grep -q "driver>"; do sleep 0.2; done'
> tmux send-keys -t app 'launch' Enter
> timeout 60 bash -c 'until tmux capture-pane -t app -p | grep -q "已启动"; do sleep 0.2; done'
> tmux send-keys -t app 'ss landing' Enter
> tmux capture-pane -t app -p
> ```
>
> 截图落在 `/tmp/shots/`（覆盖：`SCREENSHOT_DIR`）。
>
> ### 命令
>
> | 命令 | 做什么 |
> |---|---|
> | `launch` | 启动应用，等待窗口 |
> | `ss [name]` | 截图 → `/tmp/shots/<name>.png` |
> | `click <css-sel>` | 点击元素（通过 DOM，不是坐标——见陷阱） |
> | `click-text <text>` | 点击包含文本的按钮/链接 |
> | `type <text>` / `press <key>` | 键盘输入 |
> | `wait <css-sel>` | 等待元素，10s 超时 |
> | `eval <js>` | 在页面中评估，打印 JSON |
> | `text [css-sel]` | 打印 innerText |
> | `windows` | 列出所有窗口 + webContents（查找真正的 UI） |
> | `quit` | 关闭应用，退出 |
>
> 加上你构建的任何特定于应用的命令：`<your-command>` — <what it does>。
>
> ## 运行（人类路径）
>
> ```bash
> npm start   # 打开一个窗口；在无头环境中无用。Ctrl-C 退出。
> ```
>
> ## 陷阱
>
> - **<the specific weird thing you hit>** — <why> → <fix/workaround>
> - <etc. — only things you actually hit, not generic advice>
>
> ## 故障排除
>
> - **启动超时（30s）：** 构建输出缺失？→ 重新运行构建步骤。`nodeCliInspect` 保险丝已禁用？→ Playwright 无法附加；在开发构建中不要禁用该保险丝。
> - **"缺失 X server"：** 忘记了 `xvfb-run`。无头 Linux 需要它。
> - **过期的 Xvfb 锁：** `rm -f /tmp/.X*-lock; pkill Xvfb`
> - <anything else you actually hit>

## 你会遇到的障碍（它们应该进入陷阱章节）

这些是来自真实 Electron 应用的真实模式。你会遇到其中一部分：

- **`firstWindow()` 给你一个闪屏/加载屏幕，** 而不是应用。等待更长时间，或通过 URL 找到正确的页面，或等待只在应用实际就绪时出现的特定选择器。

- **真正的 UI 在 BrowserView 中，而不是 BrowserWindow 中。** Playwright 将其视为具有不同 URL 的单独"窗口"。`windows` 命令正是为此目的而存在的。`getBrowserViews()` 在较新的 Electron 上也可能返回空——改用 `webContents.getAllWebContents()`。

- **`locator.click()` 点击了错误的东西。** Playwright 相对于主窗口计算点击坐标。如果你的内容在 BrowserView 覆盖层中，这些坐标会命中其后面的窗口。驱动程序骨干为此原因使用 `page.evaluate(el => el.click())`——DOM 点击完全绕过了坐标。

- **功能门控阻止了你需要测试的东西。** 应用检查计划层级、环境标志或嵌入 SSR HTML 中的功能标志。找到检查发生的位置（grep 构建输出中的门控名称）并为你的本地运行打补丁——构建输出上的 `sed`、环境变量覆盖，或（对于 SSR 嵌入的标志）通过 CDP `Fetch.enable` 拦截响应并在传输中重写它。记录你确切地修补了什么以及为什么。

- **contentEditable 输入**（ProseMirror、Tiptap、Slate）不是 `<textarea>`。`fill()` 不起作用。聚焦元素，然后使用 `keyboard.type()`。如果应用有这些，添加 `focus <sel>` 命令。

- **Electron 抢夺 stdin。** 骨干中的 `fs.openSync('/dev/stdin', 'r')` + `createReadStream` 技巧保护了你的 REPL 输入。

- **原生模块加载失败**（钥匙串、通知等）。通常是非致命性的——核心应用运行，这些功能空操作。记录它并继续。
