From e9fd361804553b32f3df1c35403fca076f0fa5fd Mon Sep 17 00:00:00 2001 From: waterbro-8 Date: Fri, 18 Sep 2026 09:40:41 +0000 Subject: [PATCH 1/3] feat(mobile): phone shell, phone-link commands, live org and revoke Hosted RoleWeave: native phone shell instead of scaled VNC, phone-link.v1 to send commands to a running desktop, live org snapshot, and device revoke. --- CHANGELOG.md | 5 + apps/desktop/src/phone-link-host.cjs | 249 ++++++++++++++++++ apps/desktop/test/phone-link-host.test.cjs | 89 +++++++ deploy/command.css | 34 +++ deploy/command.html | 38 +++ deploy/command.mjs | 144 +++++++++++ deploy/mobile-surface.mjs | 159 ++++++++++++ deploy/mobile/android/app.css | 72 ++++++ deploy/mobile/android/app.mjs | 1 + deploy/mobile/android/index.html | 63 +++++ deploy/mobile/app.css | 235 +++++++++++++++++ deploy/mobile/app.mjs | 284 +++++++++++++++++++++ deploy/mobile/harmony/app.css | 63 +++++ deploy/mobile/harmony/app.mjs | 1 + deploy/mobile/harmony/index.html | 63 +++++ deploy/mobile/index.html | 101 ++++++++ deploy/mobile/ios/app.css | 73 ++++++ deploy/mobile/ios/app.mjs | 1 + deploy/mobile/ios/index.html | 64 +++++ deploy/mobile/shared/shell.mjs | 284 +++++++++++++++++++++ deploy/web-server.mjs | 249 ++++++++++++++++++ docs/design/mobile-shell.md | 43 ++++ docs/issues/001-mobile-shell.md | 22 ++ package.json | 5 +- packages/phone-link/README.md | 48 ++++ packages/phone-link/package.json | 14 + packages/phone-link/src/index.mjs | 6 + packages/phone-link/src/relay.mjs | 275 ++++++++++++++++++++ packages/phone-link/test/relay.test.mjs | 133 ++++++++++ scripts/test/mobile-surface.test.mjs | 136 ++++++++++ scripts/test/mobile-web-server.test.mjs | 104 ++++++++ start.sh | 109 ++++++++ 32 files changed, 3165 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/phone-link-host.cjs create mode 100644 apps/desktop/test/phone-link-host.test.cjs create mode 100644 deploy/command.css create mode 100644 deploy/command.html create mode 100644 deploy/command.mjs create mode 100644 deploy/mobile-surface.mjs create mode 100644 deploy/mobile/android/app.css create mode 100644 deploy/mobile/android/app.mjs create mode 100644 deploy/mobile/android/index.html create mode 100644 deploy/mobile/app.css create mode 100644 deploy/mobile/app.mjs create mode 100644 deploy/mobile/harmony/app.css create mode 100644 deploy/mobile/harmony/app.mjs create mode 100644 deploy/mobile/harmony/index.html create mode 100644 deploy/mobile/index.html create mode 100644 deploy/mobile/ios/app.css create mode 100644 deploy/mobile/ios/app.mjs create mode 100644 deploy/mobile/ios/index.html create mode 100644 deploy/mobile/shared/shell.mjs create mode 100644 deploy/web-server.mjs create mode 100644 docs/design/mobile-shell.md create mode 100644 docs/issues/001-mobile-shell.md create mode 100644 packages/phone-link/README.md create mode 100644 packages/phone-link/package.json create mode 100644 packages/phone-link/src/index.mjs create mode 100644 packages/phone-link/src/relay.mjs create mode 100644 packages/phone-link/test/relay.test.mjs create mode 100644 scripts/test/mobile-surface.test.mjs create mode 100644 scripts/test/mobile-web-server.test.mjs create mode 100755 start.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6dcc8b..829ae0e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ ## [Unreleased] +### Added + +- 手机壳与 phone-link.v1:手机打开产品为原生壳(组织/指令/桌面/设置);一键连接电脑发指令;组织跟随电脑当前工作区;设置可断开配对。托管入口见 deploy/ 与 start.sh。 + + ### Added - #328 R2:新增 `semantic-runtime.v1alpha1` 合同切片(BusinessObjectRef / EvidenceRef / DecisionRecord / ActionProposal / ExecutionReceipt)与 Ontology Runtime 术语表;github-ops 示例给出只读分析与可写 squash-merge 轨迹;纯函数测试覆盖「未批准不可执行、非法过期时间 fail-closed、幂等重试绑定目标版本、目标版本失效、运行前状态不可直接失败、indeterminate 不能变成 succeeded」。不是 live GitHub 执行,也不是 Sales Workbench 核心词汇。设计说明见 `docs/design/ontology-runtime-r2.md`。 diff --git a/apps/desktop/src/phone-link-host.cjs b/apps/desktop/src/phone-link-host.cjs new file mode 100644 index 00000000..3f7b65b8 --- /dev/null +++ b/apps/desktop/src/phone-link-host.cjs @@ -0,0 +1,249 @@ +const { randomUUID } = require("node:crypto"); +const path = require("node:path"); +const { pathToFileURL } = require("node:url"); + +const MAX_SUMMARY = 2000; + +function loadWebSocket() { + if (typeof WebSocket === "function") return WebSocket; + const wsPath = path.join(__dirname, "..", "..", "..", "deploy", "node_modules", "ws"); + return require(wsPath).WebSocket; +} + +function bind(socket, event, handler) { + const wrapped = (...args) => { + if (event === "message") { + const raw = args[0] && args[0].data !== undefined ? args[0].data : args[0]; + handler({ data: raw }); + return; + } + handler(args[0]); + }; + if (typeof socket.addEventListener === "function") socket.addEventListener(event, wrapped); + else socket.on(event, wrapped); +} + +function truncate(text) { + if (typeof text !== "string" || text.length === 0) return ""; + return text.length <= MAX_SUMMARY ? text : `${text.slice(0, MAX_SUMMARY - 1)}…`; +} + +function summarizeTurn(record) { + if (!record || typeof record !== "object") return "已完成"; + if (typeof record.output === "string") return truncate(record.output); + if (record.output && typeof record.output === "object") { + for (const key of ["text", "answer", "message", "content"]) { + if (typeof record.output[key] === "string") return truncate(record.output[key]); + } + } + const deltas = Array.isArray(record.events) + ? record.events + .filter((event) => event && event.type === "model.delta") + .map((event) => event.text || event.delta || "") + .join("") + : ""; + if (deltas) return truncate(deltas); + if (record.error && typeof record.error.message === "string") return truncate(record.error.message); + return "已完成"; +} + +function firstReadyEngine(health) { + const hosts = health && typeof health === "object" ? health.hosts : null; + if (!hosts || typeof hosts !== "object") return null; + const preferred = ["qoder", "claude-local", "claude-code", "codex-local", "codex", "workbuddy"]; + for (const id of preferred) { + if (hosts[id] && hosts[id].ready === true) return id; + } + for (const [id, host] of Object.entries(hosts)) { + if (host && host.ready === true) return id; + } + return null; +} + +function firstPositionId(tree, preferred) { + if (typeof preferred === "string" && preferred.trim()) return preferred.trim(); + const walk = (nodes) => { + if (!Array.isArray(nodes)) return null; + for (const node of nodes) { + if (node && typeof node.id === "string") return node.id; + const nested = walk(node && node.children); + if (nested) return nested; + } + return null; + }; + return walk(tree && tree.tree); +} + +async function defaultLoadSnapshot(dir) { + const url = pathToFileURL(path.join(__dirname, "..", "..", "..", "deploy", "mobile-surface.mjs")).href; + const mod = await import(url); + return mod.liveOrgSnapshot(mod.loadWorkspaceSnapshot(dir), dir); +} + +async function collectLiveOrgSnapshot(apiRequest, loadSnapshot = defaultLoadSnapshot) { + const workspace = await apiRequest("/workspace"); + if (workspace.status !== 200 || !workspace.body || workspace.body.open !== true) { + return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] }; + } + const dir = workspace.body.path; + if (typeof dir !== "string" || dir.length === 0) { + return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] }; + } + try { + const snapshot = await loadSnapshot(dir); + const live = snapshot && snapshot.source === "live" + ? snapshot + : { ...snapshot, open: true, source: "live" }; + if (JSON.stringify(live).includes(dir)) { + return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] }; + } + return live; + } catch { + return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] }; + } +} + +async function dispatchPhoneCommand(apiRequest, text, preferredPositionId) { + const workspace = await apiRequest("/workspace"); + if (workspace.status !== 200 || !workspace.body || workspace.body.open !== true) { + return { state: "failed", summary: "电脑上还没有打开工作区" }; + } + const org = await apiRequest("/org/tree"); + const positionId = firstPositionId(org.body, preferredPositionId); + if (!positionId) return { state: "failed", summary: "组织里没有可派发的员工" }; + const health = await apiRequest("/health", { withAuth: false }); + const engine = firstReadyEngine(health.body); + if (!engine) return { state: "failed", summary: "本机 Agent 还没就绪" }; + const sessions = await apiRequest(`/sessions?positionId=${encodeURIComponent(positionId)}`); + let sessionId = sessions.body && sessions.body.activeSessionId; + if (!sessionId) { + const created = await apiRequest("/sessions", { method: "POST", body: { positionId } }); + if (created.status !== 201 && created.status !== 200) { + return { state: "failed", summary: created.body && created.body.message ? created.body.message : "无法创建会话" }; + } + sessionId = created.body.sessionId; + } + const turn = await apiRequest(`/sessions/${sessionId}/turns`, { + method: "POST", + body: { input: text, engine }, + }); + if (turn.status === 409) return { state: "busy", summary: "该员工正在处理别的任务" }; + if (turn.status === 400 && turn.body && /approval/i.test(String(turn.body.message ?? ""))) { + return { state: "needs_approval", summary: "请在电脑上确认这次操作" }; + } + if (turn.status !== 200 && turn.status !== 201 && turn.status !== 202) { + return { state: "failed", summary: turn.body && turn.body.message ? String(turn.body.message) : "回合没有启动" }; + } + if (turn.body && turn.body.status === "failed") { + return { state: "failed", summary: summarizeTurn(turn.body) }; + } + return { state: "completed", summary: summarizeTurn(turn.body) }; +} + +function startPhoneLinkHost({ + port, + hostToken, + apiRequest, + preferredPositionId, + onPairCode, + WebSocketImpl = loadWebSocket(), +} = {}) { + if (!port || typeof hostToken !== "string" || !/^[a-f0-9]{64}$/.test(hostToken)) { + return { stop() {} }; + } + let socket = null; + let stopped = false; + let retryTimer = null; + let orgTimer = null; + + const publishOrg = async () => { + if (stopped || !socket) return; + try { + const snapshot = await collectLiveOrgSnapshot(apiRequest); + if (stopped || !socket) return; + socket.send(JSON.stringify({ v: 1, type: "org.snapshot", snapshot })); + } catch { + // A missed org refresh must not drop the command channel. + } + }; + + const connect = () => { + if (stopped) return; + socket = new WebSocketImpl(`ws://127.0.0.1:${port}/phone-link/host`); + bind(socket, "open", () => { + socket.send(JSON.stringify({ v: 1, type: "host.hello", token: hostToken })); + }); + bind(socket, "message", async (event) => { + let message; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + if (message.type === "host.accepted") { + socket.send(JSON.stringify({ v: 1, type: "pair.start" })); + void publishOrg(); + if (orgTimer) clearInterval(orgTimer); + orgTimer = setInterval(() => { void publishOrg(); }, 5000); + return; + } + if (message.type === "pair.ready" && typeof onPairCode === "function") { + onPairCode(message.code, message.expiresAt); + return; + } + if (message.type !== "command.submit") return; + socket.send(JSON.stringify({ + v: 1, type: "command.status", commandId: message.commandId, + deviceId: message.deviceId, state: "running", + })); + let result; + try { + result = await dispatchPhoneCommand( + apiRequest, + message.text, + message.positionId || preferredPositionId, + ); + } catch (error) { + result = { state: "failed", summary: error instanceof Error ? error.message : "dispatch failed" }; + } + if (stopped || !socket) return; + socket.send(JSON.stringify({ + v: 1, type: "command.status", commandId: message.commandId, + deviceId: message.deviceId, state: result.state, summary: result.summary, + })); + }); + bind(socket, "close", () => { + if (orgTimer) { + clearInterval(orgTimer); + orgTimer = null; + } + if (stopped) return; + retryTimer = setTimeout(connect, 2000); + }); + bind(socket, "error", () => {}); + }; + + connect(); + return { + stop() { + stopped = true; + if (retryTimer) clearTimeout(retryTimer); + if (orgTimer) clearInterval(orgTimer); + if (socket) socket.close(); + }, + }; +} + +function newCommandId() { + return randomUUID(); +} + +module.exports = { + dispatchPhoneCommand, + collectLiveOrgSnapshot, + summarizeTurn, + firstReadyEngine, + firstPositionId, + startPhoneLinkHost, + newCommandId, +}; diff --git a/apps/desktop/test/phone-link-host.test.cjs b/apps/desktop/test/phone-link-host.test.cjs new file mode 100644 index 00000000..29988b8b --- /dev/null +++ b/apps/desktop/test/phone-link-host.test.cjs @@ -0,0 +1,89 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { + collectLiveOrgSnapshot, + dispatchPhoneCommand, + firstPositionId, + firstReadyEngine, + summarizeTurn, +} = require("../src/phone-link-host.cjs"); + +test("picks the first org-tree position unless one is bound", () => { + const tree = { tree: [{ id: "repo-owner", children: [{ id: "issue-researcher" }] }] }; + assert.equal(firstPositionId(tree, "issue-researcher"), "issue-researcher"); + assert.equal(firstPositionId(tree), "repo-owner"); +}); + +test("picks a ready engine without inventing host ids", () => { + assert.equal(firstReadyEngine({ hosts: { qoder: { ready: false }, "claude-local": { ready: true } } }), "claude-local"); + assert.equal(firstReadyEngine({ hosts: { qoder: { ready: false } } }), null); +}); + +test("summarizes a completed turn from visible text", () => { + assert.equal(summarizeTurn({ output: { text: "合了 #198" } }), "合了 #198"); +}); + +test("dispatch creates a session and posts a turn", async () => { + const calls = []; + const apiRequest = async (pathname, options = {}) => { + calls.push({ pathname, method: options.method ?? "GET", body: options.body ?? null }); + if (pathname === "/workspace") return { status: 200, body: { open: true } }; + if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner" }] } }; + if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } }; + if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: null, sessions: [] } }; + if (pathname === "/sessions") return { status: 201, body: { sessionId: "sess-1" } }; + if (pathname === "/sessions/sess-1/turns") { + return { status: 200, body: { status: "completed", output: { text: "好的,我去处理" } } }; + } + throw new Error(pathname); + }; + const result = await dispatchPhoneCommand(apiRequest, "看一下 PR"); + assert.equal(result.state, "completed"); + assert.equal(result.summary, "好的,我去处理"); + assert.equal(calls.some((call) => call.pathname === "/sessions/sess-1/turns" && call.body.input === "看一下 PR"), true); +}); + +test("dispatch uses the selected position id", async () => { + const paths = []; + const apiRequest = async (pathname, options = {}) => { + paths.push(pathname); + if (pathname === "/workspace") return { status: 200, body: { open: true } }; + if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner", children: [{ id: "issue-researcher" }] }] } }; + if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } }; + if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: "sess-r" } }; + if (pathname === "/sessions/sess-r/turns") { + assert.equal(options.body.input, "去调研"); + return { status: 200, body: { status: "completed", output: "ok" } }; + } + throw new Error(pathname); + }; + const result = await dispatchPhoneCommand(apiRequest, "去调研", "issue-researcher"); + assert.equal(result.state, "completed"); + assert.equal(paths.includes("/sessions?positionId=issue-researcher"), true); +}); + +test("live org snapshot omits the workspace path", async () => { + const closed = await collectLiveOrgSnapshot(async () => ({ status: 200, body: { open: false } })); + assert.equal(closed.source, "closed"); + const dir = "/secret/workspace-path"; + const live = await collectLiveOrgSnapshot( + async () => ({ status: 200, body: { open: true, path: dir } }), + async () => ({ name: "current", owner: "repo-owner", description: "", roles: [{ id: "repo-owner", name: "仓库负责人" }] }), + ); + assert.equal(live.source, "live"); + assert.equal(live.name, "current"); + assert.equal(JSON.stringify(live).includes(dir), false); +}); + +test("busy employee is reported instead of queued", async () => { + const apiRequest = async (pathname) => { + if (pathname === "/workspace") return { status: 200, body: { open: true } }; + if (pathname === "/org/tree") return { status: 200, body: { tree: [{ id: "repo-owner" }] } }; + if (pathname === "/health") return { status: 200, body: { hosts: { qoder: { ready: true } } } }; + if (pathname.startsWith("/sessions?")) return { status: 200, body: { activeSessionId: "sess-1" } }; + if (pathname === "/sessions/sess-1/turns") return { status: 409, body: { code: "session_conflict" } }; + throw new Error(pathname); + }; + const result = await dispatchPhoneCommand(apiRequest, "再来一条"); + assert.equal(result.state, "busy"); +}); diff --git a/deploy/command.css b/deploy/command.css new file mode 100644 index 00000000..0e7ecd17 --- /dev/null +++ b/deploy/command.css @@ -0,0 +1,34 @@ +:root { + color-scheme: dark; + font-family: Inter, system-ui, sans-serif; + --bg: #101116; + --ink: #f5f6fa; + --muted: #a7adbb; + --line: #363946; + --accent: #7aa2ff; +} +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); } +body { padding: max(20px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); } +main { max-width: 28rem; margin: 0 auto; } +.eyebrow { margin: 0 0 8px; color: var(--accent); letter-spacing: 0.08em; text-transform: uppercase; font-size: 12px; } +h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.2; } +.lead, #status { color: var(--muted); line-height: 1.5; } +form { display: grid; gap: 10px; margin: 24px 0; } +label { font-size: 14px; } +input, textarea, button { + width: 100%; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 12px; + background: #1c1e26; +} +input, textarea { padding: 14px; } +input { letter-spacing: 0.4em; font-size: 22px; text-align: center; } +button { + min-height: 48px; background: var(--accent); color: #101116; + border: 0; font-weight: 650; cursor: pointer; +} +button:disabled { opacity: 0.55; cursor: not-allowed; } +#summary { + white-space: pre-wrap; background: #1c1e26; border: 1px solid var(--line); + border-radius: 12px; padding: 14px; line-height: 1.45; +} diff --git a/deploy/command.html b/deploy/command.html new file mode 100644 index 00000000..eafc5e0b --- /dev/null +++ b/deploy/command.html @@ -0,0 +1,38 @@ + + + + + + + 发指令 · RoleWeave + + + +
+

