# 平台桥接入指南（WebStoreHouse 托管项目 ↔ 平台 + 官方 SDK mkapp-1.js）

托管在画廊里的项目跑在 `/p/<id>/` 下，访客通常经 play 外壳（`/play/<id>` 短链或 `play.html?id=…`）打开：
外壳先盖一层带作品名与进度条的加载遮罩，再用同源 iframe 载入项目。`mkapp-1.js` 是项目与外壳/平台之间的
薄桥：**上报真实加载进度与就绪**（遮罩不再靠猜）、读运行上下文、拿当前登录用户、打开登录、分享短链、只读主题。

零依赖、加载永不抛错；`file://`、localhost 开发、直接打开 `/p/<id>/`（不在外壳里）时所有方法安静 no-op。

真相源：`~/.claude/skills/publish-web/app-api.md`（cutover 后）；公开副本 `/sdk/app-api.md`。
类型：`/sdk/mkapp.d.ts`。自测：`/sdk/demo.html` 的「MKApp 自测」一节。

## 30 秒接入

```html
<script src="/sdk/mkapp-1.js?v=dev"></script>   <!-- ?v= 由 publish.py 发布时注入时间戳 -->
<script>
// 资源加载循环里报进度（任意频率，SDK 节流到 ~10 次/秒）
let done = 0;
Promise.all(assets.map(a => load(a).then(() => window.MKApp && MKApp.progress(++done, assets.length))))
  .then(() => {
    startGame();
    window.MKApp && MKApp.ready();   // 首屏可交互时调一次：外壳立刻揭开遮罩（幂等，多调无害）
  });

// 分享按钮：手机弹系统分享面板，桌面复制 /play/<id> 短链
shareBtn.onclick = async () => {
  const r = await MKApp.share({ title: document.title });
  toast(r.method === 'copy' ? '链接已复制' : r.method === 'share' ? '已分享' : '分享不可用');
};
</script>
```

**为什么写 `window.MKApp && …`**：本 SDK 随平台一起部署，线上站点升级前 `/sdk/mkapp-1.js` 还不存在（404），
全局变量就不会有；带守卫的调用在旧站点、`file://` 和任何没引 SDK 的环境下都静默跳过，项目不会因此白屏。
不调 `ready()` 也没关系——外壳有「资源加载安静 700ms 即揭开、最长 20s 兜底」的启发式，`ready()` 只是让它更早更准。

## API

| 方法 | 说明 |
|---|---|
| `progress(v)` / `progress(done, total)` | 上报加载进度 0..1（夹紧）。首帧立即发，之后 100ms 内只发最后一个（尾沿必发）。`ready()` 之后无效 |
| `ready()` | 首屏就绪，外壳补满进度条并淡出遮罩。幂等 |
| `context(opts?)` → `{projectId, ns, inShell, from, name, shareUrl}` | 见下表；`opts` 可临时给 `{slug}` / `{ns}` 参与 ns 推断 |
| `configure({slug?, ns?})` | 设置本地开发 slug（ns 落 `<slug>-devlocal`，与 mksync 同规则）或显式 ns |
| `onShell(fn)` → 取消函数 | 外壳上下文到达时回调 `fn(context())`；已到达则立即补一次 |
| `me({refresh?})` → `Promise<{authed, name?, displayName?, role?}>` | `GET /api/user/me` 的缓存结果；任何错误都解析为 `{authed:false}` 且不缓存失败。`role`（1.1.0 起）：`'admin'` / `'publisher'` / `'user'`，老服务端不下发时缺省 |
| `login({sameTab?})` → `boolean` | 新标签打开账号页，登录后跳回当前地址（保留 query 与 hash）。**必须在用户点击里调**。被拦截返回 `false`；`sameTab:true` 时被拦才退回整页跳转 |
| `loginUrl()` | `/account.html?next=<当前路径>`；`file://` 下 `null`。拦截时给用户一个真实 `<a href target="_blank">` |
| `shareUrl()` | `<origin>/play/<id>`（优先外壳下发的地址）；不在 `/p/` 下为 `null` |
| `share({title?, text?, url?})` → `Promise<{method, url, cancelled?}>` | `navigator.share` 优先，不可用/被拒时复制链接（Clipboard API → textarea 兜底）。`method`：`share` / `copy` / `none` |
| `theme.get()` / `theme.onChange(fn)` | 统一主题协议 v1 只读透传；页面引了 `mktheme-1.js` 就委托给 `MKTheme`，否则只监听不写存储、不改 DOM |
| `version` / `build` | `'1.1.0'` / `'mkapp-1@1.1.0'`（1.1.0：`me()` 加宽 `role`） |

