跳转至

Scrawl App UI 交接规范(给 Hex)

本规范描述 Scrawl 的 macOS 应用外壳(src/)中位于 scrawl-capture 与 scrawl-router 之上的用户可见流程和边界情况。纯几何计算、状态机与协议逻辑(capture::pure_shift_state、capture::InputSink、sketch::freehand_outline、routing::*)已经存在于各个 crate 中;Hex 需要实现下面的原生 Swift/Rust UI 流程。


1. 录制快捷键、胶囊与目标切换器

1.1 录制快捷键(默认 ⌥⇧S)

  • 在松开修饰键时触发,而非按下时触发: 当处于空闲状态(Activity::Free)时按下录制快捷键,Scrawl 会进入待定状态,并等待所有快捷键修饰键(⌥、⇧ 等)都松开后才开始录制(RecordScreen)。
    • 原因: 如果在 ⇧ 仍按住时就开始录制,光标移动会立即被当作 ⇧ 墨水;而在目标应用上松开 ⌥⇧S 会把修饰键抬起事件泄漏到录制中。
    • 按住期间取消: 在快捷键修饰键松开前按下 Esc 会取消待定的启动(Action::Cancel),且不会闪烁屏幕或创建 bundle。
  • 双击保护(DOUBLE_PRESS_MS = 400 ms): 录制开始后 400 ms 内再次按下录制快捷键会被忽略(Action::None),这样按键抖动或双击不会立刻结束一个 0 秒的录制。
  • 录制中按下(≥ 400 ms): 结束当前录制(Action::Finish)。
  • 目标选择器打开时按下(Activity::Picker): 开始录制选择器中当前高亮的目标(Action::RecordSelected)。
  • 处理中按下(Activity::Processing): 忽略(Action::None)。
  • 默认快捷键别名: 当快捷键保持默认值(⌥⇧S / ⌥⇧V)时,capture::shortcut_matches 也会接受在当前键盘布局的 S/V 键以及物理 ANSI 键码上的 ⌃⌥⇧、⌘⌥⇧ 和 ⌃⌥⌘。自定义快捷键则只匹配其确切的 Carbon 修饰键和键码。

1.2 目标切换器与显示标签(Area · Window · Screen · iPhone)

  • 默认从光标所在显示器开始全屏录制(Screen,自动裁剪): 按下并松开 ⌥⇧S 会立即开始录制指针所指的显示器(Target::ActiveDisplay),并将录制区域定位在该显示器菜单栏和刘海下方(screens::capsule_origin_for_display)。
  • 点按启动时显示标签: 通过快捷键启动屏幕录制后的前 3,500 ms(REVEAL_MS)内,浮动胶囊会展开以显示目标切换标签(Area · Window · Screen · iPhone):
    • 最短可见时间:即使你立刻点击或绘制,也会至少显示 900 ms(REVEAL_MIN_MS)。
    • 操作后自动收起:一旦 900 ms 过去,第一次被记录的用户操作(点击、滚动或笔画)就会把切换标签收回到紧凑的录制胶囊中。
    • 悬停延迟:鼠标悬停在胶囊上会保持标签展开;指针离开胶囊后,它们会再保持打开 1,200 ms(REVEAL_LINGER_MS)然后收起。
  • 录制中途切换目标: 在切换标签展开时点击 Area、Window 或 iPhone 会切换录制目标:
    • Window: 高亮悬停到的窗口(通过 capture::window_at_point 从前往后做命中测试,排除 Scrawl/宿主窗口);点击某个窗口会将录制切换到 CaptureMode::Window(或在该窗口上重启录制流)。
    • Area: 让用户在单个显示器上拖出一个矩形(screens::clamp_rect_to_display;通过 screens::ensure_min_area_rect 保证最小尺寸)。跨显示器的拖动会限制在拖动开始时的显示器内。
    • iPhone: 连接已通过线缆接入的 iPhone(helper::devices),打开镜像窗口,并录制设备原生视频 + 触摸遥测数据。如果手机断开连接或被锁定(Event::PhoneEnded),录制会干净地结束或提示用户解锁。
  • 显式选择器(Choose What to Record…): 从菜单栏打开选择器会在开始任何录制之前进入 Activity::Picker;按 Esc 可关闭选择器。

2. 墨水覆盖层(⇧ 墨水、图钉、激光、标记与撤销)

墨水覆盖层是一个透明的、可点击穿透(ignoresMouseEvents = true)、不激活(NSWindowSharingNone)的窗口,覆盖目标显示器;通过 OwnWindows::excluded 排除在 ScreenCaptureKit 之外,并通过 OwnWindows::passthrough 在自点击过滤中被忽略。

