# `navigator.clipboard` 在非 HTTPS 环境为 undefined，需要 execCommand 兜底

> **日期**: 2026-05-14
> **类型**: 浏览器 API / 安全上下文陷阱
> **PR**: internal-app-platform Copy URL / Copy full command hotfix

## 现象

UAT 上 `http://43.166.182.155/internal-apps` 页面：
- 点 Copy URL → 控制台抛 `TypeError: Cannot read properties of undefined (reading 'writeText')`，UI 无任何反馈
- 点 Copy full command → 同样报错，toast.error 也不出（因为是同步 throw 在 onClick 里）
- Regenerate 后命令框显示 `Bearer ••••••••（已存到剪贴板）`，但实际没复制成功，文案误导用户

后端 API 全部 200，纯前端问题。

## 根因

`navigator.clipboard` 是 [Secure Context Only API](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) —— 仅在以下环境下 `navigator.clipboard` 才存在：
- `https://*`
- `http://localhost` / `http://127.0.0.1`
- `file://`

UAT 用 plain HTTP + 公网 IP（`http://43.166.182.155`）访问，`navigator.clipboard` 直接 `undefined`，`navigator.clipboard.writeText(...)` 解引用就炸。

## 解决方案

写一个三段降级的 `copyToClipboard(text): Promise<boolean>`：

```ts
async function copyToClipboard(text: string): Promise<boolean> {
  // 1. 优先 navigator.clipboard（HTTPS / localhost）
  if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch {
      // 权限被拒等，fallthrough
    }
  }
  // 2. execCommand fallback（HTTP / 老浏览器仍可用）
  if (typeof document === 'undefined') return false;
  const ta = document.createElement('textarea');
  ta.value = text;
  ta.style.position = 'fixed';
  ta.style.top = '-9999px';
  ta.style.opacity = '0';
  ta.setAttribute('readonly', '');
  document.body.appendChild(ta);
  ta.select();
  let ok = false;
  try {
    ok = document.execCommand('copy');
  } catch {
    ok = false;
  }
  document.body.removeChild(ta);
  return ok;
}
```

`document.execCommand('copy')` 虽被标记为 deprecated，但所有主流浏览器仍支持，是当前唯一的兼容方案。

## 工程化保险（元根因层）

仅修这一个组件不够，类似 bug 会在任何依赖 `navigator.clipboard` 的页面再出现一次。三层防线：

1. **应用层**：所有"复制"调用必须经过 `copyToClipboard` 兜底函数，返回值（boolean）决定 toast.success / toast.error，**不要无条件 toast.success**——HTTP 下 clipboard 静默失败时 success toast 是误导
2. **i18n 文案**：任何 "已复制 / copied" 文案必须跟 state 绑定（真复制成功才显示），禁止硬编码 `freshlyIssued ? '已存到剪贴板' : '...'` 这种"做了就显示已完成"的形式
3. **元根因 = HTTP**：UAT 阶段公网 IP + HTTP 是过渡态。`docs/modules/internal-app-platform/11-dns-tls-rollout.md` 一旦落地（DNS + Let's Encrypt），所有 Clipboard / ServiceWorker / Geolocation / getUserMedia 等 secure-context-only API 一并启用，问题消失

## 反复出现风险

中等。每次新写"复制"按钮、或者团队任何人在新 UAT 上用 plain HTTP 调试时都可能踩。建议：
- 若再出现，考虑把 `copyToClipboard` 提到 `frontend/src/lib/clipboard.ts` 复用
- 长期靠 HTTPS 上线根治

## 相关文件

- [frontend/src/app/(modules)/internal-apps/_pages/MyAppsPage.tsx](../frontend/src/app/(modules)/internal-apps/_pages/MyAppsPage.tsx)（本次修复）
- [docs/modules/internal-app-platform/11-dns-tls-rollout.md](../docs/modules/internal-app-platform/11-dns-tls-rollout.md)（DNS + HTTPS 上线后根治）
- [docs/modules/internal-app-platform/05-ui-interaction-spec.md](../docs/modules/internal-app-platform/05-ui-interaction-spec.md)（spec 已要求 Regenerate 有 confirm 模态，但前端漏接 → 本次一起修）
