From 334b7f3a93fd77cf88b1d547fc0b2dd0519e6350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Fri, 18 Sep 2026 00:49:12 +0800 Subject: [PATCH 1/2] feat(mobile): add a native phone shell for organization preview (#329) Phone user agents get a thumb-first org/command/settings surface. Turns still run on the desktop app. --- CHANGELOG.md | 3 + apps/mobile-web/app.css | 213 +++++++++++++++++++++++++++++++ apps/mobile-web/app.mjs | 193 ++++++++++++++++++++++++++++ apps/mobile-web/index.html | 87 +++++++++++++ apps/mobile-web/surface.mjs | 100 +++++++++++++++ docs/design/mobile-shell.md | 32 +++++ scripts/mobile-web.mjs | 78 +++++++++++ scripts/test/mobile-web.test.mjs | 78 +++++++++++ 8 files changed, 784 insertions(+) create mode 100644 apps/mobile-web/app.css create mode 100644 apps/mobile-web/app.mjs create mode 100644 apps/mobile-web/index.html create mode 100644 apps/mobile-web/surface.mjs create mode 100644 docs/design/mobile-shell.md create mode 100644 scripts/mobile-web.mjs create mode 100644 scripts/test/mobile-web.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c6f609c..8717fd09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ ### Added +- #329:手机打开 RoleWeave 时进入原生手机壳(组织 / 指令 / 桌面 / 设置),而不是缩过的桌面工作台。组织页只读预览 `examples/oss-maintainer`;回合仍在已打开的电脑桌面执行。 + + - #309 后续:控制面拒绝超大请求体时,drain 或读取被中止(2 秒截止、10 MiB drain 上限、对端断开)会向 stderr 写一行原因与字节数,现场 EPIPE / 连接复位事故从此可归因;同步 docs/api-contract-v0.md:1 MiB 上限补记先读后拒行为、drain 上限与截止、server requestTimeout / headersTimeout,以及拒绝响应携带 Connection: close。附测试:stall 的超大上传在 drain 与 read 两条路径都断言中止行。 - #302:新增随仓库版本化的 GitHub 运营员工组织 `examples/github-ops/`:打开该目录即得到「问题调研 / PR 提交 / 合并把关」三个岗位。调研岗负责查重、复现并按模板建 issue;提交岗负责实现改动与提 PR;把关岗只在「存在非作者的批准、该 head 上必需检查全绿、无未解决对话、非 draft」时 squash 合并,合并前一刻重读 head SHA,禁用管理员绕过与 dismiss 他人的变更请求。三者均通过 `Bash` 调用 `gh`,不使用 MCP(内置宿主会以 `qoder.mcp_binding_unsupported` 拒绝员工 MCP 绑定)。注意:岗位包里的 `policy.network` / `policy.filesystem` / `policy.mode` 目前只被记录、不被执行,真正生效的是 `permissions.json` 的工具白名单,因此该员工在 GitHub 上的能力边界由所用凭据决定——建议用独立机器身份的细粒度 PAT,且不要授予 Administration。随附测试守护:声明与包一致(含 digest)、内置引擎能 `org apply`、控制面能打开并供出岗位卡。 diff --git a/apps/mobile-web/app.css b/apps/mobile-web/app.css new file mode 100644 index 00000000..75b931fa --- /dev/null +++ b/apps/mobile-web/app.css @@ -0,0 +1,213 @@ +:root { + color-scheme: dark; + --bg: #12141b; + --bg-elev: #1b1d26; + --ink: #f3f4f8; + --muted: #b0b5c4; + --line: #323644; + --accent: #7aa2ff; + --accent-ink: #101116; + --danger: #f0a8a8; + --radius: 16px; + --pad: 16px; + --tab: 64px; + font-family: Inter, "PingFang SC", "Noto Sans SC", system-ui, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); } +body { + min-height: 100dvh; + padding: + max(12px, env(safe-area-inset-top)) + max(var(--pad), env(safe-area-inset-right)) + calc(var(--tab) + env(safe-area-inset-bottom)) + max(var(--pad), env(safe-area-inset-left)); + overscroll-behavior: contain; + touch-action: manipulation; +} + +.skip { + position: absolute; + left: -999px; + top: 8px; +} +.skip:focus { + left: 12px; + z-index: 20; + padding: 8px 12px; + background: var(--accent); + color: var(--accent-ink); + border-radius: 8px; +} + +.top { margin-bottom: 18px; } +.brand { display: flex; gap: 12px; align-items: center; } +.mark { width: 36px; height: 36px; flex: none; } +.eyebrow { + margin: 0; + color: var(--accent); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +h1 { + margin: 2px 0 0; + font-size: 22px; + line-height: 1.25; + letter-spacing: -0.02em; +} +.lead, #command-status { color: var(--muted); line-height: 1.5; font-size: 15px; } + +.panel[hidden] { display: none; } +.panel.is-active { display: block; } + +.roles { + list-style: none; + margin: 16px 0 0; + padding: 0; + display: grid; + gap: 10px; +} +.role { + width: 100%; + text-align: left; + min-height: 72px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-elev); + color: inherit; + font: inherit; + display: grid; + gap: 4px; +} +.role:focus-visible, .button:focus-visible, .tab:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.role strong { font-size: 16px; } +.role span { color: var(--muted); font-size: 13px; } +.role:active { transform: scale(0.99); } + +.detail { + margin-top: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-elev); + display: grid; + gap: 10px; +} +.detail pre { + white-space: pre-wrap; + margin: 0; + color: var(--muted); + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.meta { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + color: var(--muted); +} + +.stack { display: grid; gap: 10px; margin: 20px 0; } +label { font-size: 14px; } +input, textarea, button, .button { + width: 100%; + font: inherit; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 12px; + background: var(--bg-elev); +} +input, textarea { padding: 14px; } +input { letter-spacing: 0.35em; font-size: 22px; text-align: center; } +button, .button { + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: var(--accent-ink); + border: 0; + font-weight: 650; + text-decoration: none; + cursor: pointer; +} +button:disabled { opacity: 0.55; cursor: not-allowed; } +.button.ghost { + background: transparent; + color: var(--ink); + border: 1px solid var(--line); +} +#command-summary { + white-space: pre-wrap; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: 12px; + padding: 14px; + line-height: 1.45; +} + +.settings { + list-style: none; + margin: 0 0 16px; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; +} +.settings li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + min-height: 48px; + border-bottom: 1px solid var(--line); +} +.settings li:last-child { border-bottom: 0; } +.settings span { color: var(--muted); } +.settings strong { font-weight: 600; text-align: right; } + +.tabs { + position: fixed; + left: 0; + right: 0; + bottom: 0; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); + background: rgb(18 20 27 / 94%); + border-top: 1px solid var(--line); + backdrop-filter: blur(12px); +} +.tab { + min-height: 48px; + border: 0; + background: transparent; + color: var(--muted); + display: grid; + justify-items: center; + gap: 2px; + font-size: 11px; + font-weight: 600; + width: auto; + border-radius: 12px; +} +.tab svg { width: 22px; height: 22px; fill: currentColor; } +.tab.is-active { color: var(--ink); background: #262936; } + +.error { color: var(--danger); } + +@media (prefers-reduced-motion: reduce) { + .role:active { transform: none; } +} + +@media (min-width: 720px) { + main, .top { max-width: 42rem; margin-left: auto; margin-right: auto; } +} diff --git a/apps/mobile-web/app.mjs b/apps/mobile-web/app.mjs new file mode 100644 index 00000000..5407f7bd --- /dev/null +++ b/apps/mobile-web/app.mjs @@ -0,0 +1,193 @@ +const workspaceName = document.getElementById("workspace-name"); +const orgLead = document.getElementById("org-lead"); +const roleList = document.getElementById("role-list"); +const roleDetail = document.getElementById("role-detail"); +const pairForm = document.getElementById("pair-form"); +const commandForm = document.getElementById("command-form"); +const commandStatus = document.getElementById("command-status"); +const commandSummary = document.getElementById("command-summary"); +const storageKey = "roleweave-phone-link-v1"; + +let socket = null; +let deviceToken = localStorage.getItem(storageKey); +let selectedRoleId = null; + +function setCommandStatus(text) { + commandStatus.textContent = text; +} + +function showSummary(text) { + if (!text) { + commandSummary.hidden = true; + commandSummary.textContent = ""; + return; + } + commandSummary.hidden = false; + commandSummary.textContent = text; +} + +function formatBudget(role) { + const tokens = role.budget?.perTask?.tokens; + return typeof tokens === "number" ? `每任务 ${tokens.toLocaleString("zh-CN")} tokens` : "预算未声明"; +} + +function renderDetail(role, names) { + const reports = role.reportTo ? `汇报给 ${names.get(role.reportTo) ?? role.reportTo}` : "组织负责人"; + roleDetail.hidden = false; + roleDetail.innerHTML = ` +

${escapeHtml(role.name)}

+

${escapeHtml(role.description)}

+
+ ${escapeHtml(reports)} + ${escapeHtml(formatBudget(role))} +
+
${escapeHtml(role.skillExcerpt || "这份岗位还没有说明书摘录。")}
+ + `; + document.getElementById("talk")?.addEventListener("click", () => selectTab("command")); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function renderRoles(snapshot) { + workspaceName.textContent = snapshot.name; + orgLead.textContent = snapshot.description || "当前是示例组织的只读预览。选一个岗位,看说明书和预算。"; + const names = new Map(snapshot.roles.map((role) => [role.id, role.name])); + roleList.replaceChildren(); + for (const role of snapshot.roles) { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "role"; + button.innerHTML = `${escapeHtml(role.name)}${escapeHtml(role.description)}`; + button.addEventListener("click", () => { + selectedRoleId = role.id; + renderDetail(role, names); + roleDetail.scrollIntoView({ block: "nearest" }); + }); + item.append(button); + roleList.append(item); + } + const initial = snapshot.roles.find((role) => role.id === selectedRoleId) ?? snapshot.roles[0]; + if (initial) renderDetail(initial, names); +} + +async function loadWorkspace() { + try { + const response = await fetch("/api/mobile/workspace", { headers: { accept: "application/json" } }); + if (!response.ok) throw new Error("workspace unavailable"); + renderRoles(await response.json()); + } catch { + orgLead.textContent = "组织预览暂时读不到。桌面工作台仍可用。"; + orgLead.classList.add("error"); + } +} + +function selectTab(id) { + for (const panel of document.querySelectorAll(".panel")) { + const active = panel.dataset.panel === id; + panel.classList.toggle("is-active", active); + panel.hidden = !active; + } + for (const tab of document.querySelectorAll(".tab")) { + const active = tab.dataset.tab === id; + tab.classList.toggle("is-active", active); + if (active) tab.setAttribute("aria-current", "page"); + else tab.removeAttribute("aria-current"); + } +} + +function connect() { + if (!deviceToken) return; + socket?.close(); + const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/phone-link/phone`; + socket = new WebSocket(url); + socket.addEventListener("open", () => { + socket.send(JSON.stringify({ v: 1, type: "phone.hello", deviceToken })); + }); + socket.addEventListener("message", (event) => { + let message; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + if (message.type === "phone.accepted") { + pairForm.hidden = true; + commandForm.hidden = false; + setCommandStatus("已连上电脑,可以发指令。"); + return; + } + if (message.type === "command.status") { + const labels = { + accepted: "电脑已接到", + running: "员工正在处理", + completed: "完成", + failed: "失败", + busy: "该员工正在忙", + needs_approval: "请在电脑上确认", + }; + setCommandStatus(labels[message.state] ?? message.state); + if (message.summary) showSummary(message.summary); + return; + } + if (message.type === "error") { + if (message.code === "unauthorized" || message.code === "revoked") { + localStorage.removeItem(storageKey); + deviceToken = null; + pairForm.hidden = false; + commandForm.hidden = true; + } + setCommandStatus(message.message ?? "出错了"); + } + }); + socket.addEventListener("close", () => { + if (deviceToken) setCommandStatus("连接断开,正在重试…"); + }); +} + +pairForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const code = document.getElementById("code").value.trim(); + setCommandStatus("正在配对…"); + const response = await fetch("/phone-link/v1/pair", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code }), + }); + const body = await response.json(); + if (!response.ok || !body.deviceToken) { + setCommandStatus(body.message ?? "配对失败"); + return; + } + deviceToken = body.deviceToken; + localStorage.setItem(storageKey, deviceToken); + connect(); +}); + +commandForm.addEventListener("submit", (event) => { + event.preventDefault(); + const text = document.getElementById("text").value.trim(); + if (!text || !socket || socket.readyState !== WebSocket.OPEN) { + setCommandStatus("还没连上电脑"); + return; + } + showSummary(""); + const commandId = crypto.randomUUID(); + socket.send(JSON.stringify({ v: 1, type: "command.submit", commandId, text })); + setCommandStatus("已发出,等电脑受理…"); +}); + +document.querySelector(".tabs").addEventListener("click", (event) => { + const tab = event.target.closest("[data-tab]"); + if (tab) selectTab(tab.dataset.tab); +}); + +if (deviceToken) connect(); +void loadWorkspace(); diff --git a/apps/mobile-web/index.html b/apps/mobile-web/index.html new file mode 100644 index 00000000..f5724569 --- /dev/null +++ b/apps/mobile-web/index.html @@ -0,0 +1,87 @@ + + + + + + + + + RoleWeave + + + + +
+
+ +
+

