news 2026/10/15 16:43:06

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 只关心"文件写到哪里、什么时候写的"。这也意味着它天然兼容 Cmd+Shift+3/4/5、触控栏截图甚至 CleanShot——任何把截图写进文件夹的工具,都会成为它的"上游"。

从按下快捷键到照片上线的完整链路

监听机制的核心是一把O_EVTONLY的文件描述符和DispatchSource:

let 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 做的事等价于你在 Cmd+Shift+5 的 Options 菜单里手动做的两件事:关闭浮动缩略图(show-thumbnail = false),以及把保存位置改到应用自己的文件夹。

有意思的是它的写入路径。macOS 的截图设置存在com.apple.screencapture域里,普通defaults写入不会立刻被截图服务感知,tendedero 选择走CFPreferencesSetAppValue+CFPreferencesAppSynchronize,让 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记录了截取区域的全局矩形(一个四元组 plist),tendedero 读它来得知"这张截图是从屏幕哪个位置截的",从而让照片从原始位置起飞、划过一道弧线飞向顶栏。第一飞调用同样是"先探测属性是否存在、再读出数据"的两次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顶部、宽度撑满整块屏幕、高度只有 210pt(Layout.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: -(panelHeight+12))),物理上收不进鼠标事件。

只在照片上接住鼠标

面板默认ignoresMouseEvents = true,意味着除照片以外的整条透明带对鼠标完全隐形——点击屏幕顶部不会误触面板。那么"照片上的鼠标事件"是怎么恢复的?答案是把每张照片的命中矩形从 SwiftUI 视图层上报到 App 层:

// LineView.swift .onPreferenceChange(HitRectsKey.self) { rects in line.hitRects = rects }

PeggedView里的每张卡片用一个GeometryReader把自己的全局 frame 写进 PreferenceKey,汇总成[UUID: CGRect]。tick 里每帧做一次点选测试,动态翻转ignoresMouseEvents:

let 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 是 4,isActive(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 的RegisterEventHotKey(HotKey.swift),注释明确说明"与全局键监听不同,它不需要辅助功能权限";菜单栏点击检测用NSEvent.addGlobalMonitorForEvents监听鼠标按下,同样无需授权。整款应用需要的唯一系统权限是"访问桌面文件夹"(首次启动时申请一次),不需要屏幕录制权限,也不需要辅助功能权限——这几乎是对截图类工具刻板印象的颠覆。

永远不全量解码的缩略图

截图挂线后的内存账本被严格限制。核心是 Line.swift 的makeThumbnail:

func 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 为pegged),restore()启动时按路径重新挂载并跳过已不存在的文件。类注释点明了设计哲学:"文件永远不移动,线只是它们的一层视图"——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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 18:38:25

MCP协议握手到LangGraph多Server调用:从原理到实战全解析

MCP 这个圈子今年是真的热闹&#xff0c;光是“mcp是什么”这类搜索就天天有人在问。但光知道概念没用&#xff0c;真到自己上手把 MCP Server 接进 LangGraph 流程里&#xff0c;你会发现坑全藏在细节里——尤其是从协议握手到多 Server 调用这一段&#xff0c;文档写得云里雾…

作者头像 李华
网站建设 2026/10/15 15:40:01

01背包方案数全解析:从状态设计到代码实现

1. 先搞清楚题目在问什么&#xff1a;01背包方案数的识别信号很多同学一看到"方案数"三个字&#xff0c;第一反应是套组合数公式&#xff0c;第二反应是DFS枚举。这两种反应本身没问题&#xff0c;但放到GESP六级的赛题里&#xff0c;大概率会超时。为什么&#xff1…

作者头像 李华
网站建设 2026/10/15 20:11:02

笔记本蓝牙驱动故障排查与稳定配置指南

1. 为什么笔记本电脑的蓝牙驱动问题总在关键时刻掉链子&#xff1f; “蓝牙连不上耳机”“鼠标突然失联”“文件传输卡在99%”——这些不是偶然故障&#xff0c;而是笔记本电脑蓝牙模块在软硬件协同层面暴露的典型断层。我接触过上百台不同品牌、不同年份的笔记本&#xff0c;…

作者头像 李华
网站建设 2026/10/15 20:10:47

课堂行为检测数据集:VOC与YOLO格式转换及YOLO训练实践

简介&#xff1a;面向学生课堂行为检测场景&#xff0c;这份VOCYOLO双格式数据集包含5622张课堂实景图片&#xff0c;已按7个类别完成目标标注&#xff0c;可直接用于训练课堂行为识别模型、算法评测或教学实验&#xff0c;尤其适合需要现成标注数据的深度学习研究者。压缩包采…

作者头像 李华