Skip to content

v0.3.12

Choose a tag to compare

@siwilizhao siwilizhao released this 22 Sep 18:56
· 595 commits to main since this release

视频下载直出 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、maxBandwidth 400k→640x360 / 5M→960x540、
      音轨分离+上限 → audio+video、直播 → 内置+转封装出 mp4(带 live 提示)、
      无后缀播放列表 / .bin 分片 / 无扩展名分片 → 均由 ffmpeg 出 mp4、
      段全 404 → 失败且不留空文件。产物用 ffprobe 核对容器、流与分辨率。

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 写进无关消息),数组变短
      后还会越界。
  • 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+)。

  1. 解压 Desire-v0.3.12-macos-arm64.zip(双击,或
    ditto -x -k Desire-v0.3.12-macos-arm64.zip .)。
  2. 安装:把解压出来的 Desire.app 拖进 /Applications。
  3. 移除一次隔离标记(未签名构建必需),路径写你放 app 的那个:
xattr -cr /Applications/Desire.app
  1. 打开 Desire.app。首次启动 Gatekeeper 会检查一次;做过第 3 步就能正常打开。

报 xattr: No such file: Desire.app 说明当前目录里没有解压好的 app——
命令要写完整路径(如 /Applications/Desire.app),或先 cd 到它所在目录。