RoleWeave

+

发一条指令

+

发给电脑上已打开的 RoleWeave。同一产品地址可一键连接;也可以输入电脑弹窗里的 6 位码。

+

正在查看电脑是否在线…

+ +
+ +
+ +
+ + + +
+ + + +

尚未配对。

+ +
+ + + diff --git a/deploy/command.mjs b/deploy/command.mjs new file mode 100644 index 00000000..0fa680cb --- /dev/null +++ b/deploy/command.mjs @@ -0,0 +1,144 @@ +const statusEl = document.getElementById("status"); +const summaryEl = document.getElementById("summary"); +const pairForm = document.getElementById("pair-form"); +const claimForm = document.getElementById("claim-form"); +const hostStatus = document.getElementById("host-status"); +const commandForm = document.getElementById("command-form"); +const storageKey = "roleweave-phone-link-v1"; + +let socket = null; +let deviceToken = localStorage.getItem(storageKey); + +function setStatus(text) { + statusEl.textContent = text; +} + +function showSummary(text) { + if (!text) { + summaryEl.hidden = true; + summaryEl.textContent = ""; + return; + } + summaryEl.hidden = false; + summaryEl.textContent = text; +} + +function connectedUi() { + pairForm.hidden = true; + claimForm.hidden = true; + commandForm.hidden = false; +} + +function disconnectedUi() { + pairForm.hidden = false; + claimForm.hidden = false; + commandForm.hidden = true; +} + +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") { + connectedUi(); + setStatus("已连上电脑,可以发指令。"); + return; + } + if (message.type === "command.status") { + const labels = { + accepted: "电脑已接到", + running: "员工正在处理", + completed: "完成", + failed: "失败", + busy: "该员工正在忙", + needs_approval: "请在电脑上确认", + }; + setStatus(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; + disconnectedUi(); + } + setStatus(message.message ?? "出错了"); + } + }); + socket.addEventListener("close", () => { + if (deviceToken) setStatus("连接断开,正在重试…"); + }); +} + +async function acceptGrant(body) { + if (!body?.deviceToken) return false; + deviceToken = body.deviceToken; + localStorage.setItem(storageKey, deviceToken); + connect(); + return true; +} + +async function refreshHostStatus() { + if (!hostStatus) return; + try { + const response = await fetch("/phone-link/v1/status", { headers: { accept: "application/json" } }); + const body = await response.json(); + hostStatus.textContent = body.hostOnline ? "电脑在线,可以连接。" : "电脑还没连上。先打开 RoleWeave 桌面。"; + } catch { + hostStatus.textContent = "暂时读不到电脑状态。"; + } +} + +claimForm.addEventListener("submit", async (event) => { + event.preventDefault(); + setStatus("正在连接电脑…"); + const response = await fetch("/phone-link/v1/claim", { method: "POST" }); + const body = await response.json(); + if (!response.ok || !(await acceptGrant(body))) { + setStatus(body.message ?? "连接失败"); + } +}); + +pairForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const code = document.getElementById("code").value.trim(); + setStatus("正在配对…"); + 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 || !(await acceptGrant(body))) { + setStatus(body.message ?? "配对失败"); + } +}); + +commandForm.addEventListener("submit", (event) => { + event.preventDefault(); + const text = document.getElementById("text").value.trim(); + if (!text || !socket || socket.readyState !== WebSocket.OPEN) { + setStatus("还没连上电脑"); + return; + } + showSummary(""); + const commandId = crypto.randomUUID(); + socket.send(JSON.stringify({ v: 1, type: "command.submit", commandId, text })); + setStatus("已发出,等电脑受理…"); +}); + +if (deviceToken) connect(); +void refreshHostStatus(); +setInterval(() => { void refreshHostStatus(); }, 4000); diff --git a/deploy/mobile-surface.mjs b/deploy/mobile-surface.mjs new file mode 100644 index 00000000..a6e035c9 --- /dev/null +++ b/deploy/mobile-surface.mjs @@ -0,0 +1,159 @@ +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; +const PHONE_PLATFORMS = new Set(["ios", "android", "harmony"]); + +export function isMobileUserAgent(userAgent) { + return typeof userAgent === "string" && MOBILE_UA.test(userAgent); +} + +export function detectPlatform(userAgent) { + const ua = typeof userAgent === "string" ? userAgent : ""; + if (/OpenHarmony|ArkWeb|HarmonyOS/i.test(ua)) return "harmony"; + if (/iPhone|iPad|iPod/i.test(ua)) return "ios"; + if (/Android/i.test(ua)) return "android"; + if (isMobileUserAgent(ua)) return "android"; + return "desktop"; +} + +export function choosePlatform({ userAgent, searchParams }) { + const params = searchParams instanceof URLSearchParams ? searchParams : new URLSearchParams(); + const requestedPlatform = params.get("platform"); + if (requestedPlatform === "desktop" || PHONE_PLATFORMS.has(requestedPlatform)) return requestedPlatform; + if (params.get("surface") === "desktop") return "desktop"; + const detected = detectPlatform(userAgent); + if (params.get("surface") === "mobile" && detected === "desktop") return "android"; + return detected; +} + +export function chooseSurface({ userAgent, searchParams }) { + return choosePlatform({ userAgent, searchParams }) === "desktop" ? "desktop" : "mobile"; +} + +function safeJoin(root, relative) { + const candidate = path.resolve(root, relative); + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) return null; + return candidate; +} + +function phoneDir(deployDir, platform) { + return path.join(deployDir, "mobile", PHONE_PLATFORMS.has(platform) ? platform : "android"); +} + +export function resolvePublicAsset(urlPath, { deployDir, noVncDir, userAgent, searchParams }) { + const platform = choosePlatform({ userAgent, searchParams }); + if (urlPath === "/") { + return platform === "desktop" + ? path.join(deployDir, "index.html") + : path.join(phoneDir(deployDir, platform), "index.html"); + } + if (urlPath === "/mobile" || urlPath === "/mobile/") { + return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "index.html"); + } + if (urlPath === "/desktop" || urlPath === "/desktop/") return path.join(deployDir, "index.html"); + const phoneAsset = /^\/(ios|android|harmony)\/(index\.html|app\.css|app\.mjs)$/.exec(urlPath); + if (phoneAsset) return path.join(deployDir, "mobile", phoneAsset[1], phoneAsset[2]); + if (urlPath === "/ios" || urlPath === "/ios/") return path.join(deployDir, "mobile", "ios", "index.html"); + if (urlPath === "/android" || urlPath === "/android/") return path.join(deployDir, "mobile", "android", "index.html"); + if (urlPath === "/harmony" || urlPath === "/harmony/") return path.join(deployDir, "mobile", "harmony", "index.html"); + if (urlPath === "/shared/org.mjs") return path.join(deployDir, "mobile", "shared", "org.mjs"); + if (urlPath === "/shared/command.mjs") return path.join(deployDir, "mobile", "shared", "command.mjs"); + if (urlPath === "/shared/shell.mjs") return path.join(deployDir, "mobile", "shared", "shell.mjs"); + if (urlPath === "/mobile/app.css") return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "app.css"); + if (urlPath === "/mobile/app.mjs") return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "app.mjs"); + if (urlPath === "/command" || urlPath === "/command.html") return path.join(deployDir, "command.html"); + if (urlPath === "/command.css") return path.join(deployDir, "command.css"); + if (urlPath === "/command.mjs") return path.join(deployDir, "command.mjs"); + if (urlPath === "/client.mjs") return path.join(deployDir, "client.mjs"); + if (!urlPath.startsWith("/novnc/")) return null; + const relative = decodeURIComponent(urlPath.slice("/novnc/".length)); + if (relative.includes("\0") || relative.split(/[\\/]/).includes("..")) return null; + const candidate = path.resolve(noVncDir, relative); + return candidate.startsWith(`${noVncDir}${path.sep}`) ? candidate : 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 closedOrgSnapshot() { + return { open: false, source: "closed", name: "", description: "", owner: null, roles: [] }; +} + +export function previewOrgSnapshot(snapshot) { + return { ...snapshot, open: false, source: "preview" }; +} + +export function liveOrgSnapshot(snapshot, workspacePath) { + const live = { ...snapshot, open: true, source: "live" }; + if (typeof workspacePath === "string" && workspacePath.length > 0 && JSON.stringify(live).includes(workspacePath)) { + throw new Error("snapshot_leaks_path"); + } + return live; +} + +export function defaultExampleDir(productDir) { + return safeJoin(productDir, path.join("examples", "oss-maintainer")); +} diff --git a/deploy/mobile/android/app.css b/deploy/mobile/android/app.css new file mode 100644 index 00000000..71dd88d4 --- /dev/null +++ b/deploy/mobile/android/app.css @@ -0,0 +1,72 @@ +:root { + color-scheme: dark; + --md-bg: #131316; + --md-surface: #1b1b1f; + --md-ink: #e6e1e5; + --md-muted: #cac4d0; + --md-line: #49454f; + --md-accent: #7aa2ff; + --md-on-accent: #001b3f; + font-family: Roboto, "Noto Sans SC", system-ui, sans-serif; +} +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--md-bg); color: var(--md-ink); } +body { + min-height: 100dvh; + padding: 0 0 calc(80px + env(safe-area-inset-bottom)); + overscroll-behavior: contain; + touch-action: manipulation; +} +.md-bar { + padding: max(12px, env(safe-area-inset-top)) 16px 16px; + background: var(--md-surface); + box-shadow: 0 1px 3px rgb(0 0 0 / 40%); +} +.md-bar__over { margin: 0; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--md-accent); } +.md-bar h1 { margin: 6px 0 0; font-size: 22px; font-weight: 500; } +.md-body, .host-status, #command-status { margin: 16px; color: var(--md-muted); line-height: 1.5; } +.panel[hidden] { display: none; } +.md-list { list-style: none; margin: 0; padding: 0; } +.md-list .role, .md-settings li { + width: 100%; min-height: 72px; padding: 16px; + border: 0; border-bottom: 1px solid var(--md-line); + background: var(--md-surface); color: inherit; font: inherit; text-align: left; + display: grid; gap: 4px; +} +.md-list .role:active { background: #2b2930; } +.md-sheet { margin: 16px; padding: 16px; background: var(--md-surface); border-radius: 12px; display: grid; gap: 8px; } +.md-stack { display: grid; gap: 12px; margin: 16px; } +label { font-size: 12px; color: var(--md-muted); } +input, textarea { + width: 100%; padding: 16px; border-radius: 4px 4px 0 0; border: 0; border-bottom: 2px solid var(--md-accent); + background: #2b2930; color: inherit; font: inherit; +} +input { letter-spacing: 0.35em; font-size: 22px; text-align: center; } +.md-fab { + min-height: 48px; display: flex; align-items: center; justify-content: center; + border: 0; border-radius: 16px; background: var(--md-accent); color: var(--md-on-accent); + font: inherit; font-weight: 600; text-decoration: none; +} +.md-text { + min-height: 48px; display: flex; align-items: center; justify-content: center; + border: 0; background: transparent; color: var(--md-accent); font: inherit; text-decoration: none; +} +.md-nav { + position: fixed; left: 0; right: 0; bottom: 0; + display: grid; grid-template-columns: repeat(4, 1fr); + height: calc(80px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--md-surface); border-top: 1px solid var(--md-line); +} +.tab { + min-height: 48px; border: 0; background: transparent; color: var(--md-muted); + font-size: 12px; font-weight: 500; +} +.tab.is-active { color: var(--md-accent); background: #2b2930; } +.error { color: #f2b8b5; } +.meta { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { font-size: 12px; color: var(--md-muted); } +.detail pre, #command-summary { white-space: pre-wrap; color: var(--md-muted); font: 13px/1.45 ui-monospace, monospace; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible { + outline: 2px solid var(--md-accent); outline-offset: 2px; +} diff --git a/deploy/mobile/android/app.mjs b/deploy/mobile/android/app.mjs new file mode 100644 index 00000000..04023123 --- /dev/null +++ b/deploy/mobile/android/app.mjs @@ -0,0 +1 @@ +import "/shared/shell.mjs"; diff --git a/deploy/mobile/android/index.html b/deploy/mobile/android/index.html new file mode 100644 index 00000000..c79384ab --- /dev/null +++ b/deploy/mobile/android/index.html @@ -0,0 +1,63 @@ + + + + + + + RoleWeave + + + +
+