`context()` 字段：

| 字段 | 来源 |
|---|---|
| `projectId` | `location.pathname` 的 `/p/<id>/`（保留原大小写）；不在画廊里 `null` |
| `ns` | 与 mksync **完全一致**：显式 ns > `/p/<id>/` 小写 > `<slug>-devlocal` > `null` |
| `inShell` | 父窗口同源且路径是 `/play…`（同步可判断），或已收到外壳的 `mk-shell` 消息 |
| `from` | 外壳下发的返回路径：从展示集 `/s/<slug>` 点进来时是 `'/s/<slug>'`，否则 `null` |
| `name` | 外壳下发的作品名；未收到时 `null`（需要时用 `onShell` 等它） |

## 加载进度接入建议

外壳遮罩的进度条与揭开时机全看项目上报。下面是给已上线游戏逐个接入时总结的做法。

**先判断接到哪一步**

| 首屏前的加载 | 建议 |
|---|---|
| 只有 bundle/少量小文件（< ~1MB），其余都按需懒加载 | **只调 `ready()`**：省掉外壳「资源安静 700ms」的猜测，每次打开约快 0.7 秒；进度条走假进度就够了 |
| 有预加载循环（贴图/图集/音频/关卡数据）或首屏前要等 > 1MB | **`progress()` + `ready()`** |
| 项目自带加载屏/进度条 | 在自带进度更新的同一处顺手 `progress()`，否则玩家先看完外壳的条，再看一遍自带的条从 0 开始 |

**怎么报**

- **只算首屏真正在等的资源**。首屏之后才用的立绘/场景、首次点击才开始加载的音乐都不算，否则条永远走不满。
- **多阶段合成一个 0..1**：按文件数或字节给各阶段定权重，累加成总进度，不要每个阶段各自从 0 报到 1。
- **加载失败也算完成**：`onerror` 里同样计数，否则一张图 404 进度就卡住（项目本身也该有兜底画法）。
- **别提前报 1**：外壳收到 1 会立即揭幕。最后一份留给 `ready()`，常用做法是分母多留 1 份：

```js
const total = assets.length + 1;          // +1 留给"首屏画好"，由 ready() 补满
let done = 0;
const tick = () => window.MKApp && MKApp.progress(++done, total);
for (const a of assets) { const img = new Image(); img.onload = img.onerror = tick; img.src = a; }
```

- **只加回调，不拖慢加载**：不为上报加 `await`、不把并行加载改成串行、不为测大小多发请求。
- **SDK 晚到不要紧**：外壳以首个上报那一刻的显示值为起点，把项目的 0..1 映射到剩余区间，条不会倒退回 0。

**`ready()` 放哪**

- 首屏（标题/菜单）**已画出且可点**时调一次，越早越好；不要为了让进度条走完而推迟它。
- 首屏用到的大图（标题背景等）要在启动时就**并行开始加载**（不 await），否则会在揭幕后才"弹出来"。
  首屏不等它、用占位画法先顶上的话，进度里也就不必算它。
- 构建型项目放在入口模块里首屏渲染完成之后（如 `createRouter()`/`go('title')` 之后），不是模块顶层。
- **小心脚本顺序**：Vite 构建会把入口 `<script type="module">` 挪进 `<head>`，排在 body 里 `defer` 的 SDK 之前执行——
  首屏如果是同步画出来的，调 `ready()` 那一刻 `MKApp` 还不存在，守卫让它静默跳过，等于没接。看一眼 `dist/index.html` 的顺序；
  SDK 排在后面时，`MKApp` 不在就等 `DOMContentLoaded`（defer 脚本都已执行）再调（注意模块执行时 `readyState` 已是 `interactive`，不能拿它判断）：

