From 8e00147d06ae2cebf4bb74f66ec1ee5fc4bdcaed Mon Sep 17 00:00:00 2001 From: Sycun <165354365+Sycun@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:44:42 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=89=8B=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E4=BD=BF=E7=94=A8ui=E7=9A=84=E4=BD=93=E9=AA=8C=E5=92=8C?= =?UTF-8?q?=E6=95=B4=E4=BD=93=E4=BC=98=E5=8C=96=20(#344)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: integrate the mobile UI runtime layer from the migration package 迁移包给出的移动端改造是「运行时注入」架构:mobile.css 只写 767.98px 以下的 规则,mobile-ui.js 在末位加载并包装 switchPage、注入抽屉/底部标签栏/更多面板。 模板侧只有 4 处接线(viewport-fit + interactive-widget、样式层链接、ELK/SheetJS 移动端延迟加载、交互层末位脚本),其余保持上游原样,桌面端零影响。 * feat: 移动端注入层接入 i18n,并把触控下限统一到 44px 迁移包的交互层把标签栏、抽屉、更多面板的文案全写死成中文,en-US/ru-RU 下会中英 混杂;现在所有注入节点统一挂 data-i18n,交给 i18n.js 的 applyTranslations 在语言 切换时自动重译,本层只保留中文兜底。语言项改为按桌面端下拉里真实存在的语言循环 (原来只在 zh/en 之间来回,俄语用户点不到),面板里的 API 文档/GitHub 不再抄一份 URL,改为触发桌面端既有控件;iOS 上没有全屏 API 时给出提示而不是静默无反应; 不在四个标签里的页面点亮"更多",否则三分之一的页面底部栏无任何高亮。 同时补齐 PWA manifest、api-docs 页移动端样式层,并把通知铃铛与头像按钮从 38px 提到本层自己的 --m-touch(44px) 口径。配 web/static/js/mobile-ui-i18n.test.cjs: 键集三方一致、引用键必须三语都有译文、helper 区之外不许出现裸写文案的 API。 * style: 移动端外壳改 iOS 观感,并修掉抽屉页脚错位 观感:顶栏/底栏改透出材质+发丝分隔线,图标按钮一律正圆去底框,文字按钮一律胶囊, 卡片 18、面板顶角 22,按压给 scale 反馈并在 prefers-reduced-motion 下关掉;焦点环 从 2px 硬描边换成 3px 柔光环(forced-colors 下把硬描边还回去)。材质一律用 color-mix 从主题自己的 --bg-primary/--border-color 派生,浅色暗色都跟随。 两处不是"不好看"而是真错: - 抽屉页脚被 insertBefore 插到菜单上面,"返回顶部"出现在列表上方; - 顶栏材质在暗色下被 style.css 的 html[data-theme="dark"] header 不透明渐变压住, 只写 header 选择器不生效,改成同特异性后暗色才真正透出。 圆角与胶囊走 --m-r-icon/--m-r-btn 令牌(24 节那批 !important 规则读的就是它们), 不再往下补选择器与 !important 对轰。 配 scripts/mobile-shot.mjs:手机视口下抓外壳图,--why 用 CDP 匹配样式表回答 "规则写了没生效是没命中还是被谁压住",--eval 量真实计算值。 * fix: 抽屉页脚与底部固定件被 Safari 工具栏压在后面 iOS 的 100dvh 是「工具栏收起时」的最大视口高度,工具栏展开时可见高度只有 innerHeight。两个全屏抽屉写死 height:100dvh,于是页脚那一截正好落在 Safari 底栏后面;bottom:0 的底栏与贴底面板同理。现在抽屉改用 mobile-ui.js 每次 resize 重写的 --m-app-height,贴底固定件按 max(0px, 100dvh - 可见高度) 抬升;不支持 dvh 或桌面端抬升量恒为 0,实测默认态底栏/抽屉位置零位移。 * fix: 去掉底部标签栏里重复的「更多」入口 顶栏的 ··· 与标签栏的「更多」打开的是同一个面板。按一个入口一份实现收掉底部那枚, 标签栏只留四个页面跳转;随之清掉 more2 图标、mobile.tabMore 键与门禁里的点击断言。 落点在四个标签之外的页面不再高亮任何标签——没有「更多」可点了。 * fix: 清掉另外两处重复入口,并把同类视口缺陷扫干净 顺着「底部更多与顶栏 ··· 重复」这条线扫了一遍,还有两处同类: - 面板里的「退出登录」与头像菜单里的退出是同一个动作两个入口,头像菜单是桌面端 一贯的位置,去掉面板那枚;连带删掉只有它能触发的 m-danger 样式与 logout 图标。 - 抽屉页脚的「返回顶部」与面板里的返回顶部是同一个动作两个入口,且抽屉是导航容器, 动作按钮不该住在里面;去掉整个页脚(那句"移动端导航"文案也没有信息量)。 同类视口缺陷两处: - #m-toast 的 bottom 只算到 tabbar-h + safe,底栏按 --m-bar-bottom 抬升时提示条不抬, 工具栏展开时会压在底栏上;实测补前后间距 16px -> 16px。 - api-docs 页容器写 min-height:100dvh,恒定比可见区高出一截,最后一行落在 Safari 底栏后面;改成 100svh(最小视口)。 i18n 契约测试同步:header.logout / mobile.navFooter 不再被引用,mobile 命名空间 18 键。 * style: 手机端对齐 new-api 的卡片密度与 iOS 的填充式表单 仪表盘那排 KPI 在 ≤480px 被桌面样式拍成一列 116px 高的长条,手机上要滚两屏才看完 四个数字。收成两列紧凑网格(奇数张末卡通栏不留空洞),数字 26px/600 + tabular-nums 做主角,标签 12px 弱化,图标块 28px。卡片表面统一成发丝边 + 16 圆角 + 几乎看不见的 投影——桌面的双层阴影在小屏上一屏四张会散。表单改 iOS 填充式(靠底色区分字段), 聚焦只留一圈柔光环(登录框实测叠过两圈),登录卡 20 圆角 + 满宽胶囊主按钮。 KPI 按 nth-child 分的淡彩渐变是产品自己的身份,这一节不跟它抢。 * fix: 补生产侧俄语、修掉白底白字的提示条,并把逐按钮门禁跑到 PASS 用户报的两件事: - 面板里「切换语言」只在中英之间跳 —— 不是逻辑问题,是 ~/csai-生产版 缺俄语资产 (模板没有 ru-RU 选项、i18n/ 只有两个 json、i18n.js 是不认 ru 的旧版)。补齐后 真手指连点验通 中→英→俄→中,且该行文字自身跟着换。 - 点面板按钮时屏幕中间冒出一块白的 —— 是我自己造成的:25.6 把提示条改成浅色材质, 字色还是基底的 #fff,等于白底白字。回到深色 HUD 胶囊,只留一层投影。 逐按钮门禁(需要登录态,本轮首次跑通全量 34 个界面 = 31 页 + 抽屉/面板/会话抽屉展开态): - 硬缺陷 covered/offscreen/zero/pointer-events 全 0;11 条外壳真手指点击全过; - 小于 44px 从 79 降到 3,残留是桌面密集表格里的控件,钉进基线只降不升; - 修掉的真缺陷:子菜单项 42px(本层自己漏的下限)、.ai-channel-manager 的 overflow:hidden 把 4 个通道选择按钮裁到滚不进来、会话抽屉里搜索框 42/分组按钮 24 宽/ 项目行 38/折叠头 30、工具条里 只有 21px 高(这些链接既没 href 也没 onclick 属性可依赖,判据要放宽到 );复选框口径统一到 24.11 一处并放大到 30px。 - 门禁自己的两个错也修了:scrollIntoView 在「外层 hidden + 内层 auto」的嵌套里根本不滚, 造成 59 个 offscreen 假红,改成逐层滚可滚祖先模拟手指;touch+mouse 双发会让一次点击 变成两次(抽屉开了又关),只发 touch。 - 退出码口径写死:够不到/遮挡/零尺寸/pointer-events 与自家外壳必须 0,桌面遗留小命中区 走基线;基线被压低时注入探针验红(RATCHET FAIL ×2)后撤销复验绿。 * style: 手机端玻璃质感升级,并补上对话上传的安卓适配 观感目标从"iOS 化的平面灰底"升到玻璃质感。平底上做半透明无物可透,玻璃读不出来, 所以先造画布再上材质(mobile.css 第 28 节): - .container 铺环境光(浅色=近白底+右上 accent/左下紫柔光,暗色=深底+更亮柔光), .content-area / .chat-messages 交出实心底色,半透明表面从此有色可透; - 模糊只给小面积/瞬时件:顶栏、底栏、底部面板、抽屉、登录卡、弹窗;常驻卡片 只做半透明+发丝边+顶缘高光,不挂模糊——固定在滚动内容上的大面积模糊是掉帧主因 (25 节实测口径不变);浅深色全用 color-mix 从主题令牌派生,只有顶缘高光按主题 换向;prefers-reduced-transparency 与不支持模糊的环境退回实心。 - index.html 的 mobile.css 版本号递增,防 iPhone Safari 吃旧缓存。 安卓适配:对话上传的 accept="*" 不是合法 MIME 通配(各浏览器/WebView 解析不一致), 改为 "*/*";mobile-tap-audit.mjs 新增 --ua=iphone|android,同一套门禁可换安卓 UA 复跑 (壳层按视口宽度判定、与 UA 无关,换 UA 是给"安卓浏览器下同样合格"留可复现证据)。 验证(都在生产版文件夹跑):iPhone/Android 双 UA 端到端上传(按钮 44px → 触达 选择器 → POST /api/chat-uploads 200 落盘 → 附件条渲染)PASS;双 UA 全量门禁各 34 页 PASS(硬缺陷 0、点击失败 0、44px 基线无回潮)。 --- scripts/mobile-shot.mjs | 184 ++ scripts/mobile-tap-audit.mjs | 460 ++++ scripts/mobile-tap-baseline.json | 1 + web/static/css/api-docs-mobile.css | 301 ++ web/static/css/mobile.css | 3628 +++++++++++++++++++++++++ web/static/i18n/en-US.json | 20 + web/static/i18n/ru-RU.json | 20 + web/static/i18n/zh-CN.json | 20 + web/static/js/mobile-ui-i18n.test.cjs | 87 + web/static/js/mobile-ui.js | 1270 +++++++++ web/static/site.webmanifest | 14 + web/templates/api-docs.html | 6 +- web/templates/index.html | 46 +- 13 files changed, 6050 insertions(+), 7 deletions(-) create mode 100644 scripts/mobile-shot.mjs create mode 100644 scripts/mobile-tap-audit.mjs create mode 100644 scripts/mobile-tap-baseline.json create mode 100644 web/static/css/api-docs-mobile.css create mode 100644 web/static/css/mobile.css create mode 100644 web/static/js/mobile-ui-i18n.test.cjs create mode 100644 web/static/js/mobile-ui.js create mode 100644 web/static/site.webmanifest diff --git a/scripts/mobile-shot.mjs b/scripts/mobile-shot.mjs new file mode 100644 index 00000000..fcd82b0f --- /dev/null +++ b/scripts/mobile-shot.mjs @@ -0,0 +1,184 @@ +/* + * CyberStrikeAI — mobile shell screenshots. + * + * Captures the injected mobile chrome (appbar / tab bar / drawer / bottom sheet) + * at a phone viewport without needing a login: the chrome is injected by + * mobile-ui.js boot() purely from the viewport width, so hiding the login + * overlay is enough to make syncAuthChrome re-show the tab bar. Data-driven page + * bodies stay empty — this is for judging the *shell*, not the content. + * + * node scripts/mobile-shot.mjs --state=plain --out=/tmp/shot.png + * node scripts/mobile-shot.mjs --state=drawer --base=http://127.0.0.1:8088 + * states: login | plain | drawer | sheet | chat + */ +import fs from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const argv = Object.fromEntries(process.argv.slice(2).map(a => { + const i = a.indexOf('='); + return i === -1 ? [a.replace(/^--/, ''), '1'] : [a.slice(2, i), a.slice(i + 1)]; +})); +const W = Number(argv.width || 390), H = Number(argv.height || 844); +const PORT = argv.port || '9713'; +const BASE = (argv.base || 'http://127.0.0.1:8088').replace(/\/$/, ''); +const STATE = argv.state || 'plain'; +const OUT = argv.out || `/tmp/cs-shot/${STATE}-${W}x${H}.png`; + +fs.mkdirSync(dirnameOf(OUT), { recursive: true }); +function dirnameOf(p) { const i = p.lastIndexOf('/'); return p.slice(0, i) || '.'; } + +const CH = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const chrome = await import('node:child_process').then(m => + m.spawn(CH, ['--headless=new', `--remote-debugging-port=${PORT}`, + `--user-data-dir=/tmp/cs-shot-${PORT}`, '--no-sandbox', 'about:blank'], { stdio: 'ignore' })); +process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* gone */ } }); + +let ws; +for (let i = 0; i < 80; i++) { + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); + ws = new WebSocket((await r.json()).webSocketDebuggerUrl); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; setTimeout(rej, 4000); }); + break; + } catch { await sleep(300); } +} +if (!ws) { console.error('chrome devtools unreachable'); process.exit(1); } + +let idc = 0; const pending = new Map(); +ws.onmessage = e => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + const { res, rej } = pending.get(m.id); pending.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } +}; +const send = (method, params = {}, sessionId) => new Promise((res, rej) => { + const id = ++idc; pending.set(id, { res, rej }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); +}); + +const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); +for (const m of ['Page.enable', 'Runtime.enable']) await send(m, {}, sessionId); +await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId); +await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId); +await send('Network.setUserAgentOverride', { + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1' +}, sessionId); +const ev = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }, sessionId); + if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; + return r.result?.value; +}; + +/* 主题要先落进本站点的 localStorage,about:blank 上写是写给另一个 origin。 + 换主题用 Page.navigate 重新进,不要在 eval 里 location.reload()——awaitPromise + 会等一个永远不回来的执行上下文。 */ +await send('Page.navigate', { url: BASE + '/' }, sessionId); +await sleep(1500); +const theme = argv.theme || (argv.dark === '1' ? 'dark' : ''); +if (theme) { + await ev(`localStorage.setItem('cyberstrike-theme', ${JSON.stringify(theme)})`); + await send('Page.navigate', { url: BASE + '/' }, sessionId); +} +await sleep(3000); + +if (STATE !== 'login') { + /* 藏掉登录遮罩会触发 mobile-ui.js 的 MutationObserver,把底部栏显示回来 */ + await ev(`document.getElementById('login-overlay').style.display='none'`); + await sleep(600); + if (STATE === 'drawer') await ev(`window.CSAMobile && CSAMobile.openDrawer()`); + if (STATE === 'sheet') await ev(`window.CSAMobile && CSAMobile.openSheet()`); + if (STATE === 'chat') { await ev(`window.switchPage && switchPage('chat')`); await sleep(1500); } + await sleep(1200); +} + +const state = await ev(`(()=>({ + booted: !!window.CSAMobile, + tabbar: !!document.querySelector('#m-tabbar') && getComputedStyle(document.querySelector('#m-tabbar')).display, + sheet: !!document.querySelector('#m-sheet'), + drawerOpen: !!document.querySelector('#main-sidebar.m-open'), + sheetOpen: !!document.querySelector('#m-sheet.m-show'), + lang: document.documentElement.getAttribute('lang') +}))()`); +console.log(JSON.stringify(state)); + +/* 带凭据时真的登进去:未登录时对话/列表页的容器根本不渲染,看不到真东西。 + 凭据同样只从参数或环境变量来,绝不写进文件。 */ +const USER = argv.user || process.env.CSAI_AUDIT_USER || ''; +const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || ''; +const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || ''; +if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: new URL(BASE).hostname, path: '/' }, sessionId); +if (USER && PASS) { + await ev(`(()=>{const u=document.getElementById('login-username'),p=document.getElementById('login-password');if(u&&p){u.value=${JSON.stringify(USER)};p.value=${JSON.stringify(PASS)};document.getElementById('login-form').requestSubmit();}})()`); + await sleep(7000); + console.log('logged in:', JSON.stringify(await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`))); +} +if (argv.route) { await ev(`location.hash=${JSON.stringify(argv.route)}`); await sleep(3000); } + +/* --eval='...' 用来在同一个已登录/已注入的页面上量一把真实计算样式 */ +if (argv.eval) { + const probe = await ev(`(function(){${argv.eval}})()`); + console.log(JSON.stringify(probe, null, 2)); +} + +/* --why='.notification-btn' --prop=border-radius + 规则写了却不生效时,用 CDP 的匹配样式表分清:没命中,还是被更高优先级压住。 */ +if (argv.why) { + await send('DOM.enable', {}, sessionId); + await send('CSS.enable', {}, sessionId); + const { root } = await send('DOM.getDocument', { depth: -1 }, sessionId); + const { nodeId } = await send('DOM.querySelector', { nodeId: root.nodeId, selector: argv.why }, sessionId); + if (!nodeId) { console.log('selector 未命中任何节点:', argv.why); } + else { + const matched = await send('CSS.getMatchedStylesForNode', { nodeId }, sessionId); + const want = (argv.prop || 'border-radius,border,background').split(','); + const rows = []; + for (const e of matched.matchedCSSRules || []) { + const m = e.rule; + const decl = (m.style.cssProperties || []).filter(p => want.includes(p.name) && !p.disabled && p.value); + if (!decl.length) continue; + const sel = (m.selectorList && m.selectorList.text) || '?'; + rows.push({ + origin: m.origin, + sheet: (m.style.styleSheetId || '').split('@').pop(), + selector: sel.length > 78 ? sel.slice(0, 78) + '…' : sel, + decls: decl.map(d => `${d.name}:${d.value}${d.important ? ' !important' : ''}`), + }); + } + console.log(`== ${argv.why} 上命中且声明了 [${want.join(', ')}] 的规则,按级联顺序(后=更优先)==`); + rows.forEach((r, i) => console.log(`${String(i).padStart(2)} ${r.origin.padEnd(10)} ${r.decls.join(' | ')}\n ${r.selector}`)); + const cs = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.why)});const c=getComputedStyle(e);return JSON.stringify(${JSON.stringify(want)}.reduce((o,k)=>(o[k]=c[k],o),{}),null,1)})()`); + console.log('最终计算值:', cs); + } +} + +/* --tap='SEL' 用真手指(只发 touch)点一个选择器,配合 --shots=3 连拍, + 用来看"点下去之后屏幕上闪出来的那块是什么"。 */ +async function fingerTap(x, y) { + const t = { x: Math.round(x), y: Math.round(y) }; + await send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [t] }, sessionId); + await sleep(40); + await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId); +} +if (argv.tap) { + const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(argv.tap)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2}})()`); + if (!box) console.log('tap 目标不存在:', argv.tap); + else { + await fingerTap(box.x, box.y); + const shots = Number(argv.shots || 3); + for (let i = 0; i < shots; i++) { + const png = await send('Page.captureScreenshot', { format: 'png' }, sessionId); + fs.writeFileSync(OUT.replace(/\.png$/, '') + `-t${i}.png`, Buffer.from(png.data, 'base64')); + const who = await ev(`(()=>{const e=document.elementFromPoint(innerWidth/2,innerHeight/2);if(!e)return null;var s=e.tagName.toLowerCase()+(e.id?"#"+e.id:"")+(typeof e.className==="string"&&e.className?"."+e.className.trim().split(/\s+/)[0]:"");var r=e.getBoundingClientRect();var c=getComputedStyle(e);return JSON.stringify({el:s,z:c.zIndex,pos:c.position,box:[Math.round(r.width),Math.round(r.height)],bg:c.backgroundColor})})()`); + console.log(`t${i} 视口中央:`, who); + await sleep(Number(argv.shotDelay || 300)); + } + process.exit(0); + } +} + +const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); +fs.writeFileSync(OUT, Buffer.from(shot.data, 'base64')); +console.log('shot -> ' + OUT); +process.exit(0); diff --git a/scripts/mobile-tap-audit.mjs b/scripts/mobile-tap-audit.mjs new file mode 100644 index 00000000..56575916 --- /dev/null +++ b/scripts/mobile-tap-audit.mjs @@ -0,0 +1,460 @@ +/* + * CyberStrikeAI — mobile tap audit. + * + * Proves the claim "every button on a phone can actually be tapped and shows up + * correctly". It is deliberately narrower than scripts/mobile-audit.mjs (layout + * defects): here every interactive element in the *active* page must pass four + * geometric/reachability checks, and the injected mobile chrome must pass a + * scripted finger tap. + * + * node scripts/mobile-tap-audit.mjs --user=admin --pass=... --width=390 --height=844 + * CSAI_AUDIT_USER=admin CSAI_AUDIT_PASS=... node scripts/mobile-tap-audit.mjs --all + * node scripts/mobile-tap-audit.mjs --cookie= --all + * + * Flags: --base=https://127.0.0.1:8088 --pages=a,b,c --allow-writes=0|1 --ua=iphone|android + * --chrome=0 (skip the finger-tap suite) --json= + * + * Nothing destructive is tapped: elements whose label matches a write verb are + * measured but never clicked, and 新对话-style writes need --allow-writes=1. + * Credentials are never defaulted — a baked-in password leaks the moment this + * file is committed or served. + */ +import fs from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; + +const argv = Object.fromEntries(process.argv.slice(2).map(a => { + const i = a.indexOf('='); + return i === -1 ? [a.replace(/^--/, ''), '1'] : [a.slice(2, i), a.slice(i + 1)]; +})); + +const W = Number(argv.width || 390), H = Number(argv.height || 844); +const PORT = argv.port || '9712'; +const BASE = (argv.base || 'https://127.0.0.1:8088').replace(/\/$/, ''); +const USER = argv.user || process.env.CSAI_AUDIT_USER || ''; +const PASS = argv.pass || process.env.CSAI_AUDIT_PASS || ''; +/* 不想交出口令时的替代路径:在浏览器里登录 127.0.0.1:8088,从 DevTools 复制 + auth_token 的值,用 --cookie=... 或 CSAI_AUDIT_COOKIE=... 传进来。会话本身 12 小时 + 过期,比长期有效的主机口令暴露面小。两者都没有则拒绝运行。 */ +const COOKIE = argv.cookie || process.env.CSAI_AUDIT_COOKIE || ''; +const ALLOW_WRITES = argv['allow-writes'] === '1'; +const MIN_TAP = Number(argv['min-tap'] || 44); +const CHROME = argv.chrome !== '0'; +const OUT = argv.out || `/tmp/cs-tap/${W}x${H}`; +const JSON_OUT = argv.json || `${OUT}/tap-audit.json`; +const BASELINE = argv.baseline || 'scripts/mobile-tap-baseline.json'; + +if (!COOKIE && (!USER || !PASS)) { + console.error('Refusing to run without credentials: pass --user=... --pass=... (or CSAI_AUDIT_USER/CSAI_AUDIT_PASS), or an existing session via --cookie=... (or CSAI_AUDIT_COOKIE).'); + process.exit(2); +} + +const SPA_PAGES = (argv.pages || (argv.all ? '' : 'dashboard,chat,hitl,tasks')) + .split(',').filter(Boolean); + +const PAGES = argv.all + ? ['dashboard', 'chat', 'projects', 'vulnerabilities', 'tasks', 'workflows', 'asset-overview', + 'asset-library', 'info-collect', 'hitl', 'tool-guard', 'webshell', 'c2-listeners', 'c2-sessions', + 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles', 'chat-files', 'mcp-monitor', 'mcp-management', + 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'platform-rbac', + 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'audit-log', 'monitor'] + : SPA_PAGES; + +fs.mkdirSync(OUT, { recursive: true }); + +/* Write verbs: an element carrying one of these in its label is measured, never + tapped. Without this the audit would delete real rows in whatever instance it + points at. */ +const WRITE_RE = /(删除|清空|清理|退出|注销|重启|停止|终止|中止|执行|运行|发送|提交|保存|新建|新增|创建|导入|上传|应用|重置|启用|禁用|安装|卸载|开始|立即|一键|批量|覆盖|恢复|回滚|delete|remove|clear|logout|submit|save|create|import|upload|apply|reset|install|uninstall|run|execute|send|restart|stop|kill|rollback|restore)/i; + +const CH = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const chrome = await import('node:child_process').then(m => + m.spawn(CH, ['--headless=new', `--remote-debugging-port=${PORT}`, + `--user-data-dir=/tmp/cs-tap-${PORT}`, '--ignore-certificate-errors', + '--no-sandbox', '--allow-running-insecure-content', 'about:blank'], { stdio: 'ignore' })); +/* 任何提前退出(连不上、登录失败)都不许把 headless Chrome 留在机器上 */ +process.on('exit', () => { try { chrome.kill('SIGKILL'); } catch { /* 已退出 */ } }); + +let ws; +for (let i = 0; i < 80; i++) { + try { + const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); + ws = new WebSocket((await r.json()).webSocketDebuggerUrl); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; setTimeout(rej, 4000); }); + break; + } catch { await sleep(300); } +} +if (!ws) { console.error('chrome devtools unreachable'); process.exit(1); } + +let idc = 0; const pending = new Map(); const events = []; +ws.onmessage = e => { + const m = JSON.parse(e.data); + if (m.id && pending.has(m.id)) { + const { res, rej } = pending.get(m.id); pending.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) { events.push(m.method); } +}; +const send = (method, params = {}, sessionId) => new Promise((res, rej) => { + const id = ++idc; pending.set(id, { res, rej }); + ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); +}); + +const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); +for (const m of ['Page.enable', 'Runtime.enable', 'Security.enable', 'Log.enable']) await send(m, {}, sessionId); +await send('Security.setIgnoreCertificateErrors', { ignore: true }, sessionId); +await send('Emulation.setDeviceMetricsOverride', { width: W, height: H, deviceScaleFactor: 3, mobile: true }, sessionId); +await send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 }, sessionId); +/* --ua=android 用安卓 Chrome 的 UA 复跑同一套门禁(壳层按视口宽度判定,与 UA 无关; + 这里换 UA 是为了让"同一份 UI 在安卓浏览器下同样合格"有可复现的证据)。 */ +const UA_STRINGS = { + iphone: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1', + android: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36' +}; +const UA = UA_STRINGS[(argv.ua || 'iphone').toLowerCase()]; +if (!UA) { console.error(`unknown --ua=${argv.ua} (iphone | android)`); process.exit(2); } +await send('Network.setUserAgentOverride', { userAgent: UA }, sessionId); + +const ev = async expr => { + const r = await send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }, sessionId); + if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; + return r.result?.value; +}; + +/* 真手指只有 touch:Chromium 在移动模拟下会自己从 touchEnd 合成一次 click。 + 再补一对 mouse 事件就是第二次 click —— 抽屉/面板会被「开了又关」, + 于是 menu-open 这类断言假失败(实测踩过)。 */ +async function fingerTap(x, y) { + const t = { x: Math.round(x), y: Math.round(y) }; + await send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [t] }, sessionId); + await sleep(40); + await send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }, sessionId); + await sleep(260); +} + +const DETECT = `(function(){ + var vw=innerWidth, vh=innerHeight, MIN=${MIN_TAP}; + var SEL='button,a[href],input:not([type=hidden]),select,textarea,[onclick],[role=button],[role=tab],[role=menuitem],[role=switch],[role=checkbox],[role=link],.nav-item-content,.nav-submenu-item,.settings-custom-select-trigger,.tab,.switch'; + function nm(el){ + var s=el.tagName.toLowerCase(); + if(el.id) s+='#'+el.id; + else if(typeof el.className==='string'&&el.className.trim()){ + var c=el.className.trim().split(/\\s+/).filter(function(x){return !/^is-|^active$|^has-/.test(x)}); + s+='.'+(c[0]||''); if(c[1]&&/layout|sidebar|content|panel|main|nav|list|detail|grid|row|header|body|wrap|table|bar|tab/i.test(c[1])) s+='.'+c[1]; + } + return s; + } + function path(el){ var p=[],n=el; for(var i=0;i<4&&n&&n.tagName;i++){p.unshift(nm(n));n=n.parentElement} return p.join('>') } + function label(el){ + var t=(el.innerText||'').trim(); + if(!t) t=(el.value||'').trim(); + if(!t) t=el.getAttribute('aria-label')||el.getAttribute('title')||el.getAttribute('placeholder')||''; + if(!t&&el.getAttribute('data-i18n')) t=el.getAttribute('data-i18n'); + if(!t){ var i=el.querySelector('i,svg,img'); t=(el.className||'')+'[icon]'; } + return String(t).replace(/\\s+/g,' ').trim().slice(0,42); + } + function shown(el){ + var n=el; + while(n&&n!==document.documentElement){ + var c=getComputedStyle(n); + if(c.display==='none'||c.visibility==='hidden'||parseFloat(c.opacity)===0) return false; + n=n.parentElement; + } + return true; + } + function inActivePage(el){ + var p=el.closest('.page'); + return !p||p.classList.contains('active'); + } + function scrollAncestor(el,axis){ + var a=el.parentElement; + while(a){ var c=getComputedStyle(a); + if(axis==='x'&&/auto|scroll/.test(c.overflowX)) return a; + if(axis==='y'&&/auto|scroll/.test(c.overflowY)) return a; + if(a===document.body) break; a=a.parentElement; } + return null; + } + function clipAncestor(el){ + var a=el.parentElement; + while(a){ var c=getComputedStyle(a); + if(c.overflowX==='hidden'||c.overflowY==='hidden'||c.overflow==='hidden') return a; + if(a===document.body) break; a=a.parentElement; } + return null; + } + var fixedTally=function(){ + var out=[]; + [...document.querySelectorAll('*')].forEach(function(el){ + var c=getComputedStyle(el); + if(c.position!=='fixed'&&c.position!=='sticky') return; + if(!shown(el)) return; + var r=el.getBoundingClientRect(); + if(r.width<1||r.height<1) return; + if(c.pointerEvents==='none') return; + if(parseFloat(c.zIndex||0)<0&&c.zIndex!=='auto') {} + out.push({el:el,r:r,n:nm(el),z:c.zIndex}); + }); + return out; + }(); + function overFixed(r){ + for(var i=0;i=f.r.top&&r.bottom<=f.r.bottom&&r.left>=f.r.left&&r.right<=f.r.right) return f.n; + } + return null; + } + + var bad=[], scanned=0, chromeSeen={}; + var list=[...document.querySelectorAll(SEL)]; + list.forEach(function(el){ + if(!shown(el)||!inActivePage(el)) return; + /* 折叠
里的控件本来就要展开才存在,不算"点不到" */ + var det=el.closest('details'); + if(det&&!det.open&&!det.querySelector('summary').contains(el)) return; + /*SCOPE*/ + if(el.ownerSVGElement) return; + if(el.closest('.markdown-body')&&getComputedStyle(el).display==='inline') return; + scanned++; + var c=getComputedStyle(el); + var r=el.getBoundingClientRect(); + /* try to bring it into view first: unreachable means a real dead button */ + if(r.top<0||r.bottom>vh||r.left<0||r.right>vw){ + /* 判"够不够得到"必须按手指能做的事来:逐层把可滚祖先滚到把元素居中。 + scrollIntoView 不算——实测它在这种「外层 overflow:hidden + 内层 auto」的嵌套里 + 根本不滚(元素原地不动),会把 59 个划得到的控件误报成 offscreen。 + overflow:hidden 的祖先手指也划不动,所以只滚 auto/scroll 的层。 */ + (function reveal(node){ + var a=node.parentElement; + while(a&&a!==document.documentElement){ + var cc=getComputedStyle(a); + if(/auto|scroll/.test(cc.overflowY)&&a.scrollHeight>a.clientHeight+2){ + var ab=a.getBoundingClientRect(), rb2=node.getBoundingClientRect(); + a.scrollTop+= (rb2.top+rb2.height/2)-(ab.top+ab.height/2); + } + if(/auto|scroll/.test(cc.overflowX)&&a.scrollWidth>a.clientWidth+2){ + var ab2=a.getBoundingClientRect(), rb3=node.getBoundingClientRect(); + a.scrollLeft+= (rb3.left+rb3.width/2)-(ab2.left+ab2.width/2); + } + a=a.parentElement; + } + })(el); + r=el.getBoundingClientRect(); + } + var w=r.width,h=r.height; + var out={el:nm(el),p:path(el),t:label(el),w:Math.round(w),h:Math.round(h)}; + if(w<2||h<2){ out.why='zero'; bad.push(out); return; } + if(c.pointerEvents==='none'||c.pointerEvents==='hidden'){ out.why='pointer-none'; bad.push(out); return; } + if(w=32 且 label 两个方向都到下限才算合格,否则照报 tiny */ + var pass=false; + /* 本层对复选/单选框的口径是 30px(见 mobile.css 24.11),整行/label 才是命中区 */ + if((el.type==='checkbox'||el.type==='radio')&&w>=30&&h>=30){ + /* 命中区常常由 label 或整行提供(本层 24.11 就是按"整行可点"写的): + 向上找第一个真正可点的祖先,它到下限就算合格 */ + var n=el, d=0; + while(n&&n!==document.body&&d++<5){ + var clickable=n.tagName==='LABEL'||n.hasAttribute('onclick')||getComputedStyle(n).cursor==='pointer'; + if(clickable){var nb=n.getBoundingClientRect(); if(nb.width>=MIN&&nb.height>=MIN){pass=true;break;}} + n=n.parentElement; + } + } + if(!pass){ out.why='tiny'; bad.push(out); return; } + } + var cx=r.left+w/2, cy=r.top+h/2; + if(cx<0||cy<0||cx>vw||cy>vh){ + var cl=clipAncestor(el), sx=scrollAncestor(el,'x'), sy=scrollAncestor(el,'y'); + out.why='offscreen'; out.clip=cl?nm(cl):null; out.swipes=[sx?'x':null,sy?'y':null].filter(Boolean).join(''); + bad.push(out); return; + } + var hitTop=document.elementFromPoint(cx,cy); + var ok=false, cur=hitTop; + while(cur){ if(cur===el){ok=true;break} cur=cur.parentElement; } + if(!ok){ + cur=el; + while(cur&&!ok){ if(cur.contains&&hitTop&&cur.contains(hitTop)) ok=true; cur=cur.parentElement; } + } + if(!ok&&hitTop){ + /* delegation: a handler on an ancestor is a legitimate target */ + var a=el.parentElement, d=0; + while(a&&d++<4){ if(a.contains(hitTop)){ok=true;break} a=a.parentElement; } + } + if(!ok){ + out.why='covered'; + out.blocker=hitTop?nm(hitTop):'none'; + out.blockerPath=hitTop?path(hitTop):''; + var of=overFixed(r); if(of) out.blockerFixed=of; + bad.push(out); return; + } + if(el.id) chromeSeen[el.id]=1; + }); + + var scr=document.scrollingElement||document.documentElement; + return { + vw:vw,vh:vh,scanned:scanned,bad:bad, + doc:{h:Math.round(scr.scrollHeight),clientH:scr.clientHeight,canScrollY:scr.scrollHeight>scr.clientHeight+2, + bodyOverflowY:getComputedStyle(document.body).overflowY,htmlTouch:getComputedStyle(document.documentElement).touchAction}, + chrome:{tabbar:!!document.querySelector('#m-tabbar'),sheet:!!document.querySelector('#m-sheet'), + menu:!!document.querySelector('.m-menu-btn'),more:!!document.querySelector('.m-more-btn'), + chatbar:!!document.querySelector('#m-chat-bar'),scrim:!!document.querySelector('#m-scrim')}, + locale:(document.documentElement.getAttribute('lang')||'') + }; +})()`; + +/* DETECT 默认扫"当前页 + 全局固定件"。遮罩展开时页面元素会被 scrim 判成 covered, + 所以扫抽屉/面板要限定作用域,否则全是假红。 */ +const detectFor = scope => DETECT.replace('/*SCOPE*/', + scope ? `if(!el.closest(${JSON.stringify(scope)})) return;` : ''); + +async function openPage(hash) { + await ev(`location.hash=${JSON.stringify(hash)}`); + await sleep(2600); +} + +const host = new URL(BASE).hostname; +if (COOKIE) await send('Network.setCookie', { name: 'auth_token', value: COOKIE, domain: host, path: '/' }, sessionId); +await send('Page.navigate', { url: BASE + '/' }, sessionId); +await sleep(3500); + +/* The login card is a phone surface too — check it before authenticating. */ +const login = await ev(DETECT); +if (!COOKIE) { + await ev(`(()=>{const u=document.getElementById('login-username'),p=document.getElementById('login-password');if(u&&p){u.value=${JSON.stringify(USER)};p.value=${JSON.stringify(PASS)};document.getElementById('login-form').requestSubmit();}})()`); + await sleep(8000); +} else { + console.log('using the session cookie supplied via --cookie; no password was sent'); +} +const who = await ev(`(()=>{const o=document.getElementById('login-overlay');return o?getComputedStyle(o).display:'none'})()`); +if (who !== 'none') { console.error('login did not take (overlay still displayed):', who); process.exit(3); } + +const report = { login, pages: {}, chromeTaps: [] }; +let grand = {}; +const bump = k => { grand[k] = (grand[k] || 0) + 1; }; + +const log = (o, k) => { (o.bad || []).forEach(b => bump(b.why)); }; + +for (const pg of PAGES) { + await openPage(pg); + const r = await ev(DETECT); + if (!r || r.__err) { console.log(pg, 'DETECT ERROR', r && r.__err); continue; } + report.pages[pg] = r; + log(r); + const n = r.bad.length; + console.log(`${pg.padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(n).padStart(3)} ${n ? '' : ' OK'}`); + r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`)); + const shot = await send('Page.captureScreenshot', { format: 'png' }, sessionId); + fs.writeFileSync(`${OUT}/${pg}.png`, Buffer.from(shot.data, 'base64')); +} + +/* ---------------- overlays: 抽屉与面板展开后的按钮 ---------------- */ +const OVERLAYS = [ + { name: 'sheet', route: 'dashboard', open: `CSAMobile.openSheet()`, scope: '#m-sheet' }, + { name: 'drawer', route: 'dashboard', open: `CSAMobile.openDrawer()`, scope: '#main-sidebar' }, + { name: 'chat-drawer', route: 'chat', open: `CSAMobile.openChatDrawer()`, scope: '#conversation-sidebar' }, +]; +for (const o of OVERLAYS) { + await openPage(o.route); + await ev(`(()=>{try{${o.open}}catch(e){}})()`); + await sleep(900); /* 等开合动画结束,动画中量到的是位移中的盒子 */ + const r = await ev(detectFor(o.scope)); + if (!r || r.__err) { console.log(o.name, 'DETECT ERROR', r && r.__err); continue; } + report.pages[o.name] = r; + log(r); + console.log(`${('[' + o.name + ']').padEnd(26)} scanned=${String(r.scanned).padStart(3)} defects=${String(r.bad.length).padStart(3)} ${r.bad.length ? '' : ' OK'}`); + r.bad.slice(0, 6).forEach(b => console.log(` ${b.why.padEnd(12)} ${String(b.w)}x${String(b.h)} ${b.t} ${b.el}${b.blocker ? ' <= ' + b.blocker : ''}`)); + await ev(`CSAMobile.closeSheet();CSAMobile.closeDrawer();CSAMobile.closeChatDrawer();`); +} + +/* ---------------- chrome finger taps ---------------- */ +/* 关闭要走用户真能点到的控件:抽屉展开后汉堡键被抽屉自己盖住, + 再点它点的是侧栏(真实用户用抽屉内的 × 或遮罩)。每步前先复位, + 否则一条失败会连锁把后面的断言全带偏。 */ +const RESET = `CSAMobile.closeDrawer();CSAMobile.closeSheet();CSAMobile.closeChatDrawer();`; +const TAPS = [ + { name: 'menu-open', sel: '.m-menu-btn', pre: RESET, expect: `document.querySelector('#main-sidebar').classList.contains('m-open')` }, + { name: 'menu-close', sel: '#main-sidebar .m-close-btn', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` }, + { name: 'menu-scrim-close', sel: '#m-scrim', pre: `${RESET}CSAMobile.openDrawer();`, expect: `!document.querySelector('#main-sidebar').classList.contains('m-open')` }, + { name: 'more-open', sel: '.m-more-btn', pre: RESET, expect: `document.querySelector('#m-sheet').classList.contains('m-show')` }, + { name: 'more-close', sel: '#m-sheet .m-close-btn', pre: `${RESET}CSAMobile.openSheet();`, expect: `!document.querySelector('#m-sheet').classList.contains('m-show')` }, + { name: 'tab-hitl', sel: '#m-tabbar [data-tab=hitl]', expect: `document.querySelector('#page-hitl').classList.contains('active')` }, + { name: 'tab-tasks', sel: '#m-tabbar [data-tab=tasks]', expect: `document.querySelector('#page-tasks').classList.contains('active')` }, + { name: 'tab-dashboard', sel: '#m-tabbar [data-tab=dashboard]', expect: `document.querySelector('#page-dashboard').classList.contains('active')` }, + { name: 'tab-chat', sel: '#m-tabbar [data-tab=chat]', expect: `document.querySelector('#page-chat').classList.contains('active')` }, + { name: 'chat-drawer-open', sel: '#m-chat-bar button:first-child', expect: `document.querySelector('#conversation-sidebar').classList.contains('m-open')` }, + { name: 'chat-drawer-close', sel: '#conversation-sidebar .m-close-btn', expect: `!document.querySelector('#conversation-sidebar').classList.contains('m-open')` }, +]; + +if (CHROME) { + await openPage('chat'); + for (const t of TAPS) { + if (t.pre) await ev(`(()=>{try{${t.pre}}catch(e){}})()`); + await sleep(320); + const box = await ev(`(()=>{const e=document.querySelector(${JSON.stringify(t.sel)});if(!e)return null;const r=e.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2,w:r.width,h:r.height,shown:getComputedStyle(e).display!=='none'}})()`); + if (!box || !box.shown) { report.chromeTaps.push({ name: t.name, ok: false, why: 'absent' }); console.log(`tap ${t.name.padEnd(20)} ABSENT`); continue; } + events.length = 0; + await fingerTap(box.x, box.y); + const passed = await ev(`(()=>{try{return !!(${t.expect})}catch(e){return false}})()`); + const err = events.includes('Runtime.exceptionThrown'); + report.chromeTaps.push({ name: t.name, ok: !!passed && !err, state: passed, err }); + console.log(`tap ${t.name.padEnd(20)} ${passed && !err ? 'ok' : 'FAIL'} (state=${passed} jsErr=${err})`); + } + /* language round-trip: the injected chrome must re-label itself */ + await ev(`window.changeLanguage && window.changeLanguage('en-US')`); + await sleep(2500); + const en = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`); + report.langEn = en; + console.log('en-US chrome labels:', JSON.stringify(en)); + await ev(`window.changeLanguage && window.changeLanguage('ru-RU')`); + await sleep(2500); + const ru = await ev(`(()=>{const o=[];document.querySelectorAll('#m-tabbar .m-tab>span:not(.m-tab-badge),#m-sheet .m-sheet-item>span').forEach(function(s){o.push(s.textContent.trim())});return {lang:document.documentElement.getAttribute('lang'),labels:o}})()`); + report.langRu = ru; + console.log('ru-RU chrome labels:', JSON.stringify(ru)); + await ev(`window.changeLanguage && window.changeLanguage('zh-CN')`); + await sleep(2000); +} + +fs.writeFileSync(JSON_OUT, JSON.stringify(report, null, 2)); + +const chromeFail = report.chromeTaps.filter(t => !t.ok).length; +const total = Object.values(grand).reduce((a, b) => a + b, 0); +console.log(`\n=========== MOBILE TAP AUDIT @${W}x${H} ===========`); +console.log(`pages ${Object.keys(report.pages).length}`); +console.log(`covered (occluded) ${grand.covered || 0}`); +console.log(`offscreen ${grand.offscreen || 0}`); +console.log(`zero-size ${grand.zero || 0}`); +console.log(`pointer-events:none ${grand['pointer-none'] || 0}`); +console.log(`tap target < ${MIN_TAP}px ${grand.tiny || 0}`); +console.log(`chrome taps failed ${chromeFail}`); +console.log(`report ${JSON_OUT}`); + +/* Ratchet: the钉死的 baseline may only go down. */ +let bl = null; +try { bl = JSON.parse(fs.readFileSync(BASELINE, 'utf8')); } catch { } +let ratchetFail = false; +if (bl) { + for (const [k, v] of Object.entries(grand)) { + if ((bl[k] ?? 0) < v) { console.log(`RATCHET FAIL ${k}: baseline ${bl[k]} -> now ${v}`); ratchetFail = true; } + } + for (const k of Object.keys(bl)) { + if (k.startsWith('page:')) { + const name = k.slice(5), now = (report.pages[name] || {}).bad?.length ?? 0; + if (bl[k] < now) { console.log(`RATCHET FAIL ${name}: baseline ${bl[k]} -> now ${now}`); ratchetFail = true; } + } + } + if (chromeFail > (bl.chromeFail ?? 0)) { console.log(`RATCHET FAIL chrome: ${bl.chromeFail} -> ${chromeFail}`); ratchetFail = true; } +} else { + const fresh = { ...grand }; + for (const [p, r] of Object.entries(report.pages)) fresh['page:' + p] = r.bad.length; + fresh.chromeFail = chromeFail; + fresh.note = 'generated by scripts/mobile-tap-audit.mjs; numbers may only decrease'; + fs.writeFileSync(BASELINE, JSON.stringify(fresh, null, 2)); + console.log(`baseline written -> ${BASELINE}`); +} + +/* 退出码口径(写死,别每次靠人判断): + - 够不到 / 被遮挡 / 零尺寸 / pointer-events:none —— 任何一处都是硬失败,控件点不到没有"存量债"可言; + - 自家注入的抽屉、面板、会话抽屉里的按钮 —— 必须 0,不许留债; + - 桌面遗留控件的小命中区 —— 走基线只降不升,超基线即红。 */ +const hard = (grand.covered || 0) + (grand.offscreen || 0) + (grand.zero || 0) + (grand['pointer-none'] || 0); +const ownChrome = ['sheet', 'drawer', 'chat-drawer'].reduce((n, k) => n + ((report.pages[k] || {}).bad || []).length, 0); +const fail = hard || ownChrome || chromeFail || ratchetFail; +console.log(`\n判定: 硬缺陷 ${hard} | 自家外壳 ${ownChrome} | 点击失败 ${chromeFail} | 基线回潮 ${ratchetFail ? '是' : '否'} -> ${fail ? 'FAIL' : 'PASS'}`); +process.exit(fail ? 1 : 0); diff --git a/scripts/mobile-tap-baseline.json b/scripts/mobile-tap-baseline.json new file mode 100644 index 00000000..8b831cd8 --- /dev/null +++ b/scripts/mobile-tap-baseline.json @@ -0,0 +1 @@ +{"tiny": 3, "page:dashboard": 0, "page:chat": 0, "page:projects": 0, "page:vulnerabilities": 0, "page:tasks": 0, "page:workflows": 0, "page:asset-overview": 0, "page:asset-library": 1, "page:info-collect": 0, "page:hitl": 0, "page:tool-guard": 2, "page:webshell": 0, "page:c2-listeners": 0, "page:c2-sessions": 0, "page:c2-tasks": 0, "page:c2-payloads": 0, "page:c2-events": 0, "page:c2-profiles": 0, "page:chat-files": 0, "page:mcp-monitor": 0, "page:mcp-management": 0, "page:knowledge-management": 0, "page:knowledge-retrieval-logs": 0, "page:roles-management": 0, "page:platform-rbac": 0, "page:skills-monitor": 0, "page:skills-management": 0, "page:agents-management": 0, "page:settings": 0, "page:audit-log": 0, "page:monitor": 0, "page:sheet": 0, "page:drawer": 0, "page:chat-drawer": 0, "chromeFail": 0, "note": "generated by scripts/mobile-tap-audit.mjs; numbers may only decrease"} \ No newline at end of file diff --git a/web/static/css/api-docs-mobile.css b/web/static/css/api-docs-mobile.css new file mode 100644 index 00000000..dcda8361 --- /dev/null +++ b/web/static/css/api-docs-mobile.css @@ -0,0 +1,301 @@ +/* + * CyberStrikeAI — API docs page (/api-docs) on phones. + * Separate template from index.html, so it needs its own adaptation layer. + * Loaded after the template's inline + +
diff --git a/web/templates/index.html b/web/templates/index.html index 254693bc..a7bd1c61 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -2,7 +2,15 @@ - + + + + + + + + + CyberStrikeAI @@ -36,6 +44,8 @@ + + @@ -1257,7 +1267,7 @@