构建测试归档并运行 iOS 与 macOS 项目
29.9/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分
数据截至 2026/10/11 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。
安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add harde1/dsh-xcodebuild该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)
状态速览
判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态信任档位:待复验
是什么
dsh 原生插件 · dev
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 21 天前
档位由哪些信号合成
本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码v2.7.0🔵 扫描:含敏感能力尚无人工评估展开查看扫描说明与证据 ↓
安全扫描自动 · 静态分析源码
v2.7.0静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。
可执行外部命令读写/删除本地文件写入剪贴板(不计风险)发起外部网络请求
查看扫描证据(共 98 处,展示前 33 条)
- 读取本地文件lib/index.js:194
return `${new Date(statSync(fileURLToPath(import.meta.url)).mtimeMs).toISOString().slice(0, 19)}Z` - 执行外部命令来自配置/变量lib/index.js:411
child = spawn(argv[0], argv.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'] }) - 执行外部命令来自配置/变量lib/index.js:425
formatter = spawn(options.formatter[0], options.formatter.slice(1), { - 读取本地文件lib/index.js:747
if (name.includes('/')) return existsSync(name) ? name : null - 读取本地文件lib/index.js:750
if (existsSync(candidate)) return candidate - 执行外部命令来自配置/变量lib/index.js:821
child = spawn(path, flags, { stdio: ['pipe', 'pipe', 'ignore'] }) - 读取本地文件lib/index.js:931
return parseDevicectlLockState(await readFile(answer, 'utf8')) - 删除本地文件递归lib/index.js:935
await rm(root, { recursive: true, force: true }).catch(() => undefined) - 执行外部命令来自配置/变量lib/index.js:1194
child = spawn(argv[0], argv.slice(1), { cwd, stdio: ['ignore', 'pipe', 'pipe'] }) - 读取本地文件lib/index.js:1285
const size = await stat(path).then((info) => info.size).catch(() => 0) - 执行外部命令来自配置/变量lib/index.js:1511
child = spawn(argv[0], argv.slice(1), { cwd: spec.cwd, stdio: ['ignore', 'pipe', 'pipe'] }) - 读取本地文件lib/index.js:1590
const entries = await readdir(dir, { withFileTypes: true }) - 删除本地文件lib/index.js:1596
await rm(join(dir, name), { force: true }).catch(() => undefined) - 删除本地文件递归lib/index.js:1609
await rm(path, { recursive: true, force: true }).catch(() => undefined) - 删除本地文件递归lib/index.js:1614
await rm(path, { recursive: true, force: true }).catch(() => undefined) - 写入本地文件lib/index.js:1653
await mkdir(dirname(consolePath), { recursive: true }) - 执行外部命令来自配置/变量lib/index.js:1657
child = spawn(argv[0], argv.slice(1), { - 删除本地文件递归lib/index.js:1902
await rm(root, { recursive: true, force: true }) - 删除本地文件递归lib/index.js:1967
await rm(root, { recursive: true, force: true }) - 写入本地文件lib/index.js:2788
await writeFile(csv, thermalCsvText(samples), 'utf8').catch(() => undefined) - 写入本地文件lib/index.js:5589
await mkdir(scratch, { recursive: true }) - 写入本地文件lib/index.js:5702
await mkdir(pulled, { recursive: true }) - 写入本地文件lib/index.js:5797
await mkdir(scratch, { recursive: true }) - 写入本地文件lib/index.js:5896
await mkdir(pulled, { recursive: true }) - 发起网络请求lib/client.js:597
const response = await fetch(new URL(`${ROUTE_PREFIX}${method}`, baseUrl()), { - 写入剪贴板lib/client.js:3658
await navigator.clipboard.writeText(text) - 写入剪贴板lib/client.js:5581
void navigator.clipboard?.writeText(viewChatContext(all, at, state.lldb.tree?.target ?? null, state.lldb.treeReadAt ?? null)).catch(() => {}) - 读取本地文件lib/projects.js:72
const named = await stat(abs).catch(() => null) - 读取本地文件lib/projects.js:92
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null) - 读取本地文件lib/probe.js:236
if (!existsSync(sourcePath)) { - 读取本地文件lib/probe.js:241
if (existsSync(output)) return { ok: true, path: output, note: '', ms: 0, cached: true } - 写入本地文件lib/probe.js:243
mkdirSync(dir, { recursive: true }) - 读取本地文件lib/probe.js:249
if (compiled?.status !== 0 || !existsSync(output)) {
扫描于 2026/10/31(1 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库
静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个
功能亮点
- 测试与归档项目
- 实时过滤构建日志
为 DeepSeek Harness 提供 Xcode 构建、测试、归档、运行和日志流:构建/测试/清理/归档/运行 iOS 或 macOS 项目,带实时可过滤的构建日志,以及模拟器和 USB 设备日志通道。仅支持 macOS。
README
由 DeepSeek 最新模型翻译生成XcBuild
为 DeepSeek Harness 打造的 Xcode 构建与开发循环:构建、测试、清理、归档并运行 iOS/macOS 项目,带一份可筛选的实时构建日志——既能由智能体通过六个工具驱动,也能在会话内的面板里手动操作。
灵感来自 SweetPad VS Code 扩展。包名与 composition row 保留 id dsh-xcodebuild;XcBuild 是 UI 中显示的名字。
它新增了什么
七个面向模型的工具
| 工具 | 用途 |
| xcode_doctor | 这台机器有什么、缺什么,每处缺失都附上安装命令,外加正在使用的 Xcode。设备装不上或起不来时,先跑这个。 |
| xcode_project | 检测 .xcworkspace / .xcodeproj / Package.swift;列出 scheme、configuration、target,以及 .vscode/settings.json 里的 SweetPad 默认值。 |
| xcode_destinations | 合并所有能指定设备的渠道:-showdestinations(模拟器、My Mac、Xcode 管理的硬件)+ xcdevice(Xcode 自己的设备层)+ devicectl(CoreDevice)+ 经典 USB 渠道。
展开完整文档(README 剩余 1 节)
XcBuild
为 DeepSeek Harness 打造的 Xcode 构建与开发循环:构建、测试、清理、归档并运行 iOS/macOS 项目,带一份可筛选的实时构建日志——既能由智能体通过六个工具驱动,也能在会话内的面板里手动操作。
灵感来自 SweetPad VS Code 扩展。包名与 composition row 保留 id dsh-xcodebuild;XcBuild 是 UI 中显示的名字。
它新增了什么
七个面向模型的工具
| 工具 | 用途 |
| xcode_doctor | 这台机器有什么、缺什么,每处缺失都附上安装命令,外加正在使用的 Xcode。设备装不上或起不来时,先跑这个。 |
| xcode_project | 检测 .xcworkspace / .xcodeproj / Package.swift;列出 scheme、configuration、target,以及 .vscode/settings.json 里的 SweetPad 默认值。 |
| xcode_destinations | 合并所有能指定设备的渠道:-showdestinations(模拟器、My Mac、Xcode 管理的硬件)+ xcdevice(Xcode 自己的设备层)+ devicectl(CoreDevice)+ 经典 USB 渠道。每个条目都带一个可直接使用的 destination 字符串、是否可达、以及哪些来源看到了它;recommended 永远不会是不可达的设备。 |
| xcode_run | build / test / clean / archive / run。把完整日志流式写入后台 run,并返回收集到的编译器错误。run 在模拟器上用 simctl 安装,在 CoreDevice 认识的设备上用 devicectl,在 iOS 16 及更早版本上用 ideviceinstaller + ios-deploy,macOS 上两者都不用。 |
| xcode_log | run 的日志:缓冲尾部、按行号增量切片,或正则筛选视图。构建中途调用也安全。 |
| xcode_lldb | 调试这个 workspace 最近运行过的 app。view-hierarchy 把 LLDB 附加到它上面,暂停它,返回 key window 的视图树(class、frame、text、hidden、stack-view 属性),并把同一棵树连同按视图裁剪的截图写成 .lookin 文件,供 Lookin.app 打开;lookin 打开那个文件;command 在同一会话里运行任意原始 LLDB 命令(po、bt、breakpoint set、expression);attach / interrupt / detach / status 管理该会话。在 CoreDevice 够不到的 iOS 16 或更早版本的设备上,LLDB 通过 pymobiledevice3 转发的 debugserver 端口附加(pipx install pymobiledevice3);画布绘制每个控件的颜色、文字和图片,在后台抓取并按进程缓存。 |
| xcode_device_log | 模拟器的日志(simctl spawn、快照或有界的实时窗口),或物理硬件的日志。在 iOS 16 及更早版本上,传入 bundleId,app 自己的每次启动日志(Documents/PPCrashLog/log_<timestamp>.log)会从它的沙盒里读出——这个文件能挺过崩溃,设备锁定时也能读;mode: "syslog" 则给出实时的 idevicesyslog 窗口。 |
模型指引,让这些工具不用点名就能被用上
- 每一步都会带上一段简短的系统提示词片段(dsh-xcodebuild,通过 ctx.systemPrompt.section 注入)。它告诉模型改用 xcode_ 工具,别再去 shell 里调 xcodebuild,并说明哪种情况该用哪个工具(构建报错、启动后崩溃、工具缺失)。
- 一个运行时技能 xcode-build-loop(通过 ctx.skills.register 注册)保存完整流程:选项目 → 选 scheme → 选 destination,运行、读错误、修复、重跑,再读设备日志。模型按需加载,所以没有 Xcode 任务时,这段长文本不占任何开销。
两者都是可选服务(ctx.inject),所以没有它们的配置档照样能挂载这些工具。
面板跟着模型走。 xcode_run 会给自己的运行打上调用方会话工作目录的标记,因此打开该项目的面板在空闲轮询几秒内就能接上:清掉旧日志,流式输出新日志,把 scheme / configuration / destination 与实际构建的内容对上,并把这次运行标为 by agent。停止操作对它和别的运行一样有效。
一个面板,三种落座方式,占用 shell 现有的哪个侧边栏就用哪个:
- 停靠在 dsh-better-sidebar 里,前提是装了那个插件 —— 通过 ctx.betterSidebar.registerTab 注册,于是它作为标签页出现在该插件的工作台(右侧边栏或底部面板)中,标题胶囊和关闭按钮由宿主提供。这个标签页从 better-sidebar 自己的列表里添加;这里刻意不设常驻的头部按钮,因为侧边栏已经能容纳它,再在会话标题旁放第二个固定入口只会显得杂乱。
- 停靠在 shell 自带的右侧边栏里,前提是没装 better-sidebar —— 通过 ctx.sidebarRightTabs.register 注册,也就是 shell 的 Files 标签页用的那套标签注册表。面板在这里成为一种标签页类型,列在该侧边栏的 Guide 中(一个带标题和描述的胶囊),也出现在它的添加标签页菜单里;胶囊和关闭按钮由 shell 绘制。规则相同:不设头部按钮,因为侧边栏自己就能提供这个标签页。
- 浮动 —— 两种侧边栏都没有时的兜底方案:同一个面板放进 shell.overlay,自带头部行和关闭按钮,另有一个头部按钮用来打开它 —— 没有侧边栏可添加标签页时,这个按钮是唯一的入口,所以它只为这种情况保留,其余情况不渲染任何东西。
无论停靠在哪里,标记都是同一个。 插件在每种落座方式下都用同一幅四叶图形来识别:头部按钮的徽标、better-sidebar 在标签栏里绘制的图标、shell 的 Guide 胶囊、shell 的标签页胶囊,以及浮动面板的头部行。各落座方式对尺寸的传递方式并不一致 —— better-sidebar 调用 icon(size),shell 的 Guide 把 entry.icon 当组件渲染并传入 {size, className} —— 所以用一个组件(Mark)负责图形,再用一行适配器应对第一种形式。每幅图形各自声明渐变,因为好几种落座方式都处在一次显示,否则 url(#…) 会解析到文档里恰好排在最前面的那个同名定义。test/client-interaction.test.mjs 会比较各个座位画出的路径,所以「同一个标记」是测试结果,不是口头声明。
两个侧边栏是备选关系,不是叠加关系:better-sidebar 在的时候它赢,官方右侧边栏是兜底。因为每个服务什么时候到就什么时候到,syncSeats() 会同时读两边,该让位就让位,所以到达顺序不会让面板出现在两个地方,或者一个地方都没有。
两者都通过 ctx.inject([…], …) 访问,并且刻意不走 dsh.client.inject。那个列表是硬依赖:在里面写上 dsh-better-sidebar(或者右侧边栏的包名),会让没有装它的人根本加载不了这个插件的客户端部分,那就跟有兜底正好相反。三个座位都由 test/client-interaction.test.mjs 覆盖。
不管落在哪个座位,面板都给你:项目选择器(见下)、scheme / destination / configuration 选择器,destination 列表旁边有个 ⟳ 可以按需重新读取,还有 Build / Run / Test / Clean / Archive / Stop、带颜色区分的流式日志,以及一条过滤栏。
调试器,在底部抽屉里。 ⌘L,或者状态行里的 LLDB 按钮,会打开一条其余时间都藏着的条带。View Hierarchy 回答截图回答不了的问题:它把 LLDB 附加到项目上次运行的那个 app 上,停住它,画出 key window 的视图树——类名、地址、frame、文本、hidden 标记,以及 stack view 的 axis/distribution/alignment,这些通常就是界面看起来不对的原因。过滤器按类名、文本或地址缩小范围,不用再问一次宿主——匹配的行高亮,撑起它们的祖先变暗,栏上显示匹配了多少。树像 Lookin 那样折叠:每一行有子节点就带个三角(⌥ 点击折叠整条分支),方向键用来走和折叠,折叠状态按地址保留,所以重新读取后还是原样,还有三种模式——智能内容展开的默认模式、全部展开、只显示外层窗口——在画布上选中的视图会以自己的方式展开到可见。底部还有个输入框,接受任何 LLDB 命令。添到聊天——在视图的右键菜单里、选中视图的 chip 上、构建日志或 LLDB 记录的任意一行上,以及构建错误数的旁边——会把它作为引用放进聊天,标明它来自哪里(source)、具体指向什么(refers to)、哪个工具调用能到达它或读取它周围的内容,这样模型就清楚知道指的是什么。抽屉是模型用的同一个会话,所以模型启动的会话会打开它——记录什么的都在——用户可以从那里接管提示词。⤢ 让抽屉占满整个面板(⤡ 还回去;面板本身可以用角调整大小),双击某一行会聚焦那个视图的子树。
画布显示应用,视图框叠在上面。 树和检查器之间是 Lookin 的预览,构建方式相同——只靠 LLDB。它是窗口自己的画面,在应用停止时于应用内部渲染(一个视图,缩放 1,一次短暂停止),并画在画布自己的坐标系里,所以它会随框一起缩放、旋转和平移;2D 平铺显示各框,3D 按重叠深度把它们拉开,拖动可旋转整个堆叠,隐藏视图 把隐藏的视图画成虚线,当某个框在屏幕上足够大能容纳类名时,框上会显示类名。滑动移动相机,绝不改变缩放——触控板把双指滑动报告为不带修饰键的滚轮事件,这就是为什么以前每次滚动都会改变缩放——而捏合(或 ⌘+滚轮)会缩放,适配 旁边的百分比本身就是控件:横向拖动它,双击它回到 100%。点击某个框会选中该视图,双击聚焦它,右键打开该行的菜单——截图 关闭画面以进行纯框绘制,刷新截图 重新读取它。渲染应用自己的图层也修复了以前在这里看不见的问题:在 Xcode 26 上,任何 CoreGraphics 结构体都不能作为 LLDB 表达式中的值(CGSize size = [layer frame].size 和 CGRectMake 都会报 error: attempt to use a deleted function,在所有语言模式下都是如此),所以几何信息以四个 double 的形式从 NSValue 中传出,上下文是位图上下文——这才让每个视图的图像成为真实图像,而不是总是回退到裁剪的截图。当连这也无法运行时——某台设备的 UIKit 模块在已安装的 Xcode 中没有对应 SDK,因此无法命名 CGFloat 和 CGContextRef——窗口就改为裁剪到其 frame 的屏幕截图,用平台自己的工具和 sips 截取,完全不需要调试器;画面旁边的注释会说明这一点。
树旁边的检查器,只靠 LLDB 构建。 选中一个视图会打开三个窗格,任何地方都没有 Lookin 服务器。它们来自一次读取:读取树的停止也会读取每个视图的属性和布局,之后的点击不花任何代价——没有第二次附加,没有第二次停止,完全没有往返。附加是昂贵的那一半(到模拟器 4 秒,到手机 15–23 秒),而在已经停止的进程上执行一次 _ivarDescription 大约花费 20 毫秒,一次布局报告大约 10 毫秒,所以每次点击都读取它们会反复付出昂贵的那一半。测试应用的 96 个视图在 6 秒内返回 2.9 MB 的详细信息,只此一次。这一遍是有界的(400 个视图,6 秒),并会说明它到达了多少——树的状态栏中显示 已缓存 N 个视图,提前停止时显示 (部分)——所以被边界排除的视图在打开时仍会按需读取,而 刷新 会重新读取选中的视图,即使它已被缓存:
- 属性 — 应用关于对象所说的一切,按声明它的类分组,用 _ivarDescription 读取。可写入的 ⌘L 风格行会获得一个编辑器,从声明的类型:BOOL 对应开关,数字对应字段,字符串对应文本框,UIColor 对应颜色井,CGRect 对应四个框。编辑以 setValue:forKey: 发到 app,面板随后显示 app 报告的内容,所以 app 自己的布局代码写回的值会如实说明,而不是假装已经改过。
- 布局 — 一个表达式搞定 Auto Layout:autoresizing mask 是否仍在转换、引擎是否判定布局有歧义、固有尺寸、hugging 与 resistance、这个视图自身的约束,以及挂在祖先上、提到它的那些约束。约束文本里的地址是一个按钮,点击即选中那个视图。
- 预览 — 视图渲染出的图像,可单独显示,也可连同子树一起显示,支持缩放与平移。
面板上方是窗口到所选视图的链条,每一环都可跳转。
Lookin 能打开的树。 Lookin 是观察层级更好的窗口,抽屉里的 Lookin 按钮会在那里打开这棵树——但 Lookin 通常需要 app 内部有自己的服务器。是否装了 Lookin 是检查出来的,不是假设的:装了,抽屉就提供 Lookin;没装,同一个位置提供 Reveal——在 Finder 中显示文件——因为一个标着「Lookin」却悄悄打开 Finder 的按钮就是撒谎。这个不会:每次读取都会把刚读到的内容写成 /tmp/dsh-xcodebuild/lookin-<when>.lookin,Lookin.app 可直接打开。正因如此,一个只能通过调试器访问的 app——没有 LookinServer、release 二进制、只能附加的设备——也能在同一个树视图里浏览,每个节点的类链、frame、alpha 和 hidden 标志一应俱全——frame 就是面板打印的那些相对于父视图的数字,也正是 Lookin 自己的文件所用的空间。类链是向运行时询问的,每个不同类一个表达式,所以一个会话的首次读取会比之后几次多花一点时间。最多保留十个文件。
一行就是一个问题。 在树里点击某个视图,旁边会打开详情面板:视图自身的图像——Solo 只显示控件本身,Group 连同子树——然后是它背后的数字,frame 与 bounds、背景色色块、layer 类以及它继承的类链。这就是 Lookin 观察层级的窗口,就在抽屉里,无需安装 Lookin。
面板是按点击获取的,而不是随树一起下发,因为树是文本,图像不是:一次只取一个视图的两张渲染图,由宿主解析,所以颜色偏了会是一个失败的测试,而不是一张要人眯眼看的截图。
图像是视图自己的。 每个节点都带着 Lookin 显示的两张图像,两张都在 app 内部渲染,因为截图产生不了它们:solo 只显示控件本身(绘制其 layer 时隐藏子 layer,LookinServer 就是这么做的),group 则是控件连同它的子树。截取一张屏幕截图——这正是过去采用的做法——得到的只是平面图像的一块区域,永远不是控件本身;对于滚动到屏幕外的内容,甚至连它忠实的视图都拿不到。
渲染结果通过应用自己的沙盒输出:模拟器的数据容器是这台机器上的一个目录,所以 PNG 直接从里面读取;而物理设备则用 xcrun devicectl device copy from 获取。视图渲染时长边最大 1024 像素,超出绘图上下文容量的视图会被跳过。当完全无法渲染时——iOS 16 或更早的设备没有 devicectl——导出会退回到截取一张屏幕截图,文件旁边的说明也会注明这一点。
在 iPhone 13(iOS 26.6.2)上、Xcode 26.0.1 下测得,因为实现正是围绕这些事实成形的:
- 设备附加是异步且缓慢的。 device process attach -p <pid> 立即返回,进程在 6-25 秒后停止,停止以消息形式到达。插件等待 Process N stopped,并用 process status 轻推;它不把线程的 stop reason = … 行当作进程已停止——LLDB 在仍报告进程为运行时也会打印这一行,轻信它会让每个表达式都失败。
- 在这段窗口期内,除了 script 什么都不能问。 process status 和 process interrupt 都需要 LLDB 已经启动的进程,所以在附加稳定期间它们只会回答 error: Process must be launched.,别无其他;每 3 秒请求一次停止,在一次附加中打印了七次这样的错误,而这次轻推什么也没改变,因为发送 SIGSTOP 给应用的正是附加本身。因此等待先读取进程状态—— script print("xcb-lldb-state=" + str(lldb.debugger.GetSelectedTarget().GetProcess().GetState())),无论状态如何都会得到回答,包括根本没有进程时的 0——并且只中断 LLDB 报告为 running 的进程:附加发生时应用已经在运行,是唯一一种附加不会让它停止的情况。
- 进程必须先运行过才能被检查。 在调试器下启动(mode=launch)会真正启动应用,并在它有时间构建 UI 之后再附加。而改用 devicectl --start-stopped 时,转储返回 error: use of undeclared identifier 'UIApplication'——没有窗口,没有 Objective-C 运行时。
- 转储是一个表达式:po [[[[UIApplication sharedApplication] windows] firstObject] recursiveDescription]。windows 已废弃,但在 iOS 26 上仍能回答,它的第一个元素就是 key window。每个视图行的深度是其 < 之前的 | 数量,所以树是从缩进重建的,而不是信任美化打印器的输出。
选择项目
命名一个目录不足以命名一个项目。真实的 iOS 检出会提供好几个,而几乎所有的额外候选都是噪音:
Gemoy.xcworkspace ← 你要找的那个 Gemoy.xcodeproj ← workspace 已经包住的目标列表 Gemoy.xcodeproj/project.xcworkspace ← 在上面那个 bundle 里面 PodCache/MLeaksFinder/.xcodeproj ← 缓存下来的 pod OtherProject/YNLive/YNLive.xcworkspace ← 真正不同的另一个项目
所以路径字段是搜索,不是断言,搜索结果决定下一步:
- 命中一个 —— 直接采用,不再多问;
- 命中多个 —— 弹出选择器,按名称和相对路径逐条列出,让你在知情的前提下选择,而不是替你猜;
- 一个都没有 —— 直说,给一个空结果,而不是报错。
选中的项目会保留一个 Change 按钮,点它就能把选择器叫回来。两条规则去掉噪音:搜索永远不会进入 .xcodeproj / .xcworkspace 内部(它们是 bundle,不是装着其他项目的容器),而 .xcodeproj 如果和旁边的 .xcworkspace 同名,就当作 workspace 自己的目标列表丢掉。Pods、PodCache、Carthage、build、DerivedData、node_modules 和点开头的目录都会跳过,遍历有深度限制,还有总量上限,再大的目录树也拖不垮面板。
过滤与搜索
过滤和搜索是两个不同的问题,面板把它们分得很清。 过滤改变的是面板眼里存在哪些行 —— 它们消失了,而且面板可以问宿主,拿到浏览器已经不再持有的行。搜索什么都不改变:它只在屏幕上已有的行里标出命中项,然后带你过去。
过滤 —— 哪些行
两个维度,组合生效:
- 文本 —— 在失焦时提交,不是每敲一个键就提交。输入过滤条件是一个意图,执行它是另一个意图;每敲一个键就过滤,会每输入一个字符就重新查询宿主、重绘列表,模式还只写了一半,光标底下的日志就已经翻来覆去。按 Enter 或点击别处来应用(未提交时输入框描边为琥珀色)。Esc 清空。↑/↓ 翻看之前用过的过滤条件,但不会提交其中任何一个。
- 级别 —— 系统日志的四个级别,用它们自己的说法:verbose、info、warning、error(app 的 LogLevel 如何写入设备,见下表)。构建日志的行种类比这多,所以只在构建里出现的那些就归入所属级别:编译器的 note 是诊断的一部分,和 warning 放在一起;进度类输出(CompileSwift、Ld……)是最低级别;分节、测试结果和 "BUILD SUCCEEDED" 属于信息。一行的画法不变 —— note 还是灰色,构建完成还是绿色。开关立即生效,另有一个 Problems 快捷方式,表示「只看诊断」(它把 note 和对应的 warning 留在一起)。级别来自 lib/client.js 里的一张表,测试套件会遍历这些按钮,证明它们合起来能覆盖每一种分类器能产生的东西——没有按钮的那种,会是一条一旦关闭就再也无法显示的行。
文本过滤在主机上运行,不在浏览器里。面板保留日志的渲染窗口;主机每次运行保留最后 20 000 行。只搜索浏览器窗口的过滤器会悄悄漏掉大型构建的开头——而这恰恰是你需要过滤器的时候。. 按钮把模式切换为正则表达式;无效的正则表达式会回退到本地子串匹配,而不是在每次轮询时报错。
搜索——在这些行里的什么位置
⌘F(或 Ctrl+F)打开日志顶部的搜索栏。其余时间它是隐藏的:面板整整一行,花在一个通常只需一秒的任务上太浪费,而且这个快捷键浏览器自带的查找已经教会了所有人。它打开时,光标就在框里——再按一次 ⌘F 会选中已有内容,所以输入会替换掉它。空搜索栏在失去焦点时会自动关闭——点击别处,或按 Esc——因为空搜索框就是面板里空着的一行。这个判断在失焦时做出,绝不在按键时做出:删掉一个字符再重新输入,并不代表用户说他们完成了,而一个在编辑中途消失的搜索栏,会是一个你没法在里面打字的搜索栏。焦点移到面板另一个位置的同一个框(停靠和浮动可以同时挂载在同一个 store 上)也不算「离开」。搜索栏搜索屏幕上显示的内容,实时进行,不隐藏任何东西:
- 计数是实时的——3 / 17——因为「这个字符串到底在不在这次构建里」是你打字时想问的问题,让你按 Enter 才能知道答案,等于把过滤器的行为用在了错误的问题上。
- 每一处匹配都会高亮,你当前所在的行用不同的方式高亮,所以计数里的数字和屏幕上的标记始终一致。
- ↑ / ↓(以及 Enter / Shift+Enter)在命中项之间移动,并在两端循环;没有匹配时它们变灰,计数显示 no hits 而不是 0 / 0。
- 移动到某个命中项会把它滚动到视图中间,并停止视图跟随尾部——↓ Latest 按钮出现,这就是你回到观看构建的方式。只打字永远不会移动日志;只有要求跳转才会。
- 搜索词是字面文本,不区分大小写,绝不是模式:构建日志里满是 [、( 和 ,而 ( 必须能找到括号,而不是被编译。Esc 清除。
- 渲染窗口之外的命中项仍然可达:窗口(默认最后 2 500 行)会跟随当前命中项,所以按 ↓ 不会走进已不在 DOM 里的行。
↑/↓ 遍历的是每个框之前被赋予的内容
两个框各自保留自己的历史——过滤器的和搜索的是分开的,因为过滤器和搜索是不同的问题——↑/↓ 像 shell 那样遍历它:↑ 是回到过去, ↓ 是向前,走到最旧的条目就停住,不会绕回;在新条目之后再按 ↓,会把开始翻之前输入框里的半截文字放回去。只记住已完成的输入:过滤器在提交时记住(按 Enter 或点击别处),搜索在结束时记住(按 Enter 或 Esc)。所以 ↑ 永远不会给出正在输入内容的某个前缀,再打字就会结束这次翻阅——正因如此,↓ 才重新表示「我的草稿」。
机器上需要什么
运行 xcode_doctor 工具——或者打开面板,面板会用一行警告说明同样的事——就能看到这些工具哪些已就位,以及正在使用的 Xcode 版本。缺失的条目会连同安装它的命令一起报告;某个工具不存在,绝不会留到以后才以 spawn … ENOENT 的形式冒出来,或者变成一个怪到手机头上的「找不到设备」。经典通道的三个 formula 是 doctor 唯一会给出安装命令的可选缺口:缺了 xcbeautify 只会改变日志的读法,别的什么都不影响,所以只报告它,不会变成「去安装设备工具链」的建议。
| 工具 | 由谁提供 | 用来做什么 |
| xcodebuild、xcrun、xcode-select、plutil | Xcode 和 macOS | 一切 |
| idevice_id、ideviceinfo、idevicesyslog | brew install libimobiledevice | 查看、识别 iOS 16 及更早设备,并读取它的日志 |
| ideviceinstaller | brew install ideviceinstaller | 往这类设备上安装 |
| ios-deploy | brew install ios-deploy | 在这类设备上启动 |
| xcbeautify | brew install xcbeautify | 把构建输出变成每个任务一行,并标出警告和错误。可选,装了就会自动使用 |
这是三个独立的 formula,陷阱就在这里。 brew install libimobiledevice 既不提供 ideviceinstaller,也不提供 ios-deploy,所以照着那条显而易见的说明做,安装和启动都会坏掉:
brew install libimobiledevice ideviceinstaller ios-deploy
严格来说,设备工具是可选的——模拟器、macOS,以及 Xcode 通过 CoreDevice 管理的每台设备(iOS 17 及以后)都不需要它们。但如果你插上一台 iPhone X,它们就不是可选的了,因为 devicectl 根本看不到它:不只是被拒绝,它压根不在 xcrun devicectl list devices 里,所以这类设备过去会失败成「设备不存在」。
test/dependencies.test.mjs 会从 lib/index.js 自己的源码里扒出它能运行的每一条命令,只要有哪条没注册就报错,所以这张表不可能悄悄落后于代码。
安装
dsh plugin --profile web add /path/to/this/directory
然后重启配置档。
link: 安装还需要把宿主包也 link 上——否则 DSH 会掉进 Safe Mode
lib/index.js 从 @deepseek-ai/dsh-tools 导入 defineTool,当作运行时的值。宿主包被刻意设计成不是依赖:正常安装的插件会沿着目录树向上查找,进入 profiles/node_modules,部署时就把它们放在那里。
从目录做开发安装会记录成 link:,这会把插件的真实路径放到 profile 树之外的某个位置——于是向上查找永远到不了 profiles/node_modules,导入失败,插件树加载失败,而 DSH 把插件加载失败视为致命错误:它会恢复到 Safe Mode,禁用所有第三方插件。 线索在 ~/Library/Logs/DSH Desktop/harness.log 里:
plugin recovery detection: dsh-xcodebuild
[desktop] safe mode: third-party web profile bundles are blocked
加载器自己的报错有误导性——它报的是最外层的 specifier,因为它的重试路径吞掉了真正的错误:
Cannot find package 'dsh-xcodebuild' ← not the actual problem
Cannot find package '@deepseek-ai/dsh-tools' imported from .../lib/index.js ← the actual problem
所以每次 rm -rf node_modules 之后,都要运行:
npm run link-host
node_modules/ 被 gitignore 了,所以新克隆的仓库需要跑一次。想彻底避开这个脆弱的 link,可以把插件当成真正的包来安装——打包后添加 tarball,这样它就会落在 profile 下,宿主包在那里能自己解析到:
npm pack
dsh plugin --profile web add file:/absolute/path/to/dsh-xcodebuild-0.1.1.tgz
值得注意的行为
目标设备来自四个来源,而不是一个。 xcodebuild -showdestinations 描述的是 Xcode 自己那一层当前管理的东西,而在某台机器上,这一层和 CoreDevice 意见不一致时,它就不再列出一台已插上、已解锁、可构建的手机——实测环境是 iPhone 13 跑 iOS 26.6.2、Xcode 26.0.1,手机从 -showdestinations 里消失了,而 xcrun xcdevice list 仍然报告它 available: true,devicectl 仍然报告它的 tunnel connected,xcodebuild -destination 'platform=iOS,id=<udid>' 仍然能构建。只看列表的话,面板会给一台就摆在桌上的手机推荐模拟器。
所以每个通道都会被读取,并按设备 id 合并——xcdevice、devicectl,以及经典通道上的 idevice_id——映射成同一种记录结构,并在记录上标出哪些来源看到了这台设备。合并时可达性只会往上走:看不到某台设备的通道没有投票权,因为 tunnel 断了并不能证明手机不在了。任何通道都够不到的设备会留在列表里,被标记并排在最后,而且它永远不能成为默认值。
经典通道是 Xcode 自己那两层都卡死时唯一还能应答的通道,所以它最后读取,而且不加版本过滤:needsLegacyChannel 决定一次运行走哪个安装通道,这个决定每次运行都根据同一个版本做出——按版本过滤列表,正是之前在某台机器上把一台现代手机藏起来的原因,那里这是唯一还能看到它的通道。
- 派生数据归 Xcode 自己管,所以 Xcode.app 的构建缓存也能保持热乎。传 derivedDataPath 可以把它重定向到别处。
- 构建出的 .app 通过 -showBuildSettings 定位,而不是自己拼路径:用默认派生数据时,产物目录里带有一串按项目算出的哈希。
- Log lines are classified in-process (see lib/classify.js): the panel colours errors, warnings
and tasks, and the severity filter groups them, so the kind has to be attached to each line as it
arrives.
- xcbeautify is used automatically when the machine has it, and is invisible when it does not.
Nothing is configured: xcodebuild's output is piped through it, and the panel prints the pipeline
it is running. Three details make that safe rather than clever:
- Only the flags that version lists in --help are passed, and the resulting argv is then run
once on a trivial line before any build relies on it. A formatter that rejects its argv exits at
once and leaves the build writing into a pipe nobody drains; whatever that does to the build, it
is not something a log formatter is allowed to cause. When the probe fails, the formatter is
simply not used.
- NSUnbufferedIO=YES is set on the formatter. Measured on xcbeautify 2.28.0: with stdout on a pipe
rather than a terminal its own output is block buffered, so the panel would receive the whole
build log at the moment the build ended — no progress, and no errors while they still mattered. With it, lines arrive as the build produces them (measured at 0.8s / 1.5s / 2.2s against a producer printing every 0.7s, versus one 6000-byte burst at the end).
- --preserve-unbeautified is included, because xcbeautify silently drops the task lines it does
not recognize (CompileSwift, Ld) without it, and --disable-colored-output keeps escape
sequences out of a panel that does its own colouring.
Set DSH_XCODEBUILD_NO_BEAUTIFY=1 to leave it off on a machine that has it. If the formatter dies
mid-build the run keeps going with raw output and says so on its own line.
- Archives land in ~/Library/Developer/Xcode/Archives/ so Xcode's Organizer lists them.
- A workspace is asked twice for its configurations. xcodebuild -list -json -workspace X.xcworkspace
answers with { workspace: { name, schemes } } and reports no configurations at all — the configurations belong to the .xcodeproj inside. Asking only the workspace therefore makes every project look like it has nothing but Debug and Release; a project that also builds Test-Release silently loses it from the picker. listSchemes follows up with the wrapped .xcodeproj (named after the workspace, or the only one in the directory) and merges the two answers. The merge is a pure function in lib/listing.js, pinned against real captured output by test/list-schemes.test.mjs.
- run picks its install tool from the destination, not from a default.
platform=iOS Simulator,… installs with xcrun simctl, platform=macOS,… with neither (the built .app is already the runnable artifact), and platform=iOS,… splits further by asking the device its own version. simctl cannot see hardware at all and answers Invalid device: <udid> — which is how run used to report failure after a successful device build.
- Two device channels, split by iOS version rather than by the shape of the id. devicectl is
CoreDevice, and CoreDevice begins at iOS 17: an iPhone X on iOS 16 is not merely refused there, it
is absent from devicectl list devices, so an install against it failed as a device that does not
exist. The same generation is still reachable over the classic lockdown protocol, so the device's
own ProductVersion decides (< 17 → libimobiledevice). The threshold governs the destination
list too, because -showdestinations omits exactly the devices CoreDevice does not manage;
showDestinations appends what xcodebuild left out, so hardware that is not yet prepared for
development can still be chosen at all.
- Both channels launch as an attached session, and the modern one was wrong about that. A one-shot
devicectl device process launch returns in a second and leaves the panel with nothing to read: the
app's own logging only exists while something is connected to it. --console is the modern equivalent
of the classic attached session — it connects the app's standard streams and waits for it to exit — and measured on the iPhone 12 it put 128 KB of the app's own [I]/[D]/[V] lines into the panel in the first twelve seconds, live, in the same format the classic channel only ever had in a file. A signal sent to that session is forwarded to the app, and which one ends it was measured rather than assumed: devicectl ignores SIGINT (it survived it, and so did the app), while SIGTERM terminates the app — devicectl reported App terminated due to signal 15 — and then ends devicectl itself. ios-deploy's handler treats SIGTERM like SIGINT and SIGKILLs its own group, so one signal now means the same thing on both channels: Stop sends SIGTERM, escalates to SIGKILL after five seconds if the session has not ended by itself — a hung devicectl would otherwise be a run that can never leave running — and settleAttachedRun settles both channels the same way, with the run running for as long as the app is.
- The modern container reader is a different tool, and it is 55 times faster. iOS 17 and later reach
the app's data container with devicectl device copy from --domain-type appDataContainer
--domain-identifier <bundleId>, which needs no debug session and no developer disk image: measured,
the whole log directory — four files, 348 KB — arrived in 0.9 seconds, against 50 seconds for the 2.4 MB the classic ios-deploy --download moved. That is what makes it a fallback rather than a last resort, and it is armed only by continued console silence: devicectl's own launch line arrives whether or not the app ever writes, so the app's own line — the one carrying its pid — is the proof that the console is carrying its logging, and a reader that starts anyway would put every line in the panel twice. Both channels read the same file with the same helpers in lib/legacy-applog.js, because it is the same app writing it.
- Every look at the device list shows the cache and then refreshes it. A test bench plugs and
swaps hardware all day, so the list has to be re-read on every look or it goes stale invisibly — and xcodebuild -showdestinations takes seconds, so waiting for it before drawing anything is what made the control feel dead. Both happen instead, in that order: the cached list is painted immediately with its age on screen (cached 2m5s ago · refreshing…, in the stale colour), and the command runs regardless and replaces it. A look at the list is a refresh — the panel, adopting a project, changing the scheme, opening the dropdown and the ⟳ button each re-read it, and the only thing skipped is a second read while the first is still running. A read that lands while the dropdown is open is held and applied when it shuts, because rebuilding an open 's options can close the list the user is choosing from. The cache is keyed by scheme, because destinations follow the scheme — a scheme that only builds for simulators must not be shown another scheme's hardware. The selection is re-validated against the live list, so a phone unplugged since the cache was written disappears from the list and the selection moves to the host's recommendation instead of pointing at nothing.
- The list is ordered, and re-ordered on every refresh. xcodebuild's own order is neither grouped nor
stable for a bench: platform:macOS is printed first, and the iOS 16 devices this plugin discovers
itself are appended after everything — so a phone plugged in a moment ago appeared last, below a dozen simulators, exactly when it was the thing being looked for. sortDestinations imposes a total, deterministic order — connected hardware, then simulators, then a Mac; concrete entries before generic placeholders; names read the way a person reads them (iPhone 9 before iPhone 12, numbers before letters so iPhone 16 precedes iPhone X); newest OS first; and the host's own order as the final tiebreak, so two records that agree on everything never swap under the pointer. Being total is what makes a newly connected device take its place in the list rather than appearing at the end.
- The legacy install and launch are two different tools on purpose.
ideviceinstaller -u <udid> -i <app> installs. Launching uses
ios-deploy --noinstall --noninteractive, because ios-deploy's own install path fails against this
generation of AMDevice with 0xe8000067 while its lldb-driven launch works — --noinstall is the entire point of the pairing. Neither tool reports its outcome in words, so neither is read for one; the launch witness below is what decides. Both tools are brewed rather than shipped by Xcode, so they are resolved from the Homebrew prefixes instead of assumed to be on the PATH the harness was launched with.
- A launch is a launch only when something says the app is running. What decides is the app's own
new log file: an iOS 16 app writes Documents/PPCrashLog/log_<timestamp>.log as its process starts, so
a name that was not in the container before the launch is a device fact, not a claim. Nothing
ios-deploy prints can be read instead — the success line comes from str(startup_error) the moment
Launch() returns, i.e. before the process is known to be alive, and the message that would contradict
it is never printed at all, because Python block-buffers stdout when it is a pipe and os._exit
discards the buffer (python3 -c "print('x'); import os; os._exit(1)" | cat captures zero bytes). The
old exitCode !== 0 && !/^success$/m.test(stdout) test let that line veto a real failure and reported
every legacy run as launched, with artifact.pid === null and nothing on the device. A run whose
install or launch fails reports failed, with the reason in note and errors, even though
xcodebuild itself exited 0. lib/legacy-launch.js holds the wording of that failure, and
lib/legacy-applog.js the witness it is built on.
- --justlaunch is not passed, because it interrupts the app it just launched. It implies --debug
(ios-deploy.m:3688-3692) and then makes the attached path unreachable (:3400-3401): lldb runs the app,
safequit detaches, the CLI returns — and on a real iPhone X the app comes up and is closed again as that happens, which is the "launches, then instantly goes away" symptom. An attached session is what keeps it up, so the launch is spawned detached, in the background, with its console redirected to a file, and the run stays running for as long as the session is attached. The flag is
--noninteractive (-I) rather than --debug: it needs no stdin — a detached child has none — and
autoexit_command reports the app's own fate as machine-readable markers (PROCESS_EXITED,
PROCESS_CRASHED, PROCESS_STOPPED, PROCESS_DETACHED, PROCESS_NOT_STARTED) with a stack trace
under the bad ones, which the panel colours as errors. -O/--output is still not passed: measured on
the iPhone X with -I -O <file> -E <file>, both files stayed at 0 bytes while the app's own log
grew, because the app's output reaches the console through lldb rather than through the process's
stdout — the console file this plugin redirects is the real capture, and the app's own log below is the live one. Stop ends it by signalling the child's process group, which is also why the child is detached: ios-deploy answers SIGTERM/SIGINT/SIGHUP by SIGKILLing its own group (:1460-1464), so a child sharing this host's group would take the host down with it. -O/--output is still not passed — that file is opened only by
autoexit (lldb.py:103-106, ios-deploy.m:1143-1156) and would be an inert flag pretending to capture a
console — the plugin redirects the child's own stdout and stderr instead.
- An app that dies on the phone is a verdict about the run, not a line in its log. This was a real
bug and the ugliest kind: a launch that crashed settled green. The settlement asked one question — did the app ever come up? — and a crash answers that with a yes, so a crash and the user quitting the app were the same input, and the run kept a note claiming the app was up while the phone held no such process. lib/app-death.js is the reader that replaced it: the console's own evidence decides, both channels at once — ios-deploy's markers (whole-line only, because "PROCESS_CRASHED because of reasons" is an app telling its own story), the runtime's last words (* Terminating app due to uncaught exception, Fatal error:, Swift runtime failure:, libc++abi: terminating, a C assertion, dyld refusing to bind), and a debugger stopped on EXC_…/signal SIG… but never on a breakpoint. A death outranks a quieter line that follows it, because ios-deploy reports whichever it saw last and prints the stack trace under the marker. Two rules keep it honest: presence decides, silence never
does** (a console that says nothing about the end yields no verdict — an unplugged phone is not a
crash), and PROCESS_STOPPED counts as a death because ios-deploy only prints it for a stop with a
reason, which is what keeps an app the system merely suspended out of the set. The panel shows it the
moment the console says it — the run keeps streaming, since ending it there would cut off the trace — with a red dot, app died where the status row said running, and the reason set apart in red. A clean exit stays a success and says the app exited, and Stop outranks every marker that follows it: the app died because the panel ended the session, and blaming the app for our own signal would be the same lie in the other direction. test/app-death.test.mjs pins the reader, and test/client-interaction.test.mjs pins what the panel does with it, in both directions.
- The console is pulled into the panel by a separate reader. lib/log-tap.js reads the file the
launch writes to, every 300ms, and pushes complete lines into the run log the panel already streams:
a fragment at the end of the file is carried rather than printed, a multi-byte character split across
two reads is decoded with a streaming decoder, and a file that shrank (> instead of >>, a rotated
log, a reused path) is read again from the start. The file stays on disk after the session, and the
reader cannot block the launch or lose output the way a closed pipe would. Reading a file that a
long-lived child owns is the whole reason this exists: nothing awaits that child.
- The panel's live reader is the device's own log relay, not the app's log file. The obvious source
is the file the app appends to in its container, and it is the wrong one to read on a timer: measured on
the iPhone X, ios-deploy --download of Documents/PPCrashLog took 50 seconds for 2.4 MB, of which
the current launch's log was 2 MB after ten minutes of running. Nothing about that reader can be
made fast — it moves the whole file to learn one new line, and the file only grows. idevicesyslog -p <app> instead streams lines as the app writes them: six seconds of that same app produced 289 lines. lib/syslog.js turns the stream back into text (idevicesyslog escapes every byte outside ASCII in the cat -v notation, so \M-p\M^_\M^_\M-) is 🟩 and \M-e\M^F\M^E is 内), and that decoder was checked against an independent implementation over 288 captured lines with zero disagreements. Severity the device declared itself is the only thing painted as a diagnostic, and the app's system log has exactly four levels, read back the way the app writes them:
| the app's level | written as OSLogType | on the relay | the panel |
| verbose (its .verbose/.debug) | .debug | <Debug>: or [V]/[D] | verbose |
| info | .info | <Info>: or [I] | info |
| warning | .default | <Notice>: or [W] | warning |
| error | .fault | <Fault>:/<Error>: or [E] | error |
.default is the one that has to be spelled out: the device calls it <Notice>, which reads like an
ordinary line and is in fact the app's warning — the mapping is osLogType(_:)'s, and the token to level table in lib/syslog.js was read off libimobiledevice's own source rather than guessed (its os_trace path prints the header's raw level: 0 Notice, 0x01 Info, 0x02 Debug, 0x10 Error, 0x11 Fault). The app also marks every line with its own level itself, and that spelling is read too, because it is the only one the modern console carries: measured on the iPhone 12, a launch line reads … Demo-Dev[29303:4901955] 🟦 [I] 11:41:19.145 PPTaskQueue[38] cpu:7 内存:40. A line that declares no level at all leaves the question open for the text classifier, rather than being called verbose on no evidence. The feed is spawned with -x, so a phone that goes away ends the reader instead of leaving a process that looks alive and delivers nothing, and it is reconnected a few times before anything else takes over — but a stream that never delivered a line is not retried three times, because that is a relay refusing the device rather than a dropped cable: measured, idevicesyslog connects and exits immediately on an iPhone 12 running iOS 26.6.2, the same version split that routes that device to devicectl instead.
- The container-file reader is the fallback, and only one of the two ever runs. They carry the same
lines in different formats, so running both would double every line in the panel. When the live stream
cannot be had — an older toolchain, a relay that refuses, a phone that keeps disconnecting — the pump starts instead (startAppLogPump, with the marker-based diff in newLinesSince): it downloads Documents/PPCrashLog and streams what the newest log gained, no longer pausing ten seconds on top of a fetch that already takes tens of seconds, and retrying every five seconds rather than every second when the fetch fails, so a disconnected phone cannot turn it into a spin loop. The diff cannot count lines or track an offset, because each look is a fresh download of a file whose tail window slides; the last line already pushed is the marker, and a marker that has scrolled away is reported as "cannot tell" instead of reprinting a tail as if it were new.
- The console file is still read, and lldb's buffering is defeated by the environment, not by a flag.
lib/log-tap.js reads the file the launch redirects its console to, every 300ms, and pushes complete
lines into the same run log: a fragment at the end of the file is carried rather than printed, a
multi-byte character split across two reads is decoded with a streaming decoder, and a file that shrank
(> instead of >>, a rotated log, a reused path) is read again from the start. That reader is why the
launch does not have to be awaited, and the file stays on disk after the session. What it cannot fix is
lldb's own buffering: its output goes through an embedded Python that block-buffers when stdout is a
file, and measured, the console file sat at a half-written line for six minutes while the app logged.
A pty would line-buffer it, but the sandbox refuses openpty (which is what script needs), so the
child gets PYTHONUNBUFFERED=1 — verified to turn "nothing until exit" into "a line per print" — which is what makes the lifecycle markers (PROCESS_CRASHED and friends) arrive while they are news.
- The lock is asked of the device, and only true stops a run. Both channels answer the same
question — is a passcode being demanded right now — with their own tool. On iOS 16 and earlier that is ideviceinfo -k PasswordProtected; on iOS 17 and later, where the destination is a CoreDevice identifier that libimobiledevice cannot resolve, it is xcrun devicectl device info lockState, read from the --json-output file because devicectl documents that file as the only interface meant for a program. Both are device facts: no developer image, no pairing session, nothing that has to be up. The classic key is not legacy-only either — measured, an 00008110-... device answers it over the network (-n), so devicePasscodeRequired retries with -n when the plain lookup says "not found". A true stops the run before the launch, with the field it read named in the message; false launches, and so does an answer that could not be read, because a question that went unanswered is not evidence of a lock. PasswordProtected=false is not "the screen is on": this iPhone X reports false while its screen is locked, since no passcode is set, so the key answers "is a passcode being demanded" rather than "is the display lit". lib/lock-state.js holds both parsers and that rule.
- Every launch tidies up after the last one. Three kinds of file pile up in the temporary
directories and none of them is removed by whoever wrote it: a console file per attached launch
(<tmp>/dsh-xcodebuild/xr1-<epoch>-ios-deploy.log, which grows for as long as the app logs and whose
name repeats because run ids restart at xr1), a <tmp>/<UUID>/ per launch from ios-deploy — its prep-cmds path is a hard-coded #define PREP_CMDS_PATH @"/tmp/%@" (src/ios-deploy/ios-deploy.m:29), so that litter lands in /tmp whichever process starts it — and an app-log download directory that survives only if the host is killed mid-read. So a launch prunes first, from lib/session-files.js: the newest five console files stay (age then overrides keeping, at three days), and a leftover directory goes only once it has not been touched for an hour — a session being started writes its files within seconds, so silence that long is proof it is gone. Deletion is also attribution, never just age: a console file must carry a launch stamp, and an ios-deploy directory must name this device in its fruitstrap_<udid>.py/fruitstrap-lldb-prep-cmds-<udid> files. A file someone put in the plugin's own directory on purpose, another device's session, and macOS's own temporary directories are all left alone. Measured on the real /tmp: a three-hour-old directory for this device was deleted while the same-age one for another device, a ten-minute-old one, and the running session's own directory all stayed.
- idevicescreenshot is not a lock oracle, and using it was a mistake worth recording. It reports
Could not connect to screenshotr! on a locked iPhone X, which makes it look like one — but screenshotr needs the developer disk image mounted, and mounting that image is exactly what ios-deploy's own launch does. The probe therefore failed for a reason the launch would have fixed, and it refused launches that would have worked.
- The app's own log is the second positive witness on the classic channel. Alongside the pid probe,
the launch samples Documents/PPCrashLog/ before and after and reports a file that was not there
before. It is the strongest evidence available because it does not depend on the toolchain answering
anything, and it obeys the same rule as the pid: presence decides, silence never does — the file is written as the process starts, so it can lag the detach. artifact.appLog names it when it appeared.
- A physical device's log is not a simulator's, and an iOS 16 device has a third route. There is no
simctl spawn on hardware, and idevicesyslog keeps no history to query — it only relays the syslog live. On an iOS 17 and later device xcode_device_log therefore returns a bounded live window and says so, rather than implying a snapshot the device never had.
- The app's own log is the one that survives, so on iOS 16 and earlier that is what is read.
CoreDevice cannot reach that generation at all, so xcrun devicectl device copy from — the route the modern channel uses to move files — does not exist there. The app does keep its own log, one file per launch, at Documents/PPCrashLog/log_<timestamp>.log in its container, and xcode_device_log reads the newest one when it is given bundleId (which is what selects the container). Two device facts decide the implementation, both measured on the iPhone X while it was locked: ios-deploy --list is a silent no-op on this toolchain — exit 0, zero bytes, even for a path that does not exist — so the directory is downloaded whole (--download=Documents/PPCrashLog --to <dir> --non-recursively, 3.4s for 420 KB) and enumerated on the host; and the download does not preserve the device mtime, so the file NAME, which the app stamps with its own launch instant, is the ordering key. Because a launch that failed leaves its evidence in the file rather than in the tool's output, this is the log that answers "it says it launched, so where is the app" — and it can be read while the device is locked, which is exactly when a launch cannot be attempted at all. lib/legacy-applog.js holds the pure half.
- The panel's default path is the session's workspace, read from ctx.sessions.get(id).header.cwd.
The host's own process.cwd() is the harness directory and has nothing to do with the project the
user opened, so the client sends its sessionId with every request. The path field is seeded once
the answer arrives, and never overwrites what has been typed.
- Naming that session is a two-seat job, and neither seat invents one. The dock tab's scope
carries the session and is authoritative, but a tab opened without a scope carries nothing — and session adoption used to be gated on the header seat also displaying the panel, a gate that closes permanently once better-sidebar owns it. The panel was then left with no session, the host was asked to guess, and it answered with its own launch directory: the visible result was a search of …/dsh-desktop/launch-root and "No .xcworkspace or .xcodeproj under launch-root". Now the header seat — mounted in every shell, and the one seat that always knows which session it was rendered for — names the session whenever the dock cannot, while an unscoped tab never clears what the other seat established. On the host side workspaceFor returns '' when no session can be resolved rather than the process directory, so the worst case is an empty path field the user can fill, not a wrong directory the panel searches on their behalf.
- Parked above the tail, the panel offers the way back in one click. A semi-transparent button
floats at the bottom right of the log whenever following is off, saying how many lines have arrived
since the reader stopped — which is the difference between knowing it is worth clicking and guessing. One click puts the newest output on screen and re-arms following, which is what dragging a scrollbar to the end was doing by hand. It is anchored to a wrapper rather than to the scrolling element, because a button inside the scroller would scroll away with the content it exists to escape.
- The log follows the tail until the reader leaves the bottom. Returning to the bottom — and letting go there — re-arms it, so the panel keeps showing the newest line. The tolerance is 2px on purpose: with slack, parking a line or two above the end while reading would silently re-arm and yank the view away.
- Clear empties the output, and the filter has its own clear. They cannot be the same button:
while a filter is active the rows come from the host searching the whole run, so clearing only the
local buffer would let the next search pull every discarded line straight back. Clear therefore
also sends a line-number baseline (since), and Esc clears the filter.
- Reading the log is not the UI's job. Polling starts in apply(), not from a component's
effect. Tying it to a surface made the log's freshness depend on which slot happened to be
mounted, so a build could be running with nothing advancing the view. Every surface can now come
and go without the log stalling, and a component that renders nothing (the retired header entry)
no longer adopts a session out from under the surface the user is watching.
- A connected device is the default destination, and a remembered one outranks both. The
order is: what this project used last time, then connectable hardware, then a simulator, then
macOS. Hardware leads because a plugged-in phone is the thing being tested on; the previous
policy preferred a simulator, so the panel offered one with a device attached. The preference is
validated against the live -showdestinations output, so hardware that has since been unplugged
falls through instead of being offered stale. The host owns this order and the panel adopts the
recommended it is given, so the panel and the xcode_destinations tool cannot disagree.
- A workspace remembers its project, scheme, configuration and destination. Switching away and
back used to reset the panel, so the same four choices were re-made every time. The memory lives
in localStorage keyed by workspace root — the only store that survives both a page reload and a DSH restart — and a browser that refuses storage loses the convenience, never the panel. A remembered project is adopted outright only when it is an exact match among the candidates; with several unseen projects the user still chooses, because guessing there would silently build the wrong one. A remembered scheme or configuration the project no longer offers is skipped rather than carried over.
- Opening the panel searches the workspace by itself. It used to land on a path with nothing
behind it, so the project only appeared after clicking into the field and back out, or pressing
search — a step the user had to remember every single time. The workspace is already known, so searching it is not a decision to hand over. It runs once per workspace, never on every repaint: the flag that gates it is what keeps a filter keystroke from starting another walk of the directory.
- A workspace seen before paints from the store instead of walking the directory. What is cached
is the project's own description — its location, schemes and configurations — which changes only when the project is edited. The host is still asked in the background afterwards, so a project edited on disk corrects itself, and if the stored location has gone stale the panel falls back to a real search rather than showing an error where the project used to be. Destinations are deliberately not cached: hardware comes and goes, and a remembered device list would hide a phone plugged in since.
- The panel names the revision of the host half it reached. The profile mounts the host half
once at boot and keeps it in memory, so an edit on disk is invisible until DSH restarts — and a stale process is indistinguishable from a broken fix from inside the UI. The harness log records warnings and errors only, so an info line would never be seen; /state therefore carries the
source mtime and the panel renders it as a dim rev <instant>. Three separate reports in one
sitting were all the same stale process, each diagnosed by hand-comparing timestamps.
- /start answers at once; the run carries on detached. That route used to await the whole
run, which held a single HTTP request open for the entire build and — because the workspace tag was applied only after that await — left /state unable to report an active run while the build was happening. The panel therefore never polled, and a successful Build & Run showed neither an install nor a launch: those lines were written into a log the client had already stopped reading. The run is now tagged when it is created and returned immediately, with run.done settling when the work has. For the same reason the status stays running through install and launch — a poller stops at the first non-running status, so publishing succeeded before the product was on the destination is what hid the second half of the action.
- A poll still in flight is not overlapped by the next tick. logNext advances only once a
response lands, so a /poll slower than the 500ms interval used to let the following tick ask for
the same range and append it again: the same line twice, duplicate React keys, and a log growing
twice as fast. The guard is per instance deliberately — each window that owns a log owns its poll.
- Build & Run is labelled for what it does. The action is a build followed by an install and a
launch; a button reading only "Run" hid the expensive half, which is also the half that fills the log.
- The install step reports itself. run tees devicectl/simctl output into the log as it
arrives, so a device install shows Acquired tunnel connection… and App installed: bundleID: … rather than a command line followed by silence. Collecting that output instead made a slow install look like a hang, and left the real reason visible only on failure.
- run refuses Xcode's generic placeholder. Any iOS Device names no hardware; passed on to
devicectl it fails as a device-not-found, which points at the wrong thing. It is reported as the
placeholder it is, and the fix is to pick the connected device.
- Each workspace's panel is its own. State is kept per session rather than shared, so opening a
panel in one project never shows another's scheme, build or log; and a run records the workspace that
started it, so the panel only ever lists — or silently adopts — its own. A run started by a tool carries no workspace and is claimed by none: the panel is for the builds the user starts.
Layout
lib/ring.js fixed-capacity per-run log buffer (O(1) push; see the test for why)
lib/classify.js one kind per log line — drives colour, filtering and the error summary lib/beautify.js xcbeautify's flags, the escape sequences to strip, and the pipeline line shown lib/projects.js which Xcode project a directory holds, and which ones it offers lib/parse-destinations.js -showdestinations parsing, destination kind, and the order the list is shown in lib/listing.js folding -list -json answers (a workspace needs two) into one listing lib/legacy-launch.js whether an iOS 16 launch actually happened — pure, so the success trap stays pinned lib/legacy-applog.js which log_<timestamp>.log is the newest launch, and where the AFC download lands it lib/lock-state.js whether the device is asking for its passcode, per channel lib/syslog.js the live feed: the device log relay's argv, its escaping, and its severity lib/modern-launch.js the CoreDevice launch: the console-attached argv, and how a launch is witnessed lib/session-files.js which files a previous launch left behind, and when they may be deleted lib/log-tap.js the separate reader that pulls a live launch's console into the panel lib/index.js host: run registry, the six tools, the panel's JSON routes lib/client.js browser: header toggle, dock tab, overlay fallback test/ plain-node suites (no test framework) run by npm test
Tests
npm test # ring buffer, classifier, destinations, project search, listing, client, device channel
npm run test:mount # mounts the host half against a fake context (needs one link, see below)
npm run test:live # real build against a real project (slow; needs Xcode)
- test/ring-buffer.test.mjs imports the shipped lib/ring.js (not a copy) and cross-checks it
against a reference implementation over 300 randomised trials, then measures the eviction path the
buffer exists to replace.
- test/classify.test.mjs pins the classification rules against real xcodebuild output — including the file:line:col: note: form that a line-start anchor never matches.
- test/beautify.test.mjs runs the real xcbeautify where it is installed: that it accepts exactly
the flags passed to it, that its output still classifies as errors, warnings and tasks, that
--preserve-unbeautified keeps the task lines it would otherwise drop, that a line is delivered
while the build is still running rather than in one burst at the end, and that a formatter which
dies mid-build leaves the run reporting the build's own exit code.
- test/destinations.test.mjs parses captured -showdestinations output for a 24-scheme
CocoaPods workspace. Two bugs are pinned here: a value can contain a comma
(variant:Designed for [iPad,iPhone]), so fields must be split on commas that begin another
key: rather than on every comma; and platform:macOS is printed FIRST, so a "first entry wins"
default silently targets My Mac for an iOS app.
- test/find-projects.test.mjs builds a synthetic checkout holding exactly the noise a real one
does — a wrapped .xcodeproj, a workspace inside a bundle, Pods, PodCache, a dot-directory, a project past the depth limit — and pins which of them a search may offer.
- test/client-interaction.test.mjs mounts the browser half in jsdom and drives it, in all three
seats: as a better-sidebar tab (asserting the contributed descriptor and that the header button opens
the tab and mirrors it), as a tab of the shell's own right sidebar (asserting the registered type — id, kind, band, title, Guide capsule — that the body renders the docked panel, that the tab names its session, and that the header button goes away), and as the overlay fallback (open, close, reopen, and commit a filter on blur). The two sidebars are also mounted together in both arrival orders, proving the fallback seat is given back when the dock turns out to be in charge. It also walks the picker: two candidates are offered, clicking one adopts it, and Change brings the choice back. The device list is driven the same way, with a transport that answers late on purpose: the cached list and its age are on screen while -showdestinations is still running, the command is issued anyway, and the live list then replaces the cache — including a selection that no longer exists. This one earns its keep twice over — it caught the store draining its subscriber list on the first emit, which left every later click deaf.
- test/destinations.test.mjs pins the order the device list is shown in — hardware before simulators before a Mac, concrete before generic, natural name order, newest OS first, and the host's order as the final tiebreak — and then runs the same sort over the real 100+ entry fixture, asserting that every device precedes every simulator and that the list opens on concrete hardware.
- test/dependencies.test.mjs scrapes every externally-run command out of lib/index.js and fails if one is not in DEPENDENCIES with a purpose and an install command — the table above cannot silently fall behind the code. test/device-channel.test.mjs pins the CoreDevice boundary at iOS 17 — the real iPhone X (16.7.12, iPhone10,3) that exposed it included — and the rule that an unreadable version is never routed to the legacy channel, because a wrong guess that way shells out to a toolchain that may not be installed at all.
- test/app-death.test.mjs is the reader that tells a dead app from a finished one: ios-deploy's
markers as whole lines, the ObjC/Swift/libc++abi crash banners, a debugger stopped on EXC_BAD_ACCESS
(and not on stop reason = breakpoint 1.1, which would fail every run that hits a breakpoint), a
crash that a later PROCESS_EXITED may not downgrade, and the two silences that must stay silent: an
app logging about crashes, and a console that never mentions the app's end at all.
- test/legacy-launch.test.mjs feeds the launch verdict the exact tail a real iPhone X produced — success, then safequit, then nothing, exit 1 — and asserts it is a failure now that it means a session which ended before the app wrote its launch log. Its other half guards the opposite mistake: a silent --get_pid must never fail a run, because that probe reports nothing for a running SpringBoard either. The session's lifecycle markers (PROCESS_CRASHED and friends) are pinned as errors, so a crash cannot read as ordinary prose in the panel.
- test/legacy-applog.test.mjs pins the app-log route against the device it was measured on: the real
Documents/PPCrashLog listing (three launch logs plus watchdog_stall.log, which must not be
mistaken for one), the newest-by-name rule across an hour boundary, the exact ios-deploy --download
argv, the local path that download produces under --to, the byte ceiling, and the marker diff the
pump uses — including a repeated line, which must match its last occurrence so nothing already pushed comes back.
- test/host-mount.test.mjs mounts the plugin and asserts what it contributed: six tools with
correctly compiled schemas, nine routes, and the route guards (405 / 400 / 200 / 500).
- test/lock-state.test.mjs pins both lock oracles against real output: the classic false the iPhone X
printed while its screen was locked (which is why false never becomes "the device is unlocked"),
the 00008110-... device that answers the same key over the network, and the passcodeRequired JSON
devicectl writes. The refusals are checked per channel too, so the classic one keeps its 43s cost and
the modern one does not borrow it.
- test/modern-launch.test.mjs pins the modern channel against the iPhone 12's real output: the
--console argv, the container-copy argv with its domain pair, the app's own line recognized by name
and pid (and a name like App+Pro staying literal instead of becoming a quantifier), the two
witnesses and why the app's own line is the stronger one, and that a failed launch is not witnessed.
- test/syslog.test.mjs pins the live feed's decoding against lines captured byte-for-byte from the
iPhone X: the emoji and the Chinese, the caret form that carries no dash (\M^_ against \M-p, whose
confusion silently eats three of a character's four bytes), a lone high byte reading as the replacement
character rather than being dropped, an unrecognised escape surviving verbatim, the process name coming
from the bundle, and <Error>/<Fault> being the only severity that paints a line red.
- test/session-files.test.mjs pins the cleanup policy as decisions about names and times: the launch
stamp inside a console file's name beating its mtime, the keep-five rule, age overriding keeping, the
one-hour boundary from both sides, and the three-way distinction that keeps a prune from being a
danger — an ios-deploy UUID or this plugin's download versus macOS's own temporary directories, and one device's fruitstrap files versus another's. test/prune-session-files.test.mjs then runs the same prune against a real tree and asserts what survived as much as what went: notes.txt in the plugin's own directory, a two-minute-old session, another device's, and a marker-less directory all outlive it.
- test/log-tap.test.mjs pins the reader against the shapes a console file takes while it is being
written: a partial line carried and joined to the next read, a Chinese line split mid-character by a
byte boundary, \r\n, one line that never ends and must not be buffered without bound, and a file
that shrank under the reader.
- test/live-gemoy.mjs drives the real tools against a project and checks a successful build, the
log volume, warning classification, incremental reads, and the -showBuildSettings lookup.
The client test borrows react and jsdom from the deployment, so it expects the app at the
default /Applications/DSH Desktop.app path. The host half imports @deepseek-ai/dsh-tools, which
the running harness aliases for a mounted row rather than installing into the plugin. test:mount
therefore needs it resolvable — link the deployment's own copy, not a second install, or the module instance under test is not the host's:
mkdir -p node_modules/@deepseek-ai
ln -sfn "/Applications/DSH Desktop.app/Contents/Resources/app/node_modules/@deepseek-ai/dsh-tools" \
node_modules/@deepseek-ai/dsh-tools
node_modules/ is gitignored, and the runtime never needs it.
Security
The panel's routes are served by the profile's webServer, and every request is put through the
composition's connection.requestRejection first — the Host/Origin fence that defeats DNS rebinding and cross-site calls, plus the browser auth cookie. No bespoke trust logic lives here, so it cannot drift away from the rest of the GUI.预览分享卡片长什么样 ↓
分享 · DeepSeek Harness Hub
harde1/dsh-xcodebuild
构建测试归档并运行 iOS 与 macOS 项目
相关插件 · 开发 / 工程
隐私简历生成器reactive-resume/reactive-resume
Reactive Resume 已迁至 GitHub 上的…
★ 45,125已汉化MCP
71.7
AI 架构文档生成器sopaco/deepwiki-rs
🚀 Litho 已进化为 Terrain —— 给你的 AI 智能体一张会呼吸的代码库地图。
★ 3,157已汉化DSH
70.8
长时任务执行闭环AMAP-ML/LongHorizon-Harness
面向 Computer-Use Agent 的 Loop Engineering
★ 1,764已汉化DSH
70.5
AI 编程纪律方法包GanyuanRan/Aegis
让 AI 编程 agent 变得可信:少返工、更安全、说“完成”前先给证据。
★ 1,384已汉化DSH
69.6
多代理编排运行时Enderfga/claw-orchestrator
面向编程智能体的运行时。把 Claude Code、Codex、Antigravity、Grok…
★ 586已汉化MCP
69.4
记忆进化插件csyangwen/dsh-memory-evolve
dsh-memory-evolve 使用场景指南
★ 461已汉化DSH
67.9