RoleWeave · Android

+

工作区

+
+
+
+

正在读取组织…

+
    + +
    + + + +
    + + + + diff --git a/deploy/mobile/app.css b/deploy/mobile/app.css new file mode 100644 index 00000000..fac0c44e --- /dev/null +++ b/deploy/mobile/app.css @@ -0,0 +1,235 @@ +: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); +} +.button.danger { + width: 100%; + margin-bottom: 12px; + background: #3a2428; + color: var(--danger); + border: 1px solid #5a343c; +} +#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); } +.host-status { margin: 0 0 16px; color: var(--muted); } +details { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 8px 12px 12px; + background: var(--bg-elev); +} +summary { + min-height: 44px; + display: flex; + align-items: center; + cursor: pointer; + font-weight: 650; +} +details .stack { margin-top: 8px; } + +@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/deploy/mobile/app.mjs b/deploy/mobile/app.mjs new file mode 100644 index 00000000..4b9b6b0c --- /dev/null +++ b/deploy/mobile/app.mjs @@ -0,0 +1,284 @@ +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 claimForm = document.getElementById("claim-form"); +const hostStatus = document.getElementById("host-status"); +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 describeSource(snapshot) { + if (snapshot.source === "live") return "电脑当前工作区"; + if (snapshot.source === "closed") return "电脑未打开工作区"; + return "示例预览,电脑打开工作区后会换成当前组织"; +} + +function setDeviceState(connected) { + const state = document.getElementById("device-state"); + const revoke = document.getElementById("revoke"); + if (state) state.textContent = connected ? "已连接" : "未连接"; + if (revoke) revoke.hidden = !connected; +} + +function renderRoles(snapshot) { + const sourceEl = document.getElementById("org-source"); + if (sourceEl) sourceEl.textContent = describeSource(snapshot); + if (snapshot.source === "closed") { + workspaceName.textContent = "未打开工作区"; + orgLead.textContent = "在电脑上打开一个工作区后,这里会列出当前员工。"; + orgLead.classList.remove("error"); + roleList.replaceChildren(); + roleDetail.hidden = true; + return; + } + workspaceName.textContent = snapshot.name; + orgLead.textContent = snapshot.source === "live" + ? (snapshot.description || "这是电脑上正在打开的工作区。") + : (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; + claimForm.hidden = true; + commandForm.hidden = false; + setDeviceState(true); + 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; + claimForm.hidden = false; + commandForm.hidden = true; + setDeviceState(false); + } + setCommandStatus(message.message ?? "出错了"); + } + }); + socket.addEventListener("close", () => { + if (deviceToken) setCommandStatus("连接断开,正在重试…"); + }); +} + +async function acceptGrant(body) { + if (!body?.deviceToken) return false; + deviceToken = body.deviceToken; + localStorage.setItem(storageKey, deviceToken); + connect(); + return true; +} + +async function refreshHostStatus() { + try { + const response = await fetch("/phone-link/v1/status", { headers: { accept: "application/json" } }); + const body = await response.json(); + if (body.hostOnline) { + hostStatus.textContent = "电脑在线,可以连接。"; + hostStatus.classList.remove("error"); + } else { + hostStatus.textContent = "电脑还没连上。先打开 RoleWeave 桌面。"; + hostStatus.classList.add("error"); + } + } catch { + hostStatus.textContent = "暂时读不到电脑状态。"; + hostStatus.classList.add("error"); + } +} + +claimForm.addEventListener("submit", async (event) => { + event.preventDefault(); + setCommandStatus("正在连接电脑…"); + const response = await fetch("/phone-link/v1/claim", { method: "POST" }); + const body = await response.json(); + if (!response.ok || !(await acceptGrant(body))) { + setCommandStatus(body.message ?? "连接失败"); + } +}); + +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 || !(await acceptGrant(body))) { + setCommandStatus(body.message ?? "配对失败"); + } +}); + +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, + ...(selectedRoleId ? { positionId: selectedRoleId } : {}), + })); + setCommandStatus("已发出,等电脑受理…"); +}); + +document.querySelector("nav[aria-label='主要功能']")?.addEventListener("click", (event) => { + const tab = event.target.closest("[data-tab]"); + if (tab) selectTab(tab.dataset.tab); +}); + +document.getElementById("revoke")?.addEventListener("click", async () => { + if (!deviceToken) return; + const response = await fetch("/phone-link/v1/revoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceToken }), + }); + localStorage.removeItem(storageKey); + deviceToken = null; + socket?.close(); + pairForm.hidden = false; + claimForm.hidden = false; + commandForm.hidden = true; + setDeviceState(false); + setCommandStatus(response.ok ? "已断开这台手机。" : "已在本机退出,电脑侧可能仍需确认。"); +}); + +if (deviceToken) connect(); +else setDeviceState(false); +void loadWorkspace(); +void refreshHostStatus(); +setInterval(() => { + void refreshHostStatus(); + void loadWorkspace(); +}, 4000); diff --git a/deploy/mobile/harmony/app.css b/deploy/mobile/harmony/app.css new file mode 100644 index 00000000..be35030a --- /dev/null +++ b/deploy/mobile/harmony/app.css @@ -0,0 +1,63 @@ +:root { + color-scheme: dark; + --hw-bg: #16141a; + --hw-card: #221f28; + --hw-ink: #f4f0f7; + --hw-muted: #b7b0c2; + --hw-line: #3a3544; + --hw-accent: #c9a7ff; + --hw-on-accent: #2b1658; + font-family: "HarmonyOS Sans", "Noto Sans SC", system-ui, sans-serif; +} +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--hw-bg); color: var(--hw-ink); } +body { + min-height: 100dvh; + padding: max(16px, env(safe-area-inset-top)) 16px calc(72px + env(safe-area-inset-bottom)); + overscroll-behavior: contain; + touch-action: manipulation; +} +.hw-hero { margin-bottom: 18px; } +.hw-kicker { margin: 0; color: var(--hw-accent); font-size: 12px; letter-spacing: 0.12em; } +.hw-hero h1 { margin: 8px 0 0; font-size: 28px; font-weight: 650; } +.hw-copy, .host-status, #command-status { color: var(--hw-muted); line-height: 1.55; } +.panel[hidden] { display: none; } +.hw-cards { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 12px; } +.hw-cards .role, .hw-settings li { + width: 100%; min-height: 88px; padding: 18px; + border: 1px solid var(--hw-line); border-radius: 20px; + background: var(--hw-card); color: inherit; font: inherit; text-align: left; + display: grid; gap: 6px; +} +.hw-settings li { display: flex; justify-content: space-between; align-items: center; min-height: 56px; } +.hw-panel { margin-top: 14px; padding: 18px; border-radius: 20px; background: var(--hw-card); display: grid; gap: 10px; } +.hw-stack { display: grid; gap: 12px; margin: 18px 0; } +label { font-size: 13px; color: var(--hw-muted); } +input, textarea { + width: 100%; padding: 14px; border-radius: 16px; border: 1px solid var(--hw-line); + background: #2a2631; color: inherit; font: inherit; +} +input { letter-spacing: 0.32em; font-size: 22px; text-align: center; } +.hw-primary, .hw-ghost { + min-height: 48px; display: flex; align-items: center; justify-content: center; + border-radius: 24px; font: inherit; font-weight: 650; text-decoration: none; +} +.hw-primary { border: 0; background: var(--hw-accent); color: var(--hw-on-accent); } +.hw-ghost { border: 1px solid var(--hw-line); background: transparent; color: var(--hw-ink); } +.hw-dock { + position: fixed; left: 12px; right: 12px; bottom: calc(8px + env(safe-area-inset-bottom)); + display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; + padding: 8px; border-radius: 28px; background: var(--hw-card); border: 1px solid var(--hw-line); +} +.tab { + min-height: 48px; border: 0; border-radius: 20px; background: transparent; color: var(--hw-muted); + font-size: 12px; font-weight: 650; +} +.tab.is-active { color: var(--hw-on-accent); background: var(--hw-accent); } +.error { color: #ffb4ab; } +.meta { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { font-size: 12px; color: var(--hw-muted); } +.detail pre, #command-summary { white-space: pre-wrap; color: var(--hw-muted); font: 13px/1.45 ui-monospace, monospace; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible { + outline: 2px solid var(--hw-accent); outline-offset: 2px; +} diff --git a/deploy/mobile/harmony/app.mjs b/deploy/mobile/harmony/app.mjs new file mode 100644 index 00000000..04023123 --- /dev/null +++ b/deploy/mobile/harmony/app.mjs @@ -0,0 +1 @@ +import "/shared/shell.mjs"; diff --git a/deploy/mobile/harmony/index.html b/deploy/mobile/harmony/index.html new file mode 100644 index 00000000..5cadd36d --- /dev/null +++ b/deploy/mobile/harmony/index.html @@ -0,0 +1,63 @@ + + + + + + + RoleWeave 鸿蒙 + + + +
    +

    RoleWeave · 鸿蒙

    +

    工作区

    +
    +
    +
    +

    正在读取组织…

    +
      + +
      + + + +
      + + + + diff --git a/deploy/mobile/index.html b/deploy/mobile/index.html new file mode 100644 index 00000000..feb17863 --- /dev/null +++ b/deploy/mobile/index.html @@ -0,0 +1,101 @@ + + + + + + + + + RoleWeave + + + + +
      +
      + +
      +

      RoleWeave

      +

      工作区

      +
      +
      +
      +
      +
      +

      正在读取组织…

      +
        + +
        + + + +
        + + + + diff --git a/deploy/mobile/ios/app.css b/deploy/mobile/ios/app.css new file mode 100644 index 00000000..651b1a78 --- /dev/null +++ b/deploy/mobile/ios/app.css @@ -0,0 +1,73 @@ +:root { + color-scheme: dark; + --ios-bg: #000; + --ios-group: #1c1c1e; + --ios-ink: #f5f5f7; + --ios-muted: #8e8e93; + --ios-line: #38383a; + --ios-accent: #0a84ff; + font-family: -apple-system, "SF Pro Text", "PingFang SC", system-ui, sans-serif; +} +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--ios-bg); color: var(--ios-ink); } +body { + min-height: 100dvh; + padding: max(8px, env(safe-area-inset-top)) 16px calc(56px + env(safe-area-inset-bottom)); + overscroll-behavior: contain; + touch-action: manipulation; +} +.ios-nav { margin: 8px 4px 18px; } +.ios-nav__product { margin: 0; color: var(--ios-muted); font-size: 13px; } +.ios-nav h1 { margin: 4px 0 0; font-size: 34px; letter-spacing: -0.04em; } +.ios-footnote, .host-status, #command-status { color: var(--ios-muted); font-size: 13px; line-height: 1.4; } +.panel[hidden] { display: none; } +.ios-group { + list-style: none; margin: 12px 0; padding: 0; + background: var(--ios-group); border-radius: 12px; overflow: hidden; +} +.ios-group .role, .ios-settings li { + width: 100%; min-height: 48px; padding: 12px 16px; + border: 0; border-bottom: 0.5px solid var(--ios-line); + background: transparent; color: inherit; font: inherit; text-align: left; + display: flex; justify-content: space-between; gap: 12px; align-items: center; +} +.ios-group li:last-child .role, .ios-settings li:last-child { border-bottom: 0; } +.ios-group .role { display: grid; justify-items: start; } +.ios-group .role strong { font-size: 17px; } +.ios-group .role span { color: var(--ios-muted); font-size: 13px; } +.ios-card { + margin-top: 12px; padding: 16px; background: var(--ios-group); border-radius: 12px; + display: grid; gap: 8px; +} +.ios-stack { display: grid; gap: 10px; margin: 16px 0; } +label { font-size: 13px; color: var(--ios-muted); } +input, textarea { + width: 100%; padding: 12px 14px; border-radius: 12px; border: 0; + background: var(--ios-group); color: inherit; font: inherit; +} +input { letter-spacing: 0.3em; font-size: 22px; text-align: center; } +.ios-btn { + min-height: 48px; display: flex; align-items: center; justify-content: center; + border: 0; border-radius: 12px; background: var(--ios-accent); color: #fff; + font: inherit; font-weight: 600; text-decoration: none; +} +.ios-btn--plain { background: var(--ios-group); color: var(--ios-accent); } +.ios-tabbar { + position: fixed; left: 0; right: 0; bottom: 0; + display: grid; grid-template-columns: repeat(4, 1fr); + min-height: 49px; padding-bottom: env(safe-area-inset-bottom); + background: rgb(28 28 30 / 92%); border-top: 0.5px solid var(--ios-line); + backdrop-filter: blur(16px); +} +.tab { + min-height: 44px; border: 0; background: transparent; color: var(--ios-muted); + font-size: 10px; font-weight: 600; +} +.tab.is-active { color: var(--ios-accent); } +.error { color: #ff453a; } +.chip { color: var(--ios-muted); font-size: 12px; } +.meta { display: flex; gap: 8px; flex-wrap: wrap; } +.detail pre, #command-summary { white-space: pre-wrap; color: var(--ios-muted); font: 13px/1.45 ui-monospace, Menlo, monospace; } +button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible { + outline: 2px solid var(--ios-accent); outline-offset: 2px; +} diff --git a/deploy/mobile/ios/app.mjs b/deploy/mobile/ios/app.mjs new file mode 100644 index 00000000..04023123 --- /dev/null +++ b/deploy/mobile/ios/app.mjs @@ -0,0 +1 @@ +import "/shared/shell.mjs"; diff --git a/deploy/mobile/ios/index.html b/deploy/mobile/ios/index.html new file mode 100644 index 00000000..743b6e5b --- /dev/null +++ b/deploy/mobile/ios/index.html @@ -0,0 +1,64 @@ + + + + + + + + 组织 · RoleWeave + + + +
        +

        RoleWeave · iOS

        +

        组织

        +
        +
        +
        +

        正在读取组织…

        +
          + +
          + + + +
          + + + + diff --git a/deploy/mobile/shared/shell.mjs b/deploy/mobile/shared/shell.mjs new file mode 100644 index 00000000..4b9b6b0c --- /dev/null +++ b/deploy/mobile/shared/shell.mjs @@ -0,0 +1,284 @@ +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 claimForm = document.getElementById("claim-form"); +const hostStatus = document.getElementById("host-status"); +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 describeSource(snapshot) { + if (snapshot.source === "live") return "电脑当前工作区"; + if (snapshot.source === "closed") return "电脑未打开工作区"; + return "示例预览,电脑打开工作区后会换成当前组织"; +} + +function setDeviceState(connected) { + const state = document.getElementById("device-state"); + const revoke = document.getElementById("revoke"); + if (state) state.textContent = connected ? "已连接" : "未连接"; + if (revoke) revoke.hidden = !connected; +} + +function renderRoles(snapshot) { + const sourceEl = document.getElementById("org-source"); + if (sourceEl) sourceEl.textContent = describeSource(snapshot); + if (snapshot.source === "closed") { + workspaceName.textContent = "未打开工作区"; + orgLead.textContent = "在电脑上打开一个工作区后,这里会列出当前员工。"; + orgLead.classList.remove("error"); + roleList.replaceChildren(); + roleDetail.hidden = true; + return; + } + workspaceName.textContent = snapshot.name; + orgLead.textContent = snapshot.source === "live" + ? (snapshot.description || "这是电脑上正在打开的工作区。") + : (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; + claimForm.hidden = true; + commandForm.hidden = false; + setDeviceState(true); + 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; + claimForm.hidden = false; + commandForm.hidden = true; + setDeviceState(false); + } + setCommandStatus(message.message ?? "出错了"); + } + }); + socket.addEventListener("close", () => { + if (deviceToken) setCommandStatus("连接断开,正在重试…"); + }); +} + +async function acceptGrant(body) { + if (!body?.deviceToken) return false; + deviceToken = body.deviceToken; + localStorage.setItem(storageKey, deviceToken); + connect(); + return true; +} + +async function refreshHostStatus() { + try { + const response = await fetch("/phone-link/v1/status", { headers: { accept: "application/json" } }); + const body = await response.json(); + if (body.hostOnline) { + hostStatus.textContent = "电脑在线,可以连接。"; + hostStatus.classList.remove("error"); + } else { + hostStatus.textContent = "电脑还没连上。先打开 RoleWeave 桌面。"; + hostStatus.classList.add("error"); + } + } catch { + hostStatus.textContent = "暂时读不到电脑状态。"; + hostStatus.classList.add("error"); + } +} + +claimForm.addEventListener("submit", async (event) => { + event.preventDefault(); + setCommandStatus("正在连接电脑…"); + const response = await fetch("/phone-link/v1/claim", { method: "POST" }); + const body = await response.json(); + if (!response.ok || !(await acceptGrant(body))) { + setCommandStatus(body.message ?? "连接失败"); + } +}); + +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 || !(await acceptGrant(body))) { + setCommandStatus(body.message ?? "配对失败"); + } +}); + +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, + ...(selectedRoleId ? { positionId: selectedRoleId } : {}), + })); + setCommandStatus("已发出,等电脑受理…"); +}); + +document.querySelector("nav[aria-label='主要功能']")?.addEventListener("click", (event) => { + const tab = event.target.closest("[data-tab]"); + if (tab) selectTab(tab.dataset.tab); +}); + +document.getElementById("revoke")?.addEventListener("click", async () => { + if (!deviceToken) return; + const response = await fetch("/phone-link/v1/revoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceToken }), + }); + localStorage.removeItem(storageKey); + deviceToken = null; + socket?.close(); + pairForm.hidden = false; + claimForm.hidden = false; + commandForm.hidden = true; + setDeviceState(false); + setCommandStatus(response.ok ? "已断开这台手机。" : "已在本机退出,电脑侧可能仍需确认。"); +}); + +if (deviceToken) connect(); +else setDeviceState(false); +void loadWorkspace(); +void refreshHostStatus(); +setInterval(() => { + void refreshHostStatus(); + void loadWorkspace(); +}, 4000); diff --git a/deploy/web-server.mjs b/deploy/web-server.mjs new file mode 100644 index 00000000..0fe64166 --- /dev/null +++ b/deploy/web-server.mjs @@ -0,0 +1,249 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WebSocket, WebSocketServer } from "ws"; +import { createPhoneLinkRelay } from "../packages/phone-link/src/index.mjs"; +import { + defaultExampleDir, + loadWorkspaceSnapshot, + previewOrgSnapshot, + resolvePublicAsset, +} from "./mobile-surface.mjs"; + +const port = Number.parseInt(process.argv[2] ?? "", 10); +const vncPort = Number.parseInt(process.argv[3] ?? "", 10); +if (!Number.isInteger(port) || port < 1 || port > 65535 || + !Number.isInteger(vncPort) || vncPort < 1 || vncPort > 65535) { + process.stderr.write("usage: node web-server.mjs \n"); + process.exit(64); +} + +const deployDir = path.dirname(fileURLToPath(import.meta.url)); +const productDir = path.resolve(deployDir, ".."); +const exampleDir = defaultExampleDir(productDir); +const noVncDir = path.join(deployDir, "node_modules", "@novnc", "novnc"); +const contentTypes = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".js", "text/javascript; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], + [".png", "image/png"], + [".svg", "image/svg+xml"], +]); + +function resolvedAsset(urlPath, request) { + const url = new URL(request.url ?? "/", "http://localhost"); + return resolvePublicAsset(urlPath, { + deployDir, + noVncDir, + userAgent: request.headers["user-agent"] ?? "", + searchParams: url.searchParams, + }); +} + +const hostToken = process.env.ROLEWEAVE_PHONE_LINK_HOST_TOKEN ?? ""; +const phoneLink = /^[a-f0-9]{64}$/.test(hostToken) ? createPhoneLinkRelay({ hostToken }) : null; + +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); +} + +async function readJson(request) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buf.length; + if (size > 16 * 1024) throw new Error("too large"); + chunks.push(buf); + } + if (chunks.length === 0) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function parseSocketJson(raw) { + const text = typeof raw === "string" ? raw : Buffer.from(raw).toString("utf8"); + return JSON.parse(text); +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname === "/healthz") { + response.writeHead(200, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end('{"ok":true,"app":"RoleWeave"}\n'); + return; + } + if (url.pathname === "/phone-link/v1/protocol" && request.method === "GET") { + json(response, 200, phoneLink ? phoneLink.protocol() : { schema: "phone-link.v1", enabled: false }); + return; + } + if (url.pathname === "/phone-link/v1/status" && request.method === "GET") { + json(response, 200, phoneLink ? phoneLink.status() : { schema: "phone-link.v1", hostOnline: false, deviceCount: 0 }); + return; + } + if (url.pathname === "/phone-link/v1/claim" && request.method === "POST") { + if (!phoneLink) { + json(response, 503, { code: "unavailable", message: "phone-link host token is not configured" }); + return; + } + const result = phoneLink.claim(); + json(response, result.ok ? 200 : result.code === "host_offline" ? 503 : 400, result.ok + ? { deviceId: result.deviceId, deviceToken: result.deviceToken } + : { code: result.code, message: result.message }); + return; + } + if (url.pathname === "/phone-link/v1/revoke" && request.method === "POST") { + if (!phoneLink) { + json(response, 503, { code: "unavailable", message: "phone-link host token is not configured" }); + return; + } + try { + const body = await readJson(request); + const result = phoneLink.revokeByToken(body.deviceToken); + json(response, result.ok ? 200 : result.code === "unauthorized" ? 401 : 400, result.ok + ? { revoked: true, deviceId: result.deviceId } + : { code: result.code, message: result.message }); + } catch { + json(response, 400, { code: "bad_request", message: "revoke body must be JSON" }); + } + return; + } + if (url.pathname === "/phone-link/v1/pair" && request.method === "POST") { + if (!phoneLink) { + json(response, 503, { code: "unavailable", message: "phone-link host token is not configured" }); + return; + } + try { + const body = await readJson(request); + const result = phoneLink.pair(body.code); + json(response, result.ok ? 200 : 400, result.ok ? { deviceId: result.deviceId, deviceToken: result.deviceToken } : { code: result.code, message: result.message }); + } catch { + json(response, 400, { code: "bad_request", message: "pairing body must be JSON" }); + } + return; + } + if (url.pathname === "/api/mobile/workspace" && request.method === "GET") { + const live = phoneLink?.orgSnapshot(); + if (live) { + json(response, 200, live); + return; + } + try { + json(response, 200, previewOrgSnapshot(loadWorkspaceSnapshot(exampleDir))); + } catch { + json(response, 503, { code: "unavailable", message: "mobile workspace preview is not available" }); + } + return; + } + const asset = resolvedAsset(url.pathname, request); + if (asset === null) { + 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": contentTypes.get(path.extname(asset)) ?? "application/octet-stream", + "content-length": details.size, + "cache-control": url.pathname.startsWith("/novnc/") ? "public, max-age=86400" : "no-store", + "content-security-policy": "default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'", + "x-content-type-options": "nosniff", + "referrer-policy": "no-referrer", + }); + createReadStream(asset).pipe(response); + } catch { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found\n"); + } +}); + +const websocketServer = new WebSocketServer({ noServer: true, perMessageDeflate: false }); +server.on("upgrade", (request, socket, head) => { + const url = new URL(request.url ?? "/", "http://localhost"); + const origin = request.headers.origin; + let sameOrigin = true; + try { + sameOrigin = !origin || new URL(origin).host === request.headers.host; + } catch { + sameOrigin = false; + } + if (url.pathname === "/phone-link/host" || url.pathname === "/phone-link/phone") { + if (!phoneLink || !sameOrigin) { + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + websocketServer.handleUpgrade(request, socket, head, (websocket) => { + websocketServer.emit("phone-link", websocket, url.pathname); + }); + return; + } + if (url.pathname !== "/websockify" || !sameOrigin) { + socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + websocketServer.handleUpgrade(request, socket, head, (websocket) => { + websocketServer.emit("connection", websocket); + }); +}); + +websocketServer.on("phone-link", (websocket, pathname) => { + if (!phoneLink) { + websocket.close(1011, "phone-link disabled"); + return; + } + if (pathname === "/phone-link/host") { + websocket.on("message", (data) => { + try { + phoneLink.handleHostMessage(websocket, parseSocketJson(data)); + } catch { + websocket.send(JSON.stringify({ v: 1, type: "error", code: "bad_request", message: "invalid JSON" })); + } + }); + websocket.on("close", () => phoneLink.disconnectHost(websocket)); + return; + } + let deviceToken = null; + websocket.on("message", (data) => { + try { + const message = parseSocketJson(data); + if (message.type === "phone.hello") { + deviceToken = typeof message.deviceToken === "string" ? message.deviceToken : ""; + phoneLink.connectPhone(websocket, deviceToken); + return; + } + if (deviceToken) phoneLink.handlePhoneMessage(websocket, deviceToken, message); + } catch { + websocket.send(JSON.stringify({ v: 1, type: "error", code: "bad_request", message: "invalid JSON" })); + } + }); + websocket.on("close", () => phoneLink.disconnectPhone(websocket)); +}); + +websocketServer.on("connection", (websocket) => { + const upstream = net.createConnection({ host: "127.0.0.1", port: vncPort }); + upstream.on("data", (chunk) => { + if (websocket.readyState === WebSocket.OPEN) websocket.send(chunk, { binary: true }); + }); + upstream.on("error", () => websocket.close(1011, "VNC server unavailable")); + upstream.on("close", () => websocket.close()); + websocket.on("message", (data) => upstream.write(data)); + websocket.on("close", () => upstream.destroy()); + websocket.on("error", () => upstream.destroy()); +}); + +server.listen(port, "0.0.0.0", () => { + process.stdout.write(`RoleWeave web desktop listening on 0.0.0.0:${port}\n`); +}); diff --git a/docs/design/mobile-shell.md b/docs/design/mobile-shell.md new file mode 100644 index 00000000..8effd4f6 --- /dev/null +++ b/docs/design/mobile-shell.md @@ -0,0 +1,43 @@ +# RoleWeave 手机壳 + +日期:2026-09-17 +状态:已在托管入口落地第一刀 + +## 问题 + +ASteam 上的 RoleWeave 原先把 1240×800 的 Electron 桌面经 VNC 投到浏览器。手机打开后是一块缩过的桌面:点不准、键进不去、组织树和对话都不是给拇指用的。 + +## 决定 + +手机走**原生网页壳**,不再默认投桌面。 + +| 入口 | 谁看到 | 内容 | +| --- | --- | --- | +| `/`(手机 UA 或 `ASteamApp`) | 手机 / App | 组织、指令、桌面入口、设置 | +| `/`(桌面 UA) | 宽屏浏览器 | 现有 noVNC 桌面 | +| `/mobile`、`/?surface=mobile` | 任何人 | 强制手机壳 | +| `/desktop`、`/?surface=desktop` | 任何人 | 强制远程桌面 | +| `/command` | 已配对手机 | 单页发指令(旧入口保留) | + +## 信息架构 + +底栏四个入口,不超过五: + +1. **组织**:电脑打开工作区后显示当前组织;未打开时提示,宿主掉线则回落到示例预览。 +2. **指令**:电脑在线时一键 `claim` 连上;也可以输入电脑弹窗里的 6 位码。一句话交给当前选中岗位(或组织树第一个员工)。电脑必须在线,不在手机上跑员工。 +3. **桌面**:需要完整工作台时再打开 VNC。 +4. **设置**:说明数据来源,并可「断开这台手机」。 + +## 约束 + +- 触控目标 ≥ 44px,底栏计入安全区。 +- 颜色沿用桌面:背景 `#12141b`,强调 `#7aa2ff`,不用另一套 AI 紫。 +- `/api/mobile/workspace` 只返回岗位名、描述、预算和说明书摘录,不回传主机路径。 +- 控制面 API 仍绑 `127.0.0.1`;手机不直连 boot token。 +- 危险操作仍在电脑上批准;手机最多看到 `needs_approval`。 + +## 非目标(本刀不做) + +- 在手机上招聘、改组织树、改预算。 +- 把控制面端口暴露到公网。 +- 重做 Electron 四区桌面布局。 diff --git a/docs/issues/001-mobile-shell.md b/docs/issues/001-mobile-shell.md new file mode 100644 index 00000000..fd0903c5 --- /dev/null +++ b/docs/issues/001-mobile-shell.md @@ -0,0 +1,22 @@ +# 手机打开 RoleWeave 应是原生壳,而不是缩过的桌面 + +- Type: story +- Priority: P1 +- Surface: ASteam 托管产品 / 手机 App + +## What to build + +手机或 ASteam App 打开产品根路径时,看到组织、发指令、桌面入口和设置,而不是 1240×800 的 VNC 桌面。桌面浏览器行为保持不变。 + +## Acceptance criteria + +- [ ] iPhone / Android / `ASteamApp` 访问 `/` 得到手机壳(`data-surface="mobile"`) +- [ ] Macintosh 等桌面 UA 访问 `/` 仍是远程桌面 +- [ ] `/?surface=mobile` 与 `/?surface=desktop` 可强制切换 +- [ ] 组织页列出 oss-maintainer 四个岗位,响应不含主机路径 +- [ ] 底栏四个入口,触控高度 ≥ 44px,尊重安全区 +- [ ] `/healthz` 不变 + +## Blocked by + +None - can start immediately diff --git a/package.json b/package.json index df7db1b9..073b1a49 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "git", "url": "git+https://github.com/bytefolk/roleweave.git" }, - "description": "RoleWeave — file tree is the org chart. Electron shell + local control plane for digital-employee workspaces.", + "description": "RoleWeave \u2014 file tree is the org chart. Electron shell + local control plane for digital-employee workspaces.", "author": "RoleWeave Contributors", "license": "Apache-2.0", "type": "module", @@ -61,7 +61,8 @@ "check": "npm run build && npm run prepare:updater && npm run test:scripts && npm run typecheck:ui && npm run test:ui && node --test --test-timeout=120000 \"apps/server/dist/test/*.test.js\" && npm run typecheck:renderer && npm run test:renderer && npm run test:desktop-main && npm run security:check", "dev:server": "npm run build && node apps/server/dist/src/index.js", "dev:desktop": "npm run build && npm run build:renderer && npm run prepare:updater && electron apps/desktop/src/main.js", - "typecheck": "tsc -b --force" + "typecheck": "tsc -b --force", + "test:phone-link": "node --test --test-timeout=30000 \"packages/phone-link/test/*.test.mjs\" \"apps/desktop/test/phone-link-host.test.cjs\"" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/phone-link/README.md b/packages/phone-link/README.md new file mode 100644 index 00000000..0606a8f2 --- /dev/null +++ b/packages/phone-link/README.md @@ -0,0 +1,48 @@ +# phone-link.v1 + +手机发一条指令,已经打开的 RoleWeave(或任何实现本协议的宿主)立刻处理。 + +电脑不对外开端口:宿主和手机都**主动连**中继。中继只转发,不跑员工、不长期存正文。 + +## 以后的软件怎么用 + +1. 电脑进程连 `ws(s):///phone-link/host`,首条消息: + + ```json + { "v": 1, "type": "host.hello", "token": "<64-hex host token>" } + ``` + +2. 发 `{ "v": 1, "type": "pair.start" }`,把返回的 6 位 `code` 显示给用户。 + +3. 手机打开产品(手机壳「指令」或 `/command`): + + - 同一产品地址:`POST /phone-link/v1/claim` → `{ "deviceId", "deviceToken" }`(电脑必须已连中继) + - 或 `POST /phone-link/v1/pair` `{ "code": "482910" }`(电脑弹窗里的 6 位码) + + 发现:`GET /phone-link/v1/status` → `{ "schema", "hostOnline", "deviceCount" }` + + 然后连 `ws(s):///phone-link/phone`,首条: + + ```json + { "v": 1, "type": "phone.hello", "deviceToken": "" } + ``` + +4. 提交指令: + + ```json + { "v": 1, "type": "command.submit", "commandId": "", "text": "…", "positionId": "optional-role-id" } + ``` + +5. 状态回推 `command.status`:`accepted` → `running` → `completed` | `failed` | `busy` | `needs_approval`。`completed` 可带 `summary`。 + +发现:`GET /phone-link/v1/protocol` → `{ "schema": "phone-link.v1" }`。 + +## 不变式 + +- 电脑必须已经在跑;宿主掉线则指令失败,不排队。 +- 一个员工同一时刻只接一条(忙则 `busy`)。 +- 危险操作仍在电脑上批准;手机只看到 `needs_approval`。 +- 电脑打开工作区后,宿主推送 `org.snapshot`;手机 `GET /api/mobile/workspace` 读这份快照,不含主机路径。 +- 手机可 `POST /phone-link/v1/revoke` `{ "deviceToken" }` 撤销自己。 +- 配对码 5 分钟作废;设备许可可 `pair.revoke`。 +- 指令正文 ≤ 8 KiB UTF-8。 diff --git a/packages/phone-link/package.json b/packages/phone-link/package.json new file mode 100644 index 00000000..d1a84769 --- /dev/null +++ b/packages/phone-link/package.json @@ -0,0 +1,14 @@ +{ + "name": "@roleweave/phone-link", + "version": "1.0.0", + "description": "phone-link.v1 — phone commands a running RoleWeave host through an outbound relay. Other software can speak the same protocol.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.mjs" + }, + "files": [ + "src", + "README.md" + ] +} diff --git a/packages/phone-link/src/index.mjs b/packages/phone-link/src/index.mjs new file mode 100644 index 00000000..a9a4a125 --- /dev/null +++ b/packages/phone-link/src/index.mjs @@ -0,0 +1,6 @@ +export { + SCHEMA, + PAIR_TTL_MS, + MAX_COMMAND_BYTES, + createPhoneLinkRelay, +} from "./relay.mjs"; diff --git a/packages/phone-link/src/relay.mjs b/packages/phone-link/src/relay.mjs new file mode 100644 index 00000000..db7855d7 --- /dev/null +++ b/packages/phone-link/src/relay.mjs @@ -0,0 +1,275 @@ +import { randomBytes, randomInt } from "node:crypto"; + +export const SCHEMA = "phone-link.v1"; +export const PAIR_TTL_MS = 5 * 60 * 1000; +export const MAX_COMMAND_BYTES = 8 * 1024; +const MAX_DEVICES = 8; + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function newId() { + return randomBytes(16).toString("hex"); +} + +function sixDigitCode() { + return String(randomInt(0, 1_000_000)).padStart(6, "0"); +} + +function send(socket, payload) { + if (socket && typeof socket.send === "function" && socket.readyState !== 3) { + socket.send(JSON.stringify(payload)); + } +} + +export function createPhoneLinkRelay(options = {}) { + const hostToken = options.hostToken; + if (typeof hostToken !== "string" || !/^[a-f0-9]{64}$/.test(hostToken)) { + throw new Error("hostToken must be 64 lowercase hex characters"); + } + const now = options.now ?? (() => Date.now()); + + let hostSocket = null; + const devices = new Map(); + const phones = new Map(); + let pairing = null; + let orgSnapshot = null; + + function hostOnline() { + return hostSocket !== null; + } + + function expirePairing() { + if (pairing && pairing.expiresAt <= now()) pairing = null; + } + + const relay = { + schema: SCHEMA, + + protocol() { + return { schema: SCHEMA }; + }, + + status() { + expirePairing(); + return { + schema: SCHEMA, + hostOnline: hostOnline(), + deviceCount: devices.size, + }; + }, + + issueDevice() { + if (!hostOnline()) { + return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" }; + } + if (devices.size >= MAX_DEVICES) { + return { ok: false, code: "device_limit", message: "too many paired phones" }; + } + const deviceId = newId(); + const deviceToken = randomBytes(32).toString("hex"); + devices.set(deviceToken, { deviceId, createdAt: now() }); + send(hostSocket, { v: 1, type: "pair.completed", deviceId }); + return { ok: true, deviceId, deviceToken }; + }, + + claim() { + return this.issueDevice(); + }, + + connectHost(socket, token) { + if (token !== hostToken) { + send(socket, { v: 1, type: "error", code: "unauthorized", message: "invalid host token" }); + socket.close?.(); + return false; + } + if (hostSocket && hostSocket !== socket) hostSocket.close?.(); + hostSocket = socket; + send(socket, { v: 1, type: "host.accepted", schema: SCHEMA }); + return true; + }, + + disconnectHost(socket) { + if (hostSocket === socket) { + hostSocket = null; + orgSnapshot = null; + } + }, + + publishOrg(snapshot) { + if (!isRecord(snapshot) || !Array.isArray(snapshot.roles)) { + return { ok: false, code: "snapshot_invalid", message: "org snapshot must include roles" }; + } + orgSnapshot = snapshot; + return { ok: true }; + }, + + orgSnapshot() { + return orgSnapshot; + }, + + revokeByToken(deviceToken) { + const device = devices.get(deviceToken); + if (!device) return { ok: false, code: "unauthorized", message: "invalid device token" }; + return this.revoke(device.deviceId); + }, + + startPair() { + if (!hostOnline()) { + return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" }; + } + const code = sixDigitCode(); + pairing = { code, expiresAt: now() + PAIR_TTL_MS }; + const result = { ok: true, code, expiresAt: pairing.expiresAt }; + send(hostSocket, { v: 1, type: "pair.ready", code, expiresAt: pairing.expiresAt }); + return result; + }, + + pair(code) { + expirePairing(); + if (!hostOnline()) { + return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" }; + } + if (typeof code !== "string" || !/^[0-9]{6}$/.test(code) || !pairing || pairing.code !== code) { + return { ok: false, code: "pair_invalid", message: "pairing code is invalid or expired" }; + } + if (devices.size >= MAX_DEVICES) { + return { ok: false, code: "device_limit", message: "too many paired phones" }; + } + pairing = null; + return this.issueDevice(); + }, + + revoke(deviceId) { + for (const [token, device] of devices) { + if (device.deviceId === deviceId) { + devices.delete(token); + const phone = phones.get(token); + if (phone) { + send(phone, { v: 1, type: "error", code: "revoked", message: "device grant revoked" }); + phone.close?.(); + phones.delete(token); + } + return { ok: true, deviceId }; + } + } + return { ok: false, code: "not_found", message: "device not paired" }; + }, + + connectPhone(socket, deviceToken) { + const device = devices.get(deviceToken); + if (!device) { + send(socket, { v: 1, type: "error", code: "unauthorized", message: "invalid device token" }); + socket.close?.(); + return false; + } + const previous = phones.get(deviceToken); + if (previous && previous !== socket) previous.close?.(); + phones.set(deviceToken, socket); + send(socket, { v: 1, type: "phone.accepted", deviceId: device.deviceId, schema: SCHEMA }); + return true; + }, + + disconnectPhone(socket) { + for (const [token, current] of phones) { + if (current === socket) phones.delete(token); + } + }, + + submitCommand(deviceToken, commandId, text, positionId) { + const device = devices.get(deviceToken); + if (!device) return { ok: false, code: "unauthorized", message: "invalid device token" }; + if (!hostOnline()) return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" }; + if (typeof commandId !== "string" || commandId.length < 8 || commandId.length > 128) { + return { ok: false, code: "command_invalid", message: "commandId is invalid" }; + } + if (typeof text !== "string" || text.trim().length === 0) { + return { ok: false, code: "command_invalid", message: "text is required" }; + } + if (Buffer.byteLength(text, "utf8") > MAX_COMMAND_BYTES) { + return { ok: false, code: "command_invalid", message: "text exceeds 8 KiB" }; + } + const payload = { + v: 1, + type: "command.submit", + commandId, + text: text.trim(), + deviceId: device.deviceId, + }; + if (typeof positionId === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(positionId)) { + payload.positionId = positionId; + } + send(hostSocket, payload); + const phone = phones.get(deviceToken); + send(phone, { v: 1, type: "command.status", commandId, state: "accepted" }); + return { ok: true, commandId }; + }, + + publishStatus(commandId, state, summary, deviceId) { + const allowed = new Set(["accepted", "running", "completed", "failed", "busy", "needs_approval"]); + if (!allowed.has(state)) return { ok: false, code: "status_invalid", message: "unknown command state" }; + const message = { + v: 1, + type: "command.status", + commandId, + state, + ...(typeof summary === "string" && summary.length > 0 ? { summary: summary.slice(0, 2000) } : {}), + }; + if (!hostOnline()) return { ok: false, code: "host_offline", message: "RoleWeave host is not connected" }; + for (const [token, device] of devices) { + if (deviceId && device.deviceId !== deviceId) continue; + send(phones.get(token), message); + } + return { ok: true }; + }, + + handleHostMessage(socket, raw) { + if (!isRecord(raw) || raw.v !== 1 || typeof raw.type !== "string") { + send(socket, { v: 1, type: "error", code: "bad_request", message: "invalid message" }); + return; + } + if (raw.type === "host.hello") { + this.connectHost(socket, raw.token); + return; + } + if (hostSocket !== socket) { + send(socket, { v: 1, type: "error", code: "unauthorized", message: "host not accepted" }); + return; + } + if (raw.type === "pair.start") { + const result = this.startPair(); + if (!result.ok) send(socket, { v: 1, type: "error", code: result.code, message: result.message }); + return; + } + if (raw.type === "pair.revoke") { + const result = this.revoke(raw.deviceId); + send(socket, result.ok + ? { v: 1, type: "pair.revoked", deviceId: raw.deviceId } + : { v: 1, type: "error", code: result.code, message: result.message }); + return; + } + if (raw.type === "command.status") { + this.publishStatus(raw.commandId, raw.state, raw.summary, raw.deviceId); + return; + } + if (raw.type === "org.snapshot") { + const result = this.publishOrg(raw.snapshot); + if (!result.ok) send(socket, { v: 1, type: "error", code: result.code, message: result.message }); + } + }, + + handlePhoneMessage(socket, deviceToken, raw) { + if (!isRecord(raw) || raw.v !== 1 || typeof raw.type !== "string") { + send(socket, { v: 1, type: "error", code: "bad_request", message: "invalid message" }); + return; + } + if (raw.type === "command.submit") { + const result = this.submitCommand(deviceToken, raw.commandId, raw.text, raw.positionId); + if (!result.ok) send(socket, { v: 1, type: "error", code: result.code, message: result.message }); + } + }, + }; + + return relay; +} diff --git a/packages/phone-link/test/relay.test.mjs b/packages/phone-link/test/relay.test.mjs new file mode 100644 index 00000000..fc67f293 --- /dev/null +++ b/packages/phone-link/test/relay.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { randomBytes } from "node:crypto"; +import test from "node:test"; +import { SCHEMA, createPhoneLinkRelay } from "../src/index.mjs"; + +function socket() { + const inbox = []; + return { + inbox, + readyState: 1, + send(text) { + inbox.push(JSON.parse(text)); + }, + close() { + this.readyState = 3; + }, + }; +} + +function hostToken() { + return randomBytes(32).toString("hex"); +} + +test("protocol discovery", () => { + const relay = createPhoneLinkRelay({ hostToken: hostToken() }); + assert.deepEqual(relay.protocol(), { schema: SCHEMA }); +}); + +test("rejects a bad host token", () => { + const relay = createPhoneLinkRelay({ hostToken: hostToken() }); + const host = socket(); + assert.equal(relay.connectHost(host, "00".repeat(32)), false); + assert.equal(host.inbox[0].code, "unauthorized"); +}); + +test("host hello uses the constructor token", () => { + const token = hostToken(); + const relay = createPhoneLinkRelay({ hostToken: token }); + const host = socket(); + const phone = socket(); + assert.equal(relay.connectHost(host, token), true); + assert.equal(host.inbox[0].type, "host.accepted"); + const started = relay.startPair(); + assert.equal(started.ok, true); + assert.match(started.code, /^[0-9]{6}$/); + const paired = relay.pair(started.code); + assert.equal(paired.ok, true); + assert.equal(relay.connectPhone(phone, paired.deviceToken), true); + const submitted = relay.submitCommand(paired.deviceToken, "cmd-00000001", "总结今天的 PR"); + assert.equal(submitted.ok, true); + assert.equal(host.inbox.some((m) => m.type === "command.submit" && m.text === "总结今天的 PR"), true); + assert.equal(phone.inbox.some((m) => m.type === "command.status" && m.state === "accepted"), true); + relay.publishStatus("cmd-00000001", "completed", "已处理 2 条 PR", paired.deviceId); + assert.equal(phone.inbox.some((m) => m.state === "completed" && m.summary === "已处理 2 条 PR"), true); +}); + +test("expired or wrong pairing code fails closed", () => { + let t = 1_000; + const token = hostToken(); + const relay = createPhoneLinkRelay({ hostToken: token, now: () => t }); + const host = socket(); + relay.connectHost(host, token); + const started = relay.startPair(); + assert.equal(relay.pair("000000").ok, false); + t += 6 * 60 * 1000; + assert.equal(relay.pair(started.code).ok, false); +}); + +test("commands fail when the host is offline", () => { + const token = hostToken(); + const relay = createPhoneLinkRelay({ hostToken: token }); + const host = socket(); + relay.connectHost(host, token); + const code = relay.startPair().code; + const paired = relay.pair(code); + relay.disconnectHost(host); + const result = relay.submitCommand(paired.deviceToken, "cmd-offline1", "还在吗"); + assert.equal(result.ok, false); + assert.equal(result.code, "host_offline"); +}); + +test("claim pairs without a typed code when the host is online", () => { + const token = hostToken(); + const relay = createPhoneLinkRelay({ hostToken: token }); + const host = socket(); + const phone = socket(); + assert.equal(relay.claim().ok, false); + relay.connectHost(host, token); + const claimed = relay.claim(); + assert.equal(claimed.ok, true); + assert.equal(relay.status().hostOnline, true); + assert.equal(relay.connectPhone(phone, claimed.deviceToken), true); + const submitted = relay.submitCommand(claimed.deviceToken, "cmd-claim01", "合 PR", "issue-researcher"); + assert.equal(submitted.ok, true); + assert.equal(host.inbox.some((m) => m.positionId === "issue-researcher"), true); +}); + +test("host org snapshot is served until the host disconnects", () => { + const token = hostToken(); + const relay = createPhoneLinkRelay({ hostToken: token }); + const host = socket(); + relay.connectHost(host, token); + assert.equal(relay.orgSnapshot(), null); + assert.equal(relay.publishOrg({ name: "live-ws", roles: [{ id: "repo-owner" }] }).ok, true); + assert.equal(relay.orgSnapshot().name, "live-ws"); + relay.disconnectHost(host); + assert.equal(relay.orgSnapshot(), null); +}); + +test("phone can revoke its own grant by token", () => { + const token = hostToken(); + const relay = createPhoneLinkRelay({ hostToken: token }); + const host = socket(); + const phone = socket(); + relay.connectHost(host, token); + const claimed = relay.claim(); + relay.connectPhone(phone, claimed.deviceToken); + assert.equal(relay.revokeByToken("nope").ok, false); + assert.equal(relay.revokeByToken(claimed.deviceToken).ok, true); + assert.equal(relay.submitCommand(claimed.deviceToken, "cmd-after-revoke", "hi").ok, false); +}); + +test("revoke drops the phone grant", () => { + const token = hostToken(); + const relay = createPhoneLinkRelay({ hostToken: token }); + const host = socket(); + const phone = socket(); + relay.connectHost(host, token); + const paired = relay.pair(relay.startPair().code); + relay.connectPhone(phone, paired.deviceToken); + assert.equal(relay.revoke(paired.deviceId).ok, true); + assert.equal(relay.submitCommand(paired.deviceToken, "cmd-revoked1", "hello").ok, false); +}); diff --git a/scripts/test/mobile-surface.test.mjs b/scripts/test/mobile-surface.test.mjs new file mode 100644 index 00000000..fca20b04 --- /dev/null +++ b/scripts/test/mobile-surface.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + choosePlatform, + chooseSurface, + detectPlatform, + isMobileUserAgent, + liveOrgSnapshot, + loadWorkspaceSnapshot, + previewOrgSnapshot, + resolvePublicAsset, +} from "../../deploy/mobile-surface.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const productDir = path.resolve(here, "../.."); +const deployDir = path.join(productDir, "deploy"); +const noVncDir = path.join(deployDir, "node_modules", "@novnc", "novnc"); +const exampleDir = path.join(productDir, "examples", "oss-maintainer"); + +test("splits phone platforms: iOS, Android, HarmonyOS", () => { + assert.equal(detectPlatform("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), "ios"); + assert.equal(detectPlatform("Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)"), "ios"); + assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile"), "android"); + assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 12; HUAWEI) AppleWebKit/537.36 Mobile"), "android"); + assert.equal(detectPlatform("Mozilla/5.0 (Linux; Android 12; HarmonyOS) AppleWebKit/537.36 Mobile"), "harmony"); + assert.equal(detectPlatform("Mozilla/5.0 (Phone; OpenHarmony 5.0) AppleWebKit/537.36 ArkWeb/5.0.0.0"), "harmony"); + assert.equal(detectPlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), "desktop"); +}); + +test("platform query overrides user agent", () => { + assert.equal(choosePlatform({ userAgent: "iPhone", searchParams: new URLSearchParams("platform=android") }), "android"); + assert.equal(choosePlatform({ userAgent: "Macintosh", searchParams: new URLSearchParams("platform=harmony") }), "harmony"); + assert.equal(choosePlatform({ userAgent: "iPhone", searchParams: new URLSearchParams("surface=desktop") }), "desktop"); +}); + +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 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Mobile"), 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); + assert.equal(isMobileUserAgent(""), false); + assert.equal(isMobileUserAgent(undefined), 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"); + assert.equal(chooseSurface({ userAgent: "iPhone", searchParams: new URLSearchParams() }), "mobile"); + assert.equal(chooseSurface({ userAgent: "Macintosh", searchParams: new URLSearchParams() }), "desktop"); +}); + +test("phone home serves the matching platform shell instead of the VNC page", () => { + const ios = resolvePublicAsset("/", { + deployDir, + noVncDir, + userAgent: "Mozilla/5.0 (iPhone)", + searchParams: new URLSearchParams(), + }); + const android = resolvePublicAsset("/", { + deployDir, + noVncDir, + userAgent: "Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile", + searchParams: new URLSearchParams(), + }); + const harmony = resolvePublicAsset("/", { + deployDir, + noVncDir, + userAgent: "Mozilla/5.0 (Phone; OpenHarmony 5.0) ArkWeb/5.0.0.0", + searchParams: new URLSearchParams(), + }); + const desktop = resolvePublicAsset("/", { + deployDir, + noVncDir, + userAgent: "Mozilla/5.0 (Macintosh)", + searchParams: new URLSearchParams(), + }); + assert.equal(ios, path.join(deployDir, "mobile", "ios", "index.html")); + assert.equal(android, path.join(deployDir, "mobile", "android", "index.html")); + assert.equal(harmony, path.join(deployDir, "mobile", "harmony", "index.html")); + assert.equal(desktop, path.join(deployDir, "index.html")); +}); + +test("explicit desktop and platform paths stay available", () => { + assert.equal( + resolvePublicAsset("/desktop", { deployDir, noVncDir, userAgent: "iPhone", searchParams: new URLSearchParams() }), + path.join(deployDir, "index.html"), + ); + assert.equal( + resolvePublicAsset("/ios/app.css", { deployDir, noVncDir, userAgent: "Macintosh", searchParams: new URLSearchParams() }), + path.join(deployDir, "mobile", "ios", "app.css"), + ); + assert.equal( + resolvePublicAsset("/android/app.mjs", { deployDir, noVncDir, userAgent: "iPhone", searchParams: new URLSearchParams() }), + path.join(deployDir, "mobile", "android", "app.mjs"), + ); + assert.equal( + resolvePublicAsset("/harmony", { deployDir, noVncDir, userAgent: "iPhone", searchParams: new URLSearchParams() }), + path.join(deployDir, "mobile", "harmony", "index.html"), + ); +}); + +test("workspace snapshot lists oss-maintainer roles without leaking host paths", () => { + const snapshot = loadWorkspaceSnapshot(exampleDir); + assert.equal(snapshot.name, "oss-maintainer"); + assert.equal(snapshot.owner, "repo-owner"); + const ids = snapshot.roles.map((role) => role.id); + assert.deepEqual(ids.sort(), ["community-operator", "issue-researcher", "release-engineer", "repo-owner"]); + const owner = snapshot.roles.find((role) => role.id === "repo-owner"); + assert.equal(owner.name, "仓库负责人"); + assert.match(owner.skillExcerpt, /路线图/); + assert.equal(owner.reportTo, null); + assert.equal(typeof owner.budget.perTask.tokens, "number"); + assert.equal(JSON.stringify(snapshot).includes("/workspace/positions"), false); +}); + +test("live snapshot refuses to embed the workspace path", () => { + const snapshot = loadWorkspaceSnapshot(exampleDir); + const live = liveOrgSnapshot(snapshot, exampleDir); + assert.equal(live.source, "live"); + assert.equal(JSON.stringify(live).includes(exampleDir), false); + assert.throws(() => liveOrgSnapshot({ name: exampleDir, roles: [] }, exampleDir), /snapshot_leaks_path/); + assert.equal(previewOrgSnapshot(snapshot).source, "preview"); +}); + +test("snapshot refuses a directory that is not a workspace", () => { + const root = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), "roleweave-mobile-")); + try { + assert.throws(() => loadWorkspaceSnapshot(root), /workspace/); + } finally { + fs.rmSync(root, { force: true, recursive: true }); + } +}); diff --git a/scripts/test/mobile-web-server.test.mjs b/scripts/test/mobile-web-server.test.mjs new file mode 100644 index 00000000..fb206e4f --- /dev/null +++ b/scripts/test/mobile-web-server.test.mjs @@ -0,0 +1,104 @@ +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"; + +const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + +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) => { + if (error) reject(error); + else resolve(address.port); + }); + }); + server.on("error", reject); + }); +} + +async function startWebServer(t) { + const port = await unusedPort(); + const vncPort = await unusedPort(); + const child = spawn(process.execPath, [path.join(productDir, "deploy", "web-server.mjs"), String(port), String(vncPort)], { + cwd: productDir, + stdio: ["ignore", "pipe", "pipe"], + }); + t.after(() => { + child.kill("SIGTERM"); + }); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("web server did not start")), 8000); + let stdout = ""; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + if (stdout.includes("listening")) { + clearTimeout(timeout); + resolve(); + } + }); + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + if (chunk.includes("Error")) { + clearTimeout(timeout); + reject(new Error(chunk)); + } + }); + child.on("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`web server exited ${code}`)); + }); + }); + return port; +} + +async function request(port, pathname, headers = {}) { + const response = await fetch(`http://127.0.0.1:${port}${pathname}`, { headers }); + const text = await response.text(); + return { status: response.status, text, type: response.headers.get("content-type") }; +} + +test("phone user agents receive the matching platform shell at /", async (t) => { + const port = await startWebServer(t); + const ios = await request(port, "/", { "user-agent": "Mozilla/5.0 (iPhone) Mobile" }); + assert.equal(ios.status, 200); + assert.match(ios.text, /data-platform="ios"/); + assert.doesNotMatch(ios.text, /正在连接 RoleWeave/); + + const android = await request(port, "/", { "user-agent": "Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile" }); + assert.equal(android.status, 200); + assert.match(android.text, /data-platform="android"/); + + const harmony = await request(port, "/", { "user-agent": "Mozilla/5.0 (Phone; OpenHarmony 5.0) ArkWeb/5.0.0.0" }); + assert.equal(harmony.status, 200); + assert.match(harmony.text, /data-platform="harmony"/); + + const desktop = await request(port, "/", { "user-agent": "Mozilla/5.0 (Macintosh)" }); + assert.equal(desktop.status, 200); + assert.match(desktop.text, /正在连接 RoleWeave/); +}); + +test("phone-link status is discoverable without a host token", async (t) => { + const port = await startWebServer(t); + const response = await request(port, "/phone-link/v1/status"); + assert.equal(response.status, 200); + const body = JSON.parse(response.text); + assert.equal(body.schema, "phone-link.v1"); + assert.equal(body.hostOnline, false); +}); + +test("mobile workspace API returns the example org", async (t) => { + const port = await startWebServer(t); + const response = await request(port, "/api/mobile/workspace"); + assert.equal(response.status, 200); + const body = JSON.parse(response.text); + assert.equal(body.name, "oss-maintainer"); + assert.equal(body.source, "preview"); + assert.equal(body.roles.length, 4); + assert.equal(body.roles.some((role) => role.id === "repo-owner"), true); +}); diff --git a/start.sh b/start.sh new file mode 100755 index 00000000..e9ee64b0 --- /dev/null +++ b/start.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +: "${PORT:?ASTEAM must provide PORT}" +case "$PORT" in + ''|*[!0-9]*) + printf 'RoleWeave: PORT must be numeric\n' >&2 + exit 64 + ;; +esac + +product_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +runtime_dir="$product_dir/.runtime" +native_runtime="$product_dir/vendor/runtime" +display_number="$((100 + PORT % 1000))" +vnc_port="$((20000 + PORT % 20000))" +display=":$display_number" +runtime_libs="$native_runtime/lib/x86_64-linux-gnu:$native_runtime/usr/lib/x86_64-linux-gnu" + +required_files=( + "$product_dir/node_modules/electron/dist/electron" + "$product_dir/deploy/node_modules/@novnc/novnc/core/rfb.js" + "$product_dir/deploy/node_modules/ws/index.js" + "$product_dir/deploy/mobile/ios/index.html" + "$product_dir/deploy/mobile/android/index.html" + "$product_dir/deploy/mobile/harmony/index.html" + "$product_dir/deploy/mobile-surface.mjs" + "$product_dir/apps/server/dist/src/index.js" + "$product_dir/apps/desktop/dist/renderer/index.html" + "$product_dir/apps/desktop/src/vendor/electron-updater.cjs" + "$native_runtime/usr/bin/x11vnc" +) +for required_file in "${required_files[@]}"; do + if [[ ! -e "$required_file" ]]; then + printf 'RoleWeave: deployment is not built; missing %s\n' "$required_file" >&2 + printf 'Run bash scripts/build-deployment.sh from the product directory.\n' >&2 + exit 69 + fi +done + +mkdir -p "$runtime_dir/user-data" "$runtime_dir/logs" + +export ROLEWEAVE_PHONE_LINK_PORT="$PORT" +if [[ -z "${ROLEWEAVE_PHONE_LINK_HOST_TOKEN:-}" ]]; then + ROLEWEAVE_PHONE_LINK_HOST_TOKEN="$(node -e 'process.stdout.write(require("crypto").randomBytes(32).toString("hex"))')" + export ROLEWEAVE_PHONE_LINK_HOST_TOKEN +fi + +child_pids=() +electron_pid="" +cleanup() { + local pid + trap - EXIT INT TERM HUP + if [[ -n "$electron_pid" ]]; then + kill -TERM -- "-$electron_pid" 2>/dev/null || true + fi + for pid in "${child_pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null || true +} +trap cleanup EXIT INT TERM HUP + +export DISPLAY="$display" +export XDG_RUNTIME_DIR="$runtime_dir/xdg-$PORT" +if [[ -z "${ROLEWEAVE_DEFAULT_WORKSPACE:-}" && -f "$product_dir/examples/oss-maintainer/workspace.json" ]]; then + export ROLEWEAVE_DEFAULT_WORKSPACE="$product_dir/examples/oss-maintainer" +fi +export ELECTRON_OZONE_PLATFORM_HINT=x11 +export LD_LIBRARY_PATH="$runtime_libs${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" +export GSETTINGS_SCHEMA_DIR="$native_runtime/usr/share/glib-2.0/schemas" +export XDG_DATA_DIRS="$native_runtime/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}:/usr/local/share:/usr/share" +mkdir -p "$XDG_RUNTIME_DIR" +chmod 700 "$XDG_RUNTIME_DIR" + +Xvfb "$display" -screen 0 1240x800x24 -nolisten tcp -ac \ + >"$runtime_dir/logs/xvfb.log" 2>&1 & +child_pids+=("$!") + +for _ in $(seq 1 100); do + [[ -S "/tmp/.X11-unix/X$display_number" ]] && break + sleep 0.05 +done +if [[ ! -S "/tmp/.X11-unix/X$display_number" ]]; then + printf 'RoleWeave: X display failed to start\n' >&2 + exit 70 +fi + +"$native_runtime/usr/bin/x11vnc" -display "$display" -forever -shared \ + -nopw -localhost -rfbport "$vnc_port" -noxdamage -quiet \ + >"$runtime_dir/logs/x11vnc.log" 2>&1 & +child_pids+=("$!") + +node "$product_dir/deploy/web-server.mjs" "$PORT" "$vnc_port" \ + >"$runtime_dir/logs/web-server.log" 2>&1 & +child_pids+=("$!") + +cd "$product_dir" +setsid "$product_dir/node_modules/electron/dist/electron" \ + --no-sandbox \ + --disable-gpu \ + --disable-dev-shm-usage \ + --user-data-dir="$runtime_dir/user-data" \ + apps/desktop \ + >"$runtime_dir/logs/electron.log" 2>&1 & +electron_pid="$!" +child_pids+=("$electron_pid") + +wait "$electron_pid" From 5e5ce0aa2b4e167496b0f468bfa1741d169ca4ab 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: Sat, 19 Sep 2026 06:11:20 +0000 Subject: [PATCH 2/3] fix(mobile): lock @roleweave/phone-link and drop duplicate phone shell - npm install --package-lock-only so CI sees the new workspace package - generic deploy/mobile/app.mjs is a one-line import of /shared/shell.mjs - drop resolvePublicAsset routes for missing org.mjs / command.mjs - merge the extra Unreleased ### Added heading Leave /claim unauthenticated until product decides. --- CHANGELOG.md | 3 - deploy/mobile-surface.mjs | 2 - deploy/mobile/app.mjs | 285 +------------------------------------- package-lock.json | 9 ++ 4 files changed, 10 insertions(+), 289 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 829ae0e5..ed8d409a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,6 @@ - 手机壳与 phone-link.v1:手机打开产品为原生壳(组织/指令/桌面/设置);一键连接电脑发指令;组织跟随电脑当前工作区;设置可断开配对。托管入口见 deploy/ 与 start.sh。 - -### Added - - #328 R2:新增 `semantic-runtime.v1alpha1` 合同切片(BusinessObjectRef / EvidenceRef / DecisionRecord / ActionProposal / ExecutionReceipt)与 Ontology Runtime 术语表;github-ops 示例给出只读分析与可写 squash-merge 轨迹;纯函数测试覆盖「未批准不可执行、非法过期时间 fail-closed、幂等重试绑定目标版本、目标版本失效、运行前状态不可直接失败、indeterminate 不能变成 succeeded」。不是 live GitHub 执行,也不是 Sales Workbench 核心词汇。设计说明见 `docs/design/ontology-runtime-r2.md`。 - #309 后续:控制面拒绝超大请求体时,drain 或读取被中止(2 秒截止、10 MiB drain 上限、对端断开)会向 stderr 写一行原因与字节数,现场 EPIPE / 连接复位事故从此可归因;同步 docs/api-contract-v0.md:1 MiB 上限补记先读后拒行为、drain 上限与截止、server requestTimeout / headersTimeout,以及拒绝响应携带 Connection: close。附测试:stall 的超大上传在 drain 与 read 两条路径都断言中止行。 diff --git a/deploy/mobile-surface.mjs b/deploy/mobile-surface.mjs index a6e035c9..6a668e25 100644 --- a/deploy/mobile-surface.mjs +++ b/deploy/mobile-surface.mjs @@ -58,8 +58,6 @@ export function resolvePublicAsset(urlPath, { deployDir, noVncDir, userAgent, se if (urlPath === "/ios" || urlPath === "/ios/") return path.join(deployDir, "mobile", "ios", "index.html"); if (urlPath === "/android" || urlPath === "/android/") return path.join(deployDir, "mobile", "android", "index.html"); if (urlPath === "/harmony" || urlPath === "/harmony/") return path.join(deployDir, "mobile", "harmony", "index.html"); - if (urlPath === "/shared/org.mjs") return path.join(deployDir, "mobile", "shared", "org.mjs"); - if (urlPath === "/shared/command.mjs") return path.join(deployDir, "mobile", "shared", "command.mjs"); if (urlPath === "/shared/shell.mjs") return path.join(deployDir, "mobile", "shared", "shell.mjs"); if (urlPath === "/mobile/app.css") return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "app.css"); if (urlPath === "/mobile/app.mjs") return path.join(phoneDir(deployDir, platform === "desktop" ? "android" : platform), "app.mjs"); diff --git a/deploy/mobile/app.mjs b/deploy/mobile/app.mjs index 4b9b6b0c..04023123 100644 --- a/deploy/mobile/app.mjs +++ b/deploy/mobile/app.mjs @@ -1,284 +1 @@ -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 claimForm = document.getElementById("claim-form"); -const hostStatus = document.getElementById("host-status"); -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 describeSource(snapshot) { - if (snapshot.source === "live") return "电脑当前工作区"; - if (snapshot.source === "closed") return "电脑未打开工作区"; - return "示例预览,电脑打开工作区后会换成当前组织"; -} - -function setDeviceState(connected) { - const state = document.getElementById("device-state"); - const revoke = document.getElementById("revoke"); - if (state) state.textContent = connected ? "已连接" : "未连接"; - if (revoke) revoke.hidden = !connected; -} - -function renderRoles(snapshot) { - const sourceEl = document.getElementById("org-source"); - if (sourceEl) sourceEl.textContent = describeSource(snapshot); - if (snapshot.source === "closed") { - workspaceName.textContent = "未打开工作区"; - orgLead.textContent = "在电脑上打开一个工作区后,这里会列出当前员工。"; - orgLead.classList.remove("error"); - roleList.replaceChildren(); - roleDetail.hidden = true; - return; - } - workspaceName.textContent = snapshot.name; - orgLead.textContent = snapshot.source === "live" - ? (snapshot.description || "这是电脑上正在打开的工作区。") - : (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; - claimForm.hidden = true; - commandForm.hidden = false; - setDeviceState(true); - 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; - claimForm.hidden = false; - commandForm.hidden = true; - setDeviceState(false); - } - setCommandStatus(message.message ?? "出错了"); - } - }); - socket.addEventListener("close", () => { - if (deviceToken) setCommandStatus("连接断开,正在重试…"); - }); -} - -async function acceptGrant(body) { - if (!body?.deviceToken) return false; - deviceToken = body.deviceToken; - localStorage.setItem(storageKey, deviceToken); - connect(); - return true; -} - -async function refreshHostStatus() { - try { - const response = await fetch("/phone-link/v1/status", { headers: { accept: "application/json" } }); - const body = await response.json(); - if (body.hostOnline) { - hostStatus.textContent = "电脑在线,可以连接。"; - hostStatus.classList.remove("error"); - } else { - hostStatus.textContent = "电脑还没连上。先打开 RoleWeave 桌面。"; - hostStatus.classList.add("error"); - } - } catch { - hostStatus.textContent = "暂时读不到电脑状态。"; - hostStatus.classList.add("error"); - } -} - -claimForm.addEventListener("submit", async (event) => { - event.preventDefault(); - setCommandStatus("正在连接电脑…"); - const response = await fetch("/phone-link/v1/claim", { method: "POST" }); - const body = await response.json(); - if (!response.ok || !(await acceptGrant(body))) { - setCommandStatus(body.message ?? "连接失败"); - } -}); - -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 || !(await acceptGrant(body))) { - setCommandStatus(body.message ?? "配对失败"); - } -}); - -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, - ...(selectedRoleId ? { positionId: selectedRoleId } : {}), - })); - setCommandStatus("已发出,等电脑受理…"); -}); - -document.querySelector("nav[aria-label='主要功能']")?.addEventListener("click", (event) => { - const tab = event.target.closest("[data-tab]"); - if (tab) selectTab(tab.dataset.tab); -}); - -document.getElementById("revoke")?.addEventListener("click", async () => { - if (!deviceToken) return; - const response = await fetch("/phone-link/v1/revoke", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ deviceToken }), - }); - localStorage.removeItem(storageKey); - deviceToken = null; - socket?.close(); - pairForm.hidden = false; - claimForm.hidden = false; - commandForm.hidden = true; - setDeviceState(false); - setCommandStatus(response.ok ? "已断开这台手机。" : "已在本机退出,电脑侧可能仍需确认。"); -}); - -if (deviceToken) connect(); -else setDeviceState(false); -void loadWorkspace(); -void refreshHostStatus(); -setInterval(() => { - void refreshHostStatus(); - void loadWorkspace(); -}, 4000); +import "/shared/shell.mjs"; diff --git a/package-lock.json b/package-lock.json index 20a6ec80..89e92a44 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2386,6 +2386,10 @@ "resolved": "apps/desktop", "link": true }, + "node_modules/@roleweave/phone-link": { + "resolved": "packages/phone-link", + "link": true + }, "node_modules/@roleweave/server": { "resolved": "apps/server", "link": true @@ -9195,6 +9199,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "packages/phone-link": { + "name": "@roleweave/phone-link", + "version": "1.0.0", + "license": "Apache-2.0" + }, "packages/shared": { "name": "@roleweave/shared", "version": "0.0.0", From 4490118c652b8acd238a1f44753e68ef09ab7385 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: Sat, 19 Sep 2026 06:19:38 +0000 Subject: [PATCH 3/3] chore(mobile): touch app.mjs so hosted CI sees 5e5ce0aa follow-up