```js
const shellReady = () => window.MKApp?.ready();
if (window.MKApp || document.readyState === 'complete') shellReady();
else document.addEventListener('DOMContentLoaded', shellReady, { once: true });
```

  `progress()` 同理：SDK 到位前报的值会丢，到位后补报一次当前值。

## 外壳协议（不用 SDK 的项目 & 排错用）

| 方向 | 消息 | 说明 |
|---|---|---|
| 项目 → 外壳 | `{type:'app-progress', value:0..1}` | **冻结**。`parent.postMessage(msg, location.origin)`，外壳只认 `ev.source === iframe` |
| 项目 → 外壳 | `{type:'app-ready'}` | **冻结** |
| 项目 → 外壳 | `{type:'mk-shell-hello'}` | SDK 加载时发一次；外壳收到即回同一条 `mk-shell`（防 SDK 晚于 iframe load 才注入时错过）。旧外壳忽略 |
| 外壳 → 项目 | `{type:'mk-shell', v:1, ctx:{id, name, entry, back, share}}` | iframe load 后下发。SDK 校验 `ev.origin === location.origin && ev.source === parent`，`back` 只认 `/` 开头的站内路径 |

手写协议时 targetOrigin 必须写 `location.origin` 而不是 `'*'`；`file://` 下 origin 是字符串 `"null"`，
拿它当 targetOrigin 会抛 SyntaxError——SDK 在非 http(s) 下直接不发。

## 注意事项

- **登录/分享都要用户手势**：项目跑在 iframe 里，非手势的 `window.open` 必被拦，`navigator.share` 直接 reject。
  `login()` 返回 `false` 时把 `loginUrl()` 渲染成真实链接让用户点。**绝不同框跳转**（账号页会被关在 iframe 里）。
- **外壳 iframe 里 `navigator.share` 可能被权限策略拒绝**（iframe 的 `allow` 未授 `web-share`），SDK 会自动退回复制；
  复制依赖 `clipboard-write`（外壳已授权）。返回 `none` 时自己弹个框把 `r.url` 显示出来。
- `me()` 只给 `authed/name/displayName/role`；要云端存档请用 `mksync-1.js`，要存大量条目/图片音视频用 `mkvault-1.js`。
- `role` 只用来**调界面**（如给管理员显示调试面板），不是安全边界：页面 JS 可以随意改，真正的权限只在服务端判。
- 作品设为**登录可见**（publish.py `--members` / 画廊「登录可见」）时，访客连页面都打不开（平台回「需要登录」页），
  进到项目里 `me()` 必为 `authed:true`；但仍应按未登录兜底写（作品可能随时被改回公开）。
- 外壳显示的进度只进不退：项目报出比之前小的值会被忽略（不会倒回去），所以多阶段请自己合成总进度；也别在 `ready()` 前长时间停在 100%。
- `theme` 是只读的：要切换按钮请引 `mktheme-1.js`（放 `<head>` 第一个 stylesheet 之前），本 SDK 初始化绝不写 `localStorage`。

## 本地开发

- `file://` 直接打开：全部 no-op，`me()` → `{authed:false}`，`shareUrl()/loginUrl()` → `null`，`login()` → `false`。
- `localhost` 开发服务器：不在 `/p/` 下，`projectId/shareUrl` 为 `null`；`ns` 需 `MKApp.configure({ slug: 'myapp' })` 才有（`myapp-devlocal`）。
  `me()` 请求本机 `/api/user/me` 失败 → `{authed:false}`。
- 要测外壳交互：起本机 WebStoreHouse，`ln -s <构建目录> data/projects/<name>dev`，打开
  `http://localhost:4321/play.html?id=<name>dev&e=index.html&n=测试`，看遮罩进度与揭开时机。
- 线上站点部署本 SDK 之前就发布的项目：引用会 404，务必用 `window.MKApp && …` 守卫（见上）。