2.1 纯 Shift 状态(capture::pure_shift_state)

  • 只有当 ⇧ 单独按住(没有 ⌘、⌥、⌃ 或 fn),并且不是多修饰键组合(例如录制开始时的 ⌥⇧S,或录制中的 ⇧⌘4)遗留下来的状态时,⇧ 才会激活墨水/图钉。
  • 如果在 ⇧ 按下的同时又按下了另一个修饰键,await_shift_release 会锁存为 true,直到 ⇧ 被完全松开并再次单独按下。

2.2 绘制 ⇧ 墨水(InputSink::ink)

  • 无需点击即可绘制: 按住纯 ⇧ 并在录制目标内移动指针,会沿光标路径绘制实时红色墨水(#FF3B30)(sketch::freehand_outline),无需按下鼠标按钮。
  • 最小跨度(MIN_INK_SPAN_PT = 6.0 pt): 松开 ⇧ 时,仅当指针从起点至少移动了 6 pt(≥ 2 个点)才提交该笔画。原地不动的 ⇧ 点按(例如光标停在屏幕上时输入一个大写字母)会被丢弃,因此不会产生杂散的点。
  • 点抽稀与上限: 距上一点小于 1.5 pt 的点会被跳过,每笔最多 2,400 个点。
  • 辅助功能 / DOM 探测: 当 agentContext 开启时,松开一笔会在笔画目标点探测 Accessibility 树(以及已登录浏览器标签页的 /web/context)(ax::stroke_probe_point:下划线正上方、圆的中心、箭头尖端)。

2.3 编号图钉(⇧ + 左键点击)

  • 放下图钉: 按住纯 ⇧ 并左键点击(button_down → button_up 在 PIN_MAX_PRESS_MS = 1,000 ms 内且移动量 < CLICK_SLOP_PT = 12.0 pt)会在按下点放下下一个顺序编号的图钉(1、2、3、…)。
  • 绘制中途抑制: 如果在当前 ⇧ 按住期间、点击之前已经绘制了 ≥ 6 pt 的墨水,该点击会被视为绘制的一部分,不会放下图钉,也不会记录点击事件。
  • 长按不动(> 1,000 ms): 按住 ⇧ 和鼠标按钮,超过 1 s 不移动再松开,不会放下图钉。
  • 视觉呈现: 在覆盖层上以短暂向外脉冲的方式渲染(sketch::pin_pulse),并在关键帧上盖一个带白色边框的红色编号徽章。

2.4 激光笔

  • 当指针在激光修饰键/按钮激活的状态下移动时触发(InputSink::laser)。
  • 在实时叠加层上渲染一条拖尾并逐渐淡化的红色光束(sketch::slice_laser_window + sketch::smooth_laser_points),不会在后续关键帧上留下永久墨迹;激光结束时,其最终停留坐标被记录为 InteractionEvent::Point。

2.5 标记与撤销

  • 标记(Flag)(InputSink::flag): 按下录制胶囊中的标记按钮(或使用快捷键)会盖上一个仅含时间戳的 Shape::Flag 标记(t_ms),即使屏幕静止,也会在该时刻强制生成一个优先关键帧。
  • 撤销(Undo)(⌘Z 按住 ⇧,或 InputSink::undo_stroke): 如果当前正在绘制实时墨迹,则清除正在进行中的笔划;否则弹出最近一次已提交的笔划或图钉。未按住 ⇧ 的普通 ⌘Z 会作为普通按键组合传递给最前端的应用。
  • 自身窗口过滤: 落在宿主自身胶囊或设置窗口(OwnWindows,不包括可点击穿透的墨迹叠加层)上的点击和滚动永远不会被记录为捕获交互。

3. 粘贴、自动路由与成品捕获的交付

当捕获完成(SealedCapture::finish → Finished)时,应用决定如何交付它:

3.1 剪贴板与两步粘贴(⌥⇧V 或粘贴按钮)

  • 剪贴板默认行为: 捕获一完成,summary.md 就会复制到系统剪贴板(SystemClipboard::copy_bundle),因此在任何地方手动按 ⌘V 都会粘贴纯文本的 markdown 摘要,其中包含指向 contact-sheet.png 和 scrawl.json 的绝对路径。
  • 两步合成粘贴(paste_bundle_image_and_text / ⌥⇧V):
    1. 等待最多 350 ms,直到所有物理修饰键(⌥、⇧、⌘、⌃)都被释放,这样合成的 ⌘V 不会变成 ⌥⇧⌘V。
    2. 激活目标外部应用(或者,如果用户将粘贴标签拖放到特定窗口/输入框上,则在该坐标处发送一次合成点击,并等待 65 ms 获得焦点)。
    3. 第 1 步(图片):
      • 如果最前端应用是终端(Ghostty、iTerm2、Terminal、kitty、WezTerm、Alacritty、Warp),则将 contact-sheet.png 的绝对路径作为文本复制,并发送 ⌘V(终端会从文件路径附加 [Image 1])。
      • 如果最前端应用是 GUI 应用(OpenCode Desktop、Slack、Linear、浏览器),则将 contact-sheet.png 作为 NSPasteboardTypePNG 字节复制,并发送 ⌘V。
    4. 等待 160 ms 让目标编辑框(composer)接收图片附件。
    5. 第 2 步(文本): 将 summary.md 作为 NSPasteboardTypeString 复制,等待 20 ms,然后发送第二次 ⌘V。

3.2 会话路由、倒计时、标签与撤销

  • 路由决策(scrawl-router): 在捆绑包组装的同时或紧接着运行(Router::decide),结合确定性线索(开始时的语音会话名称、routing.sites、routing.apps、git 检出中的开发构建二进制文件、/sdk.js 签名页面、.opencode-session 发布者标记、localhost 端口)以及 Jev(jevRouting)。
  • 四种结果(decision 区间 + autoSend 策略):
    • 立即发送: 确定性规则/地址(在 autoSend: "rules" 或 "confident" 下)。立即投递(Router::deliver),并显示带有 撤销 按钮的“已发送”toast。
    • 倒计时发送: 在 autoSend: "confident" 下,经加权或 Jev 高置信度选定。显示一个简短倒计时胶囊,命名目标会话(Sending to <Title>…),并带有候补会话标签、粘贴 和 取消(Esc / ✕)。
      • 边缘情况: 悬停在胶囊上会暂停倒计时;将指针移开则恢复倒计时。
      • 修正记录: 按 Esc/✕ 记录 Action::Cancel;点击 Paste 记录 Action::Paste;点击候补标签会在该处投递并记录 Action::Redirect(Router::correct)。
    • 建议 / 询问(Decision::Offer): 在 autoSend: "rules" 下,Jev/加权发送会变成一条建议(suggested: true),首先预选首选选项,随后显示最多 3 个候补标签(Jev 的次选、智能路由的最佳项、HOME),外加 粘贴。
    • 保留在剪贴板(Band::Keep): 保持在剪贴板上,带有一个 粘贴 按钮(可拖放到目标窗口上进行点击粘贴)。
    • 规则学习提议(OfferRule): 当用户针对同一站点源或非通用应用手动选择同一会话两次时(router/learned.json),胶囊会提供一个一键提示:“总是将 发送到 ?”,并带有 总是(policy::add_rule)和 暂时不(policy::refuse_rule,后者会将键添加到 routing.refusedRules,使其不再被提议)。
    • 撤回已发送内容(Router::undo): 在已发送的捕获上点击 撤销 会调用 Router::undo(delivery_id, deadline) 并显示结果(Report::text()):
      • withdrawn:在会话收件箱中被读取之前取消。
      • reverted:中断了该消息启动的回合,并将会话恢复到消息之前的状态(也包括文件更改,但仅限该回合的编辑工具所做的更改):先暂存(staged),再提交(committed),因此消息及其回合会被删除。
      • disowned:会话已经处于回合中途或有更新的消息,因此发送了一条引导性后续消息,告知代理忽略先前的消息。在暂存回滚期间有新输入到达,或者提交失败,都会先清除暂存的回滚,因为任何提示词都会提交它。
      • unfinished:暂存的回滚既无法提交也无法清除;不再对该会话发送任何内容,胶囊会将其显示为“太迟”。
      • 同时调用 Recents::taken_back,这样一次误发送就不会在下一次捕获时作为先例提供给 Jev。
    • 离线队列与重试(delivery::retry_due): 如果投递时 OpenCode 服务器不可达,会在捆绑包中写入一个 .deliver.json 标记(或写入 router/outbox/<key>),并在后台以指数退避方式重试(15 s 起翻倍,最大到 30 m,24 h 后过期)。

4. 引导与权限

Scrawl 需要两项权限来录制,以及两项可选权限以获得更丰富的上下文(crates/capture/src/permissions.rs):

权限 作用 检测与授权流程
屏幕录制 用于 Mac 屏幕/窗口/区域捕获 CGPreflightScreenCaptureAccess() / CGRequestScreenCaptureAccess()。在“系统设置”中授权时,如果进程已在运行,macOS 可能需要重新启动应用。
麦克风 用于语音旁白 AVCaptureDevice.authorizationStatus(for: .audio)。首次请求时内联提示;若被拒绝,则深度链接到 x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone。
辅助功能 可选 — 启用模拟 ⌘V 粘贴、全局鼠标/键盘监听以及 UI 元素标签(agentContext) AXIsProcessTrusted()。通过 AXIsProcessTrustedWithOptions 提示;深度链接到 Privacy_Accessibility。若未获得此权限,Scrawl 将回退为轮询鼠标按键(NSEvent.pressedMouseButtons)和剪贴板复制,而非模拟粘贴。
输入监控 可选 — 在辅助功能关闭时记录全局组合键并触发快捷键 CGPreflightListenEventAccess()。获得辅助功能权限后自动涵盖。
  • 非阻塞权限面板: 当屏幕录制权限缺失时,如果用户按下 ⌥⇧S,显示权限检查清单窗口,说明缺失项,并提供一键按钮直接打开对应的“系统设置”窗格;同时实时轮询授权状态(permissions::Grants::current()),一旦开关被打开,该行立即变绿。
  • 签名二进制身份: 使用稳定的 Developer ID bundle identifier 对应用进行签名,以便 macOS tccd 在更新后保留屏幕录制、麦克风和辅助功能授权。

5. 设置界面

在 Hex 中,这些设置存放在 Hex 自己的配置存储中,并构造 scrawl_capture::EngineConfig 和 scrawl_router::RouterConfig(通过 scrawl_router::parse_scrawl_config(&str) 和 handoff::import_or_ensure_ingest_token_file 从 ~/.config/scrawl/config.json 一次性迁移):

  1. 快捷键:
    • 录制快捷键: 默认为 ⌥⇧S。不可取消设置(ShortcutKind::Record.can_unset() == false)。
    • 粘贴上一次捕获快捷键: 默认为 ⌥⇧V。可清除/禁用("paste": null)。
    • 录制器边界情况: 在用户录制新的按键绑定时暂停全局快捷键监听器(capture::pause_shortcuts(true)),这样在录制器字段中按下现有快捷键时会捕获按键,而不会开始录制。
  2. 路由与投递(RouterConfig):
    • 自动发送策略(routing.autoSend):
      • Rules(默认):仅确定性匹配(语音会话名称、站点/应用规则、开发构建、/sdk.js、发布者标记、localhost 端口)会自动发送;Jev 和按词加权选中的候选项会预选一个建议 chip。
      • Confident:Jev 的置信带(置信度 ≥ 0.80,边际 ≥ 0.50,拟合度 ≥ 0.65)也会在撤销倒计时结束后自动发送。
      • Never:仅在明确说出目标地址时发送;规则和 Jev 始终显示 chip 选择器。
    • 忙碌会话投递(delivery): Steer(默认 — 立即加入当前活跃轮次)与 Queue(等待当前轮次结束)。
    • 撤销窗口(routing.undoWindowSec): 默认为 600 s(10 分钟)。
    • 站点与应用规则(routing.sites、routing.apps、routing.servers、routing.aliases): 可编辑的 URL 前缀和应用 bundle ID 列表,映射到项目文件夹或 ses_… ID。将通用浏览器、终端和 OpenCode 排除在 routing.apps 之外(routing::policy::is_general_app)。
  3. 录制与转写(EngineConfig):
    • 麦克风(microphone): 跟随 macOS 系统默认(None),或固定到特定的 CoreAudio 设备 UID(MicChoice { id, name })。如果固定的麦克风被拔出,则本次录制回退到系统默认并显示提示(mic::Pick::note)。
    • 转写器(transcriber): hex(Hex 的本地模型服务)、apple(macOS 26+ 上的 SpeechAnalyzer,带语言区域选择器 appleLocale)或 command(自定义 CLI 程序)。
    • 自动裁剪(autocrop): 默认对全屏捕获开启;将关键帧裁剪到活动窗口/区域,同时将完整帧保留在 full/ 中。
  4. 存储与保留(retention.rs):
    • 捕获文件夹(capturesDir): 必须是绝对路径或 ~/… 形式的可写目录;如果配置的外部磁盘被拔出或变为只读,则记录一次日志并回退到内置的 Application Support 目录,确保捕获内容不会丢失。
    • 清理策略(retention): 预设(Keep all、Balanced — 7 天后压缩/删除大体积视频,30 天后将 bundles 移入废纸篓;Compact — 2 天 / 14 天)。绝不触碰 .pinned bundles、.media-sending bundles 或正在进行的录制。
  5. 代理与 iPhone 配对:
    • CLI 与 Skill: 将 scrawl 安装到 PATH(~/.local/bin/scrawl),并安装 skills/scrawl/SKILL.md,以便编码代理可以运行 scrawl zoom 和 scrawl inspect。
    • ScrawlKit 配对(ingestToken): 仅所有者可读(0600)的 bearer token,以及用于配对基于 ScrawlKit 构建的 iOS 应用的 scrawl://setup?... 深度链接 / QR 码。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 hex19/hex