ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

tendedero 是怎么“接住“每一张新截图的?macOS 截图拦截机制探究

tendedero 是怎么“接住“每一张新截图的?macOS 截图拦截机制探究 tendedero 是怎么接住每一张新截图的macOS 截图拦截机制探究【免费下载链接】tendederoScreenshots, hung out to dry. A tiny native macOS app that hangs every screenshot on a line at the top of your screen.项目地址: https://gitcode.com/gh_mirrors/te/tendedero在截图工具这个赛道上社区里大多数讨论都围绕怎么截得更多展开长截图、滚动截图、OCR 识别、贴图固定从 Snipaste 到 PixPin 莫不如此。而 tendedero 选择了一个完全相反的切入点——它自己根本不截图它只负责接住你截下来的每一张图截图刚落盘一张装进玻璃相框的卡片就会瞬间挂上屏幕顶栏的一条细线点击复制、长按标注、拖进任意应用一切都发生在浮动的缩略图消失之前。整个应用只有 1.7 MB要求 macOS 14 及以上无账号、无网络、无埋点。这篇开源项目只有十余个 Swift 源文件恰好是一个观察 macOS 截图生态与桌面级 UI 技巧的绝佳样本。本文基于仓库源码Sources/Tendedero/下共 14 个 Swift 文件逐条拆解三条技术主线系统截图事件的监听思路、顶栏线的绘制与悬停交互、离线无埋点约束下的内存与性能取舍。每一处结论都落在具体代码上。它不是拦截而是接住文件系统级的事件监听先澄清一个反直觉的事实tendedero 没有用CGEventTap去拦快捷键没有用 ScreenCaptureKit 去抢屏幕内容也没有监听 Notification Center 的截图通知。它的感知起点是文件系统。ScreenshotWatcher.swift 的开头注释写得很直白/// Watches the folder macOS saves screenshots to and reports new ones. /// Tendedero never takes screenshots itself: you keep your usual shortcut /// (or CleanShot, or anything else) and the line just picks them up.这句话定义了整个架构的分工截图动作永远属于系统或你的第三方工具tendedero 只关心文件写到哪里、什么时候写的。这也意味着它天然兼容 CmdShift3/4/5、触控栏截图甚至 CleanShot——任何把截图写进文件夹的工具都会成为它的上游。从按下快捷键到照片上线的完整链路监听机制的核心是一把O_EVTONLY的文件描述符和DispatchSourcelet fd open(folder.path, O_EVTONLY) let src DispatchSource.makeFileSystemObjectSource( fileDescriptor: fd, eventMask: [.write, .rename, .delete], queue: .main) src.setEventHandler { [weak self] in self?.scheduleScan() }O_EVTONLY打开目录时只申请事件通知权限不占用目录句柄的读写能力配合.write / .rename / .delete三个事件掩码任何有东西进入目录的动作都会触发一次回调。但这里藏着一个关键细节——macOS 写截图并不是一步到位private func scheduleScan() { pending?.cancel() let work DispatchWorkItem { [weak self] in self?.scan() } pending work // macOS writes a hidden temp file and renames it; give it a moment. DispatchQueue.main.asyncAfter(deadline: .now() 0.2, execute: work) }注释直接点破了原因系统会先写一个隐藏的临时文件再通过 rename 原子地换成最终文件名。若事件一触发就立刻扫描很容易扫到还没写完的半成品。所以这里做了一个 0.2 秒的合并式延迟——scheduleScan先取消上一个待执行的扫描任务再排新的意味着连续多次写入事件会被折叠成一次目录扫描。这既是稳定性兜底也是后面要谈的节流策略的雏形。用扩展属性区分截图和普通图片监听目录之后的下一个问题是目录里出现的任何图片都要挂上线吗如果监听的是用户的桌面默认模式答案必须是只有真截图——否则你拖到桌面上的一张照片也会被当作截图挂起来。macOS 在写截图文件时会在扩展属性xattr里打上标记。tendedero 用getxattr精确校验private func isScreenCapture(_ url: URL) - Bool { url.withUnsafeFileSystemRepresentation { path in guard let path else { return false } return getxattr(path, com.apple.metadata:kMDItemIsScreenCapture, nil, 0, 0, 0) 0 } }第一次getxattr调用传空 buffer只问这个属性存不存在返回值非负即命中。而一旦进入收件箱模式截图直接写进 tendedero 自己的文件夹这个过滤就被放开了onlyTaggedScreenshots只在监听的目录恰好是 Desktop 时为真自己的文件夹里任何图片都算数。同一套 Watcher两种语义靠一个布尔量切换。文件扩展名白名单同样被收进同一处逻辑[png, jpg, jpeg, heic, tif, tiff, gif, webp]覆盖了系统截图在格式选项里的全部候选。启动窗口期把迟到的截图补挂上去watcher 的启动顺序暗含一个竞态处理。应用冷启动时macOS 可能正在弹出允许访问桌面文件夹的授权弹窗此时第一张截图可能已经在磁盘上、却还没被任何监听器看到。解决方案是记录一个启动时刻private let launchDate Date() func start() { let files listing() known Set(files.filter { creationDate($0) launchDate }.map(\.path)) for url in files where !known.contains(url.path) isCandidate(url) { onNew(url) } known Set(files.map(\.path)) ... }规则是凡创建时间晚于launchDate的文件一律视为新截图即使它落在 watcher 就绪之前。known集合随后被填充为当前目录全量快照作为之后增量比对的基础。listing()还会按创建时间排序取出每个 URL——排序保证新到的挂在最右端的布局直觉。收件箱模式接管 com.apple.screencapture 并保证还原如果说文件监听是被动接住那么收件箱模式就是主动引水。Inbox.swift 做的事等价于你在 CmdShift5 的 Options 菜单里手动做的两件事关闭浮动缩略图show-thumbnail false以及把保存位置改到应用自己的文件夹。有意思的是它的写入路径。macOS 的截图设置存在com.apple.screencapture域里普通defaults写入不会立刻被截图服务感知tendedero 选择走CFPreferencesSetAppValueCFPreferencesAppSynchronize让 cfprefsd 即时同步private static func set(_ key: CFString, _ value: Any?) { CFPreferencesSetAppValue(key, value as CFPropertyList?, domain) CFPreferencesAppSynchronize(domain) }代码注释特别提醒了一个跨版本差异macOS 27 把键名从location改成了location-screenshot并忽略旧键所以两个键都要写。而为了防止改设置却不还原把用户留在无主之地apply()先把旧值完整快照进 UserDefaults退出或关闭该模式时用restore()原样写回。更稳妥的是双 watcher 兜底AppDelegate.swift 的startWatcher()里如果收件箱已启用且主 watcher 的目录不是 Desktop会再启动一个只盯 Desktop 的safetyWatcher。万一某个 macOS 版本忽略了你改的偏好设置、截图仍然落到桌面安全网会照样把它挂上线并打一条日志。系统行为不可控时用多一个观察者来兜底而不是假设设置一定生效——这是整份代码里最值得借鉴的工程态度。顺带一提CaptureFlight.swift 里还有一处对 xattr 的深度利用com.apple.metadata:kMDItemScreenCaptureGlobalRect记录了截取区域的全局矩形一个四元组 plisttendedero 读它来得知这张截图是从屏幕哪个位置截的从而让照片从原始位置起飞、划过一道弧线飞向顶栏。第一飞调用同样是先探测属性是否存在、再读出数据的两次getxattr坐标系从主屏左上原点换算到 AppKit 的左下原点return CGRect(x: x, y: main.frame.maxY - y - h, width: w, height: h)顶栏那条线透明面板、热区悬停与点击穿透接住只是第一步真正决定产品气质的是那条永远悬在屏幕顶部的线——以及它那套像自动隐藏 Dock 一样的进出逻辑。一个永不抢焦点的全屏宽面板线本身住在 LinePanel.swift 的一个NSPanel里配置几乎句句都是设计约束super.init(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], ...) isOpaque false backgroundColor .clear hasShadow false level .floating collectionBehavior [.canJoinAllSpaces, .stationary, .ignoresCycle] hidesOnDeactivate false becomesKeyOnlyIfNeeded true ignoresMouseEvents true逐项翻译无边框、非激活面板所以它永远不会从当前应用抢走焦点透明背景意味着它只是一层画布.floating层级保证它盖在普通窗口之上.canJoinAllSpaces让它在每个 Space 都出现配合.stationary与.ignoresCycle避免出现在 Mission Control 的窗口循环与切换动画里canBecomeKey被覆写为false彻底断掉它成为键盘焦点的可能。placeOnScreen()把面板定位在visibleFrame顶部、宽度撑满整块屏幕、高度只有 210ptLayout.panelHeight——一条横贯屏幕的透明带屏幕下方的内容完全不受影响。配合NSWorkspace.activeSpaceDidChangeNotification与didChangeScreenParametersNotification切 Space、改分辨率或插拔显示器后面板会重新定位并重新计算容量。抛物线绳与玻璃相框面板里的内容是一张 SwiftUI 视图 LineView.swift。绳子的弧度不是贴图而是一条二次贝塞尔曲线下垂量随屏幕宽度自适应static func sag(width: CGFloat) - CGFloat { min(30, width * 0.018) } static func ropeY(x: CGFloat, width: CGFloat) - CGFloat { let f x / width return ropeTop 4 * sag(width: width) * f * (1 - f) }4 * sag * f * (1-f)正是抛物线的标准形式中点下垂最深两端归零。绳体由三层描边叠成——黑色半透明底边加模糊制造阴影、中灰主绳、白色细高光——再套一个两端渐隐的 mask让绳子从屏幕外延伸进来而不是突兀地断开。每张照片都带一点random(in: -2.5...2.5)度的随机倾斜像真的挂歪了一样。相框卡片PeggedView.swift里最值得一提的是两处细节一是照片按比例缩放后嵌入白色边框圆角采用同心圆角照片圆角 边框圆角减内边距与 macOS 系统圆角风格一致二是glassFrame用.ultraThinMaterial做背景、叠加一条上亮下暗的描边渐变模拟玻璃的镜面高光但明确不做折射注释特意说明避免背景在相框边缘弯曲失真。连夹子都是纯代码画出来的9×26pt 的铝色渐变胶囊顶部一条 5×1.4pt 的深色缝隙充当夹住绳子的槽口。30Hz 鼠标巡检热区、延迟与收线线的呼出逻辑没有用全局事件钩子而是 AppDelegate.swift 里的一个 30fps 定时器只在有东西可挂wanted true时才启动let timer Timer(timeInterval: 1.0 / 30.0, repeats: true) { ... self?.tick() } RunLoop.main.add(timer, forMode: .common)每个 tick 做三件事定位指针所在屏幕、判断指针是否位于菜单栏热区、推进收线计时。菜单栏热区不是硬编码的 20 像素而是从屏幕几何关系里算出来的static func menuBarBand(of screen: NSScreen) - NSRect { var h screen.frame.maxY - screen.visibleFrame.maxY if h 1 { h max(NSStatusBar.system.thickness, screen.safeAreaInsets.top) } ... }正常情况下frame与visibleFrame的高度差就是菜单栏厚度遇到自动隐藏菜单栏此时可见框直达屏幕顶边、差值 1就退回系统状态栏厚度与安全区高度中较大者——这块让指针停一停的触发区被建模为一块矩形带。呼出与收起各有精心调的阈值指针在热区停留0.25 秒才放下线快到像即时的又不会因为路过菜单栏就误触发指针离开线区域0.5 秒后收线。新截图到来时则走peek路径线降下来停留 2.5 秒展示新照片然后自动收起。点击菜单栏任意位置图标或菜单会置menuBarSuppressed线不会在菜单弹出期间又滑下来遮挡菜单。这套热区 延迟 抑制位的组合比单纯监听mouseEntered可靠得多因为面板在收起时已经整体移出屏幕外offset(y: -(panelHeight12))物理上收不进鼠标事件。只在照片上接住鼠标面板默认ignoresMouseEvents true意味着除照片以外的整条透明带对鼠标完全隐形——点击屏幕顶部不会误触面板。那么照片上的鼠标事件是怎么恢复的答案是把每张照片的命中矩形从 SwiftUI 视图层上报到 App 层// LineView.swift .onPreferenceChange(HitRectsKey.self) { rects in line.hitRects rects }PeggedView里的每张卡片用一个GeometryReader把自己的全局 frame 写进 PreferenceKey汇总成[UUID: CGRect]。tick 里每帧做一次点选测试动态翻转ignoresMouseEventslet overPhoto line.hitRects.values.contains { $0.insetBy(dx: -4, dy: -4).contains(flipped) } if panel.ignoresMouseEvents overPhoto { panel.ignoresMouseEvents !overPhoto }insetBy(dx: -4, dy: -4)还把命中区域向外扩了 4pt给差一点没点中留出手感余量。只在状态变化时赋值避免每帧无谓地翻转属性。这套整板穿透、局部接收的方案让一个覆盖全屏宽度的窗口做到了与透明交互区完全一致的点击行为且全程不需要 Accessibility 权限。全屏应用的让位当某个应用全屏播放视频或演示时一条晾衣绳横在顶部是不可接受的。tendedero 用两个私有但稳定的 WindowServer 调用来判定当前 Space 的类型FullScreen.swift 里以_silgen_name直接符号链接_silgen_name(CGSMainConnectionID) private func CGSMainConnectionID() - Int32 _silgen_name(CGSCopyManagedDisplaySpaces) private func CGSCopyManagedDisplaySpaces(_ connection: Int32) - CFArray注释说明了选型理由这些 CGS 调用十年稳定、无需权限yabai 等窗口管理器也依赖它们。全屏 Space 的 type 是 4isActive(on:)据此判定还要处理显示器使用独立 Space开关——关闭时只有一个显示条目开启时要按NSScreenNumber转 UUID 匹配具体显示器。配合didActivateApplicationNotification每次激活应用或切换 Space 后做一次refresh()并在动画稳定后再补一次把进入全屏的瞬间线还悬着的窗口期压到最小。手势、飞行与坠落交互层由 GrabArea.swift 的一个 NSView 桥接它叠加在每张 SwiftUI 卡片之上把点击、双击、长按、拖拽全部转成对Line的调用。长按用 0.45 秒定时器判定慢于普通点击、快于刻意长按拖拽以 4pt 位移阈值触发并通过NSDraggingSource给出按目的地区分语义的操作掩码context .outsideApplication ? [.copy, .move, .delete] : []于是拖进应用是复制照片留在线上、拖进文件夹是移动文件被 Finder 收走line.prune()检测到文件消失后照片自动坠落、拖到 Dock 的废纸篓是.delete——但 macOS 只负责报告这个操作真正删除文件是源应用的工作所以 tendedero 在收到.delete操作后自己调用trashItem完成移动。每个拖拽目的地各有一种含义代码注释一一行标明。飞行与坠落是 CaptureFlight.swift 用 Core Animation 层实现的两段全屏动画新截图从截取区域起飞、0.65 秒内完成缩小、倾斜、长出玻璃框和夹子的变形120Hz 定时器驱动插值丢弃的照片则从线上坠落520pt、越坠越倾斜、0.55 秒淡出动画窗口独立于线面板floating 1层级保证下落过程不被 210pt 的面板裁切。线的日常微风则来自 Line.swift 里 7~16 秒随机一次的gust计数触发每张照片随机 1.6~3.4 度的摆动——注释说这是让它像一个物件而不是一个控件的决定性细节。离线、无埋点内存与性能的取舍1.7MB 与零权限诉求在整份Sources/目录里搜索URLSession、NWConnection、任何埋点 SDK结果为空——README 里的 No account. No network. No analytics 是代码级事实。应用以NSApplication.setActivationPolicy(.accessory)运行main.swift不占 Dock 图标全局快捷键走 Carbon 的RegisterEventHotKeyHotKey.swift注释明确说明与全局键监听不同它不需要辅助功能权限菜单栏点击检测用NSEvent.addGlobalMonitorForEvents监听鼠标按下同样无需授权。整款应用需要的唯一系统权限是访问桌面文件夹首次启动时申请一次不需要屏幕录制权限也不需要辅助功能权限——这几乎是对截图类工具刻板印象的颠覆。永远不全量解码的缩略图截图挂线后的内存账本被严格限制。核心是 Line.swift 的makeThumbnailfunc makeThumbnail(_ url: URL, maxPixels: Int 480) - NSImage? { let options: [CFString: Any] [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceThumbnailMaxPixelSize: maxPixels, ] ... }一张 5K 分辨率的 Retina 截图永远不会以原始尺寸进内存——CGImageSource直接按目标尺寸解码缩略图上限 480pt并保留方向变换。飞行动画期间才允许临时生成最高 3000pt 的飞行帧min(3000, max(400, pixels))按截取区域对角线 × 屏幕缩放系数估算动画结束即弃。甚至复制操作也是按需全量解码仅当用户单击复制时才用Data(contentsOf:)读取原始 PNG 写入剪贴板非 PNG 格式则临时转码——线上相框到剪贴板之间全分辨率数据从不驻留。容量上限与旧照片自动坠落容量是显式治理的。updateCapacity()按屏幕宽度动态计算上限let usable panel.frame.width - 200 line.maxItems max(3, min(12, Int(usable / Layout.spacing)))Layout.spacing 174是卡片中心距窄屏 3 张、宽屏最多 12 张。挂新照片时若超员最老的卡片会安静地坠落离场while liveCount maxItems ... drop(oldest.id, quietly: true)线上的内存占用因此恒有上界12 张 × 480pt 缩略图最坏情况不过数 MB。每张Pegged的tilt在创建时随机、相框绘制全部用 SwiftUI 的矢量与系统材质没有位图素材——README 里那句包括图标在内所有图像都由代码绘制scripts/make-icon.swift是这个体积预算的底气。事件风暴下的节流与去重文件监听最怕的场景是一次保存多张截图或文件被批量拷入dispatch source 会连发一串事件。前面的 0.2 秒合并延迟解决了第一层风暴scan()里用known集合做第二层去重——只处理路径不在已知集合且通过候选校验的文件然后整批刷新已知集合。删除侧同样闭环line.prune()检查线上每个文件是否还存在被拖走或删掉的照片自动坠落杜绝线还挂着、文件已不在的悬空状态。持久化则刻意保持最轻Line.save()只往 UserDefaults 写一组文件路径key 为peggedrestore()启动时按路径重新挂载并跳过已不存在的文件。类注释点明了设计哲学文件永远不移动线只是它们的一层视图——tendedero 从不搬运你的截图文件收件箱模式下文件的最终去向保留到桌面或丢弃完全由你的拖拽手势决定。这既规避了与 Finder/Spotlight 的元数据同步问题也让崩溃恢复变得极其廉价。小成本大效果30Hz 鼠标巡检只在线上有东西时运行面板只在有东西可展示且当前无全屏应用时才orderFrontRegardless收线后 0.4 秒即orderOut撤出窗口服务器欢迎动画、Markup 编辑回写com.apple.MarkupUI.Markup分享服务与系统声音NSSound(named: Tink/Pop)则全部压在本地完成。这份克制与社区里截图工具普遍越做越重的趋势形成了有趣的对照当大家都在追问还能截出什么时tendedero 回答的是另一个问题——截图之后它该去哪。而它的答案是让每一张新截图刚落地就被一条细细的线轻轻接住。【免费下载链接】tendederoScreenshots, hung out to dry. A tiny native macOS app that hangs every screenshot on a line at the top of your screen.项目地址: https://gitcode.com/gh_mirrors/te/tendedero创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进