Repository navigation
v0.3.12
视频下载直出 MP4(装了 ffmpeg 就用它拉 HLS,音轨分离站点不再丢声音,没装/直播/
失败自动回退内置下载器);Agent 聊天修掉两个只在流式中间态复现的致命问题
(内联样式失效索引导致的崩溃、块解析在一行"1. "上的死循环);聊天面板回车
不再刷 59 条 SwiftUI "Publishing changes from within view updates";Xcode 警告清零。
Added
- HLS 下载优先走系统 ffmpeg,直出 MP4(用户提议:"如果系统安装了 ffmpeg 是否
考虑直接使用 ffmpeg 来下载 m3u8 并且直接转为 mp4"):新增
Features/Downloads/FFmpegExporter.swift,导出前探测 ffmpeg(/opt/homebrew/bin、
/usr/local/bin、/opt/local/bin、/usr/bin——GUI 进程的 PATH 不含 Homebrew,
必须显式探测)。不内置 ffmpeg(GPL/LGPL 的独立项目,不该打进 app 包)。- 判定顺序:HLS 且
EXT-X-ENDLIST(VOD)→ ffmpeg 直连;没装 ffmpeg / 直播 /
ffmpeg 失败 → 原来的内置下载器,行为不变;内置路径产出.ts时若机器上有
ffmpeg,再本地转封装成 MP4(直播因此也能拿到 mp4)。 - 音轨分离(
EXT-X-MEDIA)站点不再丢声音:这类站点的媒体播放列表里只有视频,
只有把 master 交给 ffmpeg 才会把AUDIO="…"的音轨接上(实测 master →
h264+aac,只给 variant → 只有 h264;内置下载器正是后者,属于原有缺陷)。 - 码率上限(桥的
maxBandwidth)用-map 0:p:N选第 N 个 variant(program 顺序
= 播放列表文件顺序,不是按码率排序的),并连带它的音频组;不限速时交给
ffmpeg 自己挑(实测它选最高码率)。 - 防盗链走
-referer/-user_agent;-progress pipe:1 -nostats解析
out_time_us换算进度(总时长取播放列表EXTINF之和)。 - 实测定下的几条硬约束(
FFmpegExporter里逐条有注释):①-map是输出
选项,放在-i之前会被 ffmpeg 拒收(退出码 234);② 站点常用没有.m3u8
后缀的播放列表、.bin分片、完全无扩展名的分片,ffmpeg ≥7.1 默认的
-extension_picky一律拒收——要-f hls -allowed_segment_extensions ALL -extension_picky 0,且老版本不认这些选项("Unrecognized option")时自动退回
只给-f hls重试;③ live 播放列表绝不能交给 ffmpeg:它没有新分片就一直
等,-t也拦不住(实测 40s 不退出);④ 取消走 SIGTERM(实测 0.00s 退出、不留
残件),仍会兜底删文件。 - 验证(app 内、经桥
/media/download的真实路径,端到端 9 例全过):音轨分离
master → mp4 640x360 video+audio、maxBandwidth400k→640x360 / 5M→960x540、
音轨分离+上限 → audio+video、直播 → 内置+转封装出 mp4(带 live 提示)、
无后缀播放列表 /.bin分片 / 无扩展名分片 → 均由 ffmpeg 出 mp4、
段全 404 → 失败且不留空文件。产物用ffprobe核对容器、流与分辨率。
- 判定顺序:HLS 且
Fixed
-
聊天面板里按回车不再刷 "Publishing changes from within view updates"(用户贴出的
Xcode 运行时警告,一次回车 59 条):.onKeyPress的处理器在 SwiftUI 的更新事务内
执行,而提交消息会写十几处@Published,于是每条写入都报一次。现在回车、⌘↩、Esc 取消
以及"语音结束后自动发送"这几条路径都跳到下一个主线程回合再动 store——行为不变,
只是不再在视图更新中发布。- 定位手法(可复用):这些警告也会进统一日志
(subsystem == "com.apple.runtime-issues",--style json还带线程/activity/调用栈)。
本次 59 条挤在 36ms 内、同一线程同一 activity ——说明是一个动作连环发布而非用户点了
59 次;再看同一时刻的 app 日志,紧跟在LegacyTextInputActions signal:DidAction
(键盘输入)之后、SecItemCopyMatching+MCP(回合启动)之前,对上被标记的
sendMessage写入行,触发点就锁定了。 - 顺带确认(有证据、别再瞎改):
.onReceive(CommandBus)(菜单/桥命令)与
.onChange(initial: true)这两条路径不触发该警告。
- 定位手法(可复用):这些警告也会进统一日志
-
Xcode 编译警告清零(用户贴出的清单,逐条修):
DevToolsPanel:DOM 树分支里if let root = …的root从未使用 → 改成
if treeRoot != nil。DevToolsStore.sameSiteLabel:HTTPCookieStringPolicy是 struct,写
policy == .none实际在跟Optional.none比、恒为 false,导致"看 properties
里有没有 samesite 键"那段成了死代码(没显式声明 SameSite 的 Cookie 也会被挂上徽章)
→ 判定完全改走 properties。MediaExportStore:@preconcurrency import UserNotifications,并且不再把
UNUserNotificationCenter(非 Sendable)捕获进 @sendable 回调,改为各自取
.current()。MarkdownRendererView:parse标了nonisolated(要在 detached 任务里跑),
但它的四个辅助函数还是 MainActor 隔离 → 一并标nonisolated。
-
设置页服务档案行每次重绘读两次 Keychain:
SecItemCopyMatching是阻塞系统调用,
Xcode 的 Performance Diagnostics 会报 "This method should not be called on the main
thread as it may lead to UI unresponsiveness"(同一次运行里 12 条)。同一个StatusPill
的两个分支各读一次 → 现在只读一次复用。 -
一个段都没下到时不再"假装成功":内置下载器在全部段失败时会留下一个 0 字节
的.ts并报完成(用户点开是空的)。现在会删掉残件并报
Every segment failed to download — nothing was saved;与 ffmpeg 的失败原因合并成
一条错误(fallbackFailed),两条信息都不丢。 -
Agent 消息渲染的死循环已修复(块解析器不推进):
MarkdownParser.parse的有序列表
分支里入口条件用原始行、循环条件用 trim 后的行,于是"1. "这种行(首字符是数字、
以 ". " 结尾)能通过入口,进循环后却匹配不上(尾空格被 trim 掉,". " 不复存在),三个
分支全不中 →else里break退出内层循环,但i一次都没加 → 外层continue
回到同一行,无限循环,同时把空列表块越堆越多。- 触发面正好落在流式输出上:模型写有序列表时的中间态就是
"1. ";而完整落盘的消息
里不常有这种行——350 条历史消息整体跑一遍不复现,只有盯着实时流式才会撞上。 - 后果:解析跑在
Task.detached里,而.task(id:)的取消不会传给 detached 任务,
所以每撞一次就永久泄漏一个满核空转的解析任务,blocks同时无限增长——表现为聊天
卡顿、发热,最终可能被系统杀掉。 - 修法三层:① 入口条件改用 trim 后的行(
"1. "现在正确渲染成段落,而不是凭空
消失);② 循环顶部加defer { if i == iterationStart { i += 1 } }兜底——任何分支
忘了推进都会被补上,解析结构性终止,不依赖每个分支自觉;③parse新增
isCancelled参数(并标nonisolated),视图用withTaskCancellationHandler把
.task的取消显式转发进去,文本一变化旧解析立刻收手。 - 验证:
parse("1. ")修前必卡死(跟踪里同一行重复了几百次)、修后立即返回
para("1. ");对抗语料 1418 条(含 200 条真实历史消息)+ 9600 条专攻分支
条件(数字开头 + 尾随空白/制表符、CRLF/CR、阿拉伯-印度数字、全角数字、混合标记),
每条再按 1/8 前缀各渲染 9 轮跑完整管线(解析 → 每块内联),合计 99,162 次
((1418 + 9600) × 9),全部通过,无卡死无崩溃。
- 触发面正好落在流式输出上:模型写有序列表时的中间态就是
-
聊天渲染路径的下标改为快照枚举:
ForEach(x.indices, id: \.self)配x[index]是
SwiftUI 的经典越界形态——流式期间块数会减少(围栏一开吞掉后面几块、列表合并),
下标当 id 时 SwiftUI 可能在缩容的那次更新里拿旧下标去取新数组 →Index out of range。- Markdown 渲染器 5 处(块、无序列表、有序列表、表头、表体)与输入框附件条 1 处,
全部改成ForEach(Array(x.enumerated()), id: \.offset),闭包内只碰快照值。 - 流式尾部写回由"append 时记下的下标"改为按 message id 找回(
flushTail):会话
在流式中途被清空/切换时,旧下标会指向另一条消息(把 token 写进无关消息),数组变短
后还会越界。
- Markdown 渲染器 5 处(块、无序列表、有序列表、表头、表体)与输入框附件条 1 处,
-
Agent 消息渲染崩溃(EXC_BREAKPOINT / SIGTRAP)已修复(用户报告:"看一下智能体最近
的聊天记录 导致崩溃了"):MarkdownRendererView.buildInlineContent原来在同一个
AttributedString上按link → bareURL → code → bold → italic顺序做五次
replaceSubrange,但每一趟的 range 都是按原文本匹配出来的,而Range(_:in:)只做
偏移映射、并不知道字符串已经被前一趟改短了。偏移一对不上,替换边界就会落在多字节字符
中间,接着在AttributedString.Guts.replaceSubrange内部触发
CollectionsInternal/BigString+Chunk+UnicodeScalar.swift:137断言,进程直接 SIGTRAP
(崩溃报告Desire-2026-09-23-002707.ips:EXC_BREAKPOINT ← CollectionsInternal ←
buildInlineContent,触发点是列表项里的粗体一趟)。- 最小复现(同一个函数、
-Onone,且用应用里逐字一致的正则验证过):[a](b)**粗***斜体*
— 链接那趟先把字符串缩短了,粗体、斜体两趟却还拿着旧偏移继续替换。纯 ASCII 的
[](u)**a***b*不复现,必须有多字节字符参与才会命中 scalars 中间;19 条历史对话的
373 个内联块(含每块 8 个流式前缀共 2984 次渲染)在已落盘文本上也都不复现——
崩溃发生在流式的中间状态,所以是按崩溃栈定位的根因。 - 改法:先收集片段、再一次性拼接。按原文本匹配出所有 span(重叠时先占先得:
链接 > 裸链接 > 行内代码 > 粗体 > 斜体,与旧顺序语义一致),按位置排序后逐段切原文
拼进结果。全程不再对已经变过长度的AttributedString使用旧索引,这类失效索引在
结构上不再可能出现。样式与旧实现完全对齐(链接用强调色 + 下划线、行内代码等宽 +
底色、粗体/斜体各自字重字型)。
- 最小复现(同一个函数、
安装
未签名构建(无 Developer ID 证书),仅支持 Apple Silicon(arm64,macOS 26.5+)。
- 解压
Desire-v0.3.12-macos-arm64.zip(双击,或
ditto -x -k Desire-v0.3.12-macos-arm64.zip .)。 - 安装:把解压出来的
Desire.app拖进/Applications。 - 移除一次隔离标记(未签名构建必需),路径写你放 app 的那个:
xattr -cr /Applications/Desire.app
- 打开
Desire.app。首次启动 Gatekeeper 会检查一次;做过第 3 步就能正常打开。
报
xattr: No such file: Desire.app说明当前目录里没有解压好的 app——
命令要写完整路径(如/Applications/Desire.app),或先cd到它所在目录。