RoleWeave

+

工作区

+
+
+
+
+
+

正在读取组织…

+
    + +
    + + + +
    + + + + diff --git a/apps/mobile-web/surface.mjs b/apps/mobile-web/surface.mjs new file mode 100644 index 00000000..b096d00f --- /dev/null +++ b/apps/mobile-web/surface.mjs @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import path from "node:path"; + +const MOBILE_UA = /Android|iPhone|iPod|iPad|Mobile|ASteamApp|webOS|BlackBerry|IEMobile|Opera Mini/i; +const SKILL_EXCERPT_CHARS = 480; + +export function isMobileUserAgent(userAgent) { + return typeof userAgent === "string" && MOBILE_UA.test(userAgent); +} + +export function chooseSurface({ userAgent, searchParams }) { + const requested = searchParams instanceof URLSearchParams ? searchParams.get("surface") : null; + if (requested === "desktop" || requested === "mobile") return requested; + return isMobileUserAgent(userAgent) ? "mobile" : "desktop"; +} + +function safeJoin(root, relative) { + const candidate = path.resolve(root, relative); + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) return null; + return candidate; +} + +export function resolvePublicAsset(urlPath, { webDir, userAgent, searchParams }) { + const surface = chooseSurface({ userAgent, searchParams }); + if (urlPath === "/" || urlPath === "/mobile" || urlPath === "/mobile/") { + return path.join(webDir, "index.html"); + } + if (urlPath === "/app.css") return path.join(webDir, "app.css"); + if (urlPath === "/app.mjs") return path.join(webDir, "app.mjs"); + if (surface === "desktop" && urlPath === "/") return path.join(webDir, "index.html"); + return null; +} + +function findNamedDirectory(root, name) { + if (!fs.existsSync(root)) return null; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const full = path.join(current, entry.name); + if (entry.name === name) return full; + stack.push(full); + } + } + return null; +} + +function skillExcerpt(positionDir) { + const skillPath = path.join(positionDir, "SKILL.md"); + if (!fs.existsSync(skillPath)) return ""; + const text = fs.readFileSync(skillPath, "utf8").replace(/\r\n/g, "\n").trim(); + if (text.length <= SKILL_EXCERPT_CHARS) return text; + return `${text.slice(0, SKILL_EXCERPT_CHARS).trimEnd()}…`; +} + +export function loadWorkspaceSnapshot(workspaceDir) { + const workspaceFile = path.join(workspaceDir, "workspace.json"); + const orgFile = path.join(workspaceDir, "organization.v1alpha1.json"); + if (!fs.existsSync(workspaceFile) || !fs.existsSync(orgFile)) { + throw new Error("directory is not a RoleWeave workspace"); + } + const workspace = JSON.parse(fs.readFileSync(workspaceFile, "utf8")); + const org = JSON.parse(fs.readFileSync(orgFile, "utf8")); + if (!workspace || typeof workspace.name !== "string" || !org || !Array.isArray(org.roles)) { + throw new Error("directory is not a RoleWeave workspace"); + } + const positionsRoot = path.join(workspaceDir, "positions"); + const roles = org.roles.map((role) => { + const id = typeof role.id === "string" ? role.id : ""; + const positionDir = findNamedDirectory(positionsRoot, id); + return { + id, + name: typeof role.name === "string" ? role.name : id, + description: typeof role.description === "string" ? role.description : "", + reportTo: role.reportTo ?? null, + budget: role.budget && typeof role.budget === "object" ? { + perTask: role.budget.perTask ?? null, + perDay: role.budget.perDay ?? null, + } : null, + skillExcerpt: positionDir ? skillExcerpt(positionDir) : "", + }; + }); + return { + name: workspace.name, + description: typeof workspace.description === "string" ? workspace.description : "", + owner: typeof org.owner === "string" ? org.owner : null, + roles, + }; +} + +export function defaultExampleDir(productDir) { + return safeJoin(productDir, path.join("examples", "oss-maintainer")); +} diff --git a/docs/design/mobile-shell.md b/docs/design/mobile-shell.md new file mode 100644 index 00000000..12aaede8 --- /dev/null +++ b/docs/design/mobile-shell.md @@ -0,0 +1,32 @@ +# RoleWeave mobile shell + +Date: 2026-09-17 +Status: first slice + +## Problem + +Opening RoleWeave on a phone currently presents a desktop workspace. Targets are too small, typing is hostile, and the org chart plus conversation are not designed for a thumb. + +## Decision + +Phones get a native web shell. The desktop app remains where hiring, org edits, and turns actually run. + +Bottom navigation, four entries at most: + +1. Organization: read-only preview of the example workspace roles. +2. Command: send one instruction to an already-running desktop host. +3. Desktop: remind the user that the full workbench stays on the computer. +4. Settings: data boundary (preview is read-only; turns execute on the desktop). + +## Constraints + +- Touch targets at least 44px, including the tab bar, with safe-area insets. +- Keep the existing RoleWeave palette (`#12141b` / `#7aa2ff`). +- `/api/mobile/workspace` returns names, descriptions, budgets, and skill excerpts only. No host filesystem paths. +- The control plane stays on loopback. The phone never receives the boot token. + +## Non-goals + +- Hiring, rewriting the org tree, or editing budgets on the phone. +- Exposing the control-plane port on a LAN. +- Rebuilding the Electron four-zone layout in this slice. diff --git a/scripts/mobile-web.mjs b/scripts/mobile-web.mjs new file mode 100644 index 00000000..7c897aae --- /dev/null +++ b/scripts/mobile-web.mjs @@ -0,0 +1,78 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + defaultExampleDir, + loadWorkspaceSnapshot, + resolvePublicAsset, +} from "../apps/mobile-web/surface.mjs"; + +const port = Number.parseInt(process.env.PORT ?? "4173", 10); +const host = process.env.PORT ? "0.0.0.0" : "127.0.0.1"; +const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const webDir = path.join(productDir, "apps", "mobile-web"); +const exampleDir = defaultExampleDir(productDir); +const types = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], +]); + +function json(response, status, body) { + const payload = JSON.stringify(body); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "content-length": Buffer.byteLength(payload), + }); + response.end(payload); +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname === "/healthz") { + json(response, 200, { ok: true, app: "RoleWeave" }); + return; + } + if (url.pathname === "/api/mobile/workspace" && request.method === "GET") { + try { + json(response, 200, loadWorkspaceSnapshot(exampleDir)); + } catch { + json(response, 503, { code: "unavailable", message: "mobile workspace preview is not available" }); + } + return; + } + const asset = resolvePublicAsset(url.pathname, { + webDir, + userAgent: request.headers["user-agent"] ?? "", + searchParams: url.searchParams, + }); + if (!asset) { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found\n"); + return; + } + try { + const details = await stat(asset); + if (!details.isFile()) throw new Error("not a file"); + response.writeHead(200, { + "content-type": types.get(path.extname(asset)) ?? "application/octet-stream", + "content-length": details.size, + "cache-control": "no-store", + }); + createReadStream(asset).pipe(response); + } catch { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found\n"); + } +}); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + server.listen(port, host, () => { + process.stdout.write(`RoleWeave mobile web listening on ${host}:${port}\n`); + }); +} + +export { server }; diff --git a/scripts/test/mobile-web.test.mjs b/scripts/test/mobile-web.test.mjs new file mode 100644 index 00000000..807bc903 --- /dev/null +++ b/scripts/test/mobile-web.test.mjs @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import net from "node:net"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + chooseSurface, + isMobileUserAgent, + loadWorkspaceSnapshot, +} from "../../apps/mobile-web/surface.mjs"; + +const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const exampleDir = path.join(productDir, "examples", "oss-maintainer"); + +test("classifies phone and ASteam app user agents as mobile", () => { + assert.equal(isMobileUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), true); + assert.equal(isMobileUserAgent("Mozilla/5.0 ASteamApp/0.15"), true); + assert.equal(isMobileUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), false); +}); + +test("surface query overrides user agent", () => { + assert.equal(chooseSurface({ userAgent: "iPhone", searchParams: new URLSearchParams("surface=desktop") }), "desktop"); + assert.equal(chooseSurface({ userAgent: "Macintosh", searchParams: new URLSearchParams("surface=mobile") }), "mobile"); +}); + +test("workspace snapshot lists oss-maintainer roles without leaking host paths", () => { + const snapshot = loadWorkspaceSnapshot(exampleDir); + assert.equal(snapshot.name, "oss-maintainer"); + const ids = snapshot.roles.map((role) => role.id).sort(); + assert.deepEqual(ids, ["community-operator", "issue-researcher", "release-engineer", "repo-owner"]); + assert.equal(JSON.stringify(snapshot).includes("/workspace/positions"), false); +}); + +function unusedPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + server.on("error", reject); + }); +} + +test("phone user agents receive the mobile shell", async (t) => { + const port = await unusedPort(); + const child = spawn(process.execPath, [path.join(productDir, "scripts", "mobile-web.mjs")], { + cwd: productDir, + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); + t.after(() => child.kill("SIGTERM")); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("server did not start")), 8000); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (String(chunk).includes("listening")) { + clearTimeout(timeout); + resolve(); + } + }); + child.on("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`exited ${code}`)); + }); + }); + const phone = await fetch(`http://127.0.0.1:${port}/`, { + headers: { "user-agent": "Mozilla/5.0 (iPhone) Mobile" }, + }); + const html = await phone.text(); + assert.equal(phone.status, 200); + assert.match(html, /data-surface="mobile"/); + const workspace = await fetch(`http://127.0.0.1:${port}/api/mobile/workspace`); + assert.equal(workspace.status, 200); + const body = await workspace.json(); + assert.equal(body.roles.length, 4); +}); From 1893b50186656d3245d3098c13c63446ebede211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Fri, 18 Sep 2026 09:24:10 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=AD=A3=E7=A1=AE=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=A1=8C=E9=9D=A2=20surface=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mobile-web/surface.mjs | 2 +- scripts/test/mobile-web.test.mjs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/mobile-web/surface.mjs b/apps/mobile-web/surface.mjs index b096d00f..566eafdc 100644 --- a/apps/mobile-web/surface.mjs +++ b/apps/mobile-web/surface.mjs @@ -23,11 +23,11 @@ function safeJoin(root, relative) { export function resolvePublicAsset(urlPath, { webDir, userAgent, searchParams }) { const surface = chooseSurface({ userAgent, searchParams }); if (urlPath === "/" || urlPath === "/mobile" || urlPath === "/mobile/") { + if (urlPath === "/" && surface === "desktop") return null; return path.join(webDir, "index.html"); } if (urlPath === "/app.css") return path.join(webDir, "app.css"); if (urlPath === "/app.mjs") return path.join(webDir, "app.mjs"); - if (surface === "desktop" && urlPath === "/") return path.join(webDir, "index.html"); return null; } diff --git a/scripts/test/mobile-web.test.mjs b/scripts/test/mobile-web.test.mjs index 807bc903..007c6eed 100644 --- a/scripts/test/mobile-web.test.mjs +++ b/scripts/test/mobile-web.test.mjs @@ -8,6 +8,7 @@ import { chooseSurface, isMobileUserAgent, loadWorkspaceSnapshot, + resolvePublicAsset, } from "../../apps/mobile-web/surface.mjs"; const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -24,6 +25,15 @@ test("surface query overrides user agent", () => { assert.equal(chooseSurface({ userAgent: "Macintosh", searchParams: new URLSearchParams("surface=mobile") }), "mobile"); }); +test("desktop surface falls through instead of serving the mobile shell", () => { + const asset = resolvePublicAsset("/", { + webDir: path.join(productDir, "apps", "mobile-web"), + userAgent: "iPhone", + searchParams: new URLSearchParams("surface=desktop"), + }); + assert.equal(asset, null); +}); + test("workspace snapshot lists oss-maintainer roles without leaking host paths", () => { const snapshot = loadWorkspaceSnapshot(exampleDir); assert.equal(snapshot.name, "oss-maintainer");