tech · 2026-09-12
让 macOS 截图同时保存文件并复制到剪贴板,配合 VSCode 上传到 R2
保留 Command+Shift+3,让截图文件和剪贴板图片同时可用
我在 VSCode 里写博客时,经常需要截一张图,粘贴到 Markdown 文档,再把图片上传到对象存储。Markdown 用文本和链接描述文章格式;对象存储负责保存图片文件,文章里只保留图片的公开地址。
原来的操作中,Command+Shift+3 已经生成了截图文件,但后续复制、粘贴并交给上传插件的过程不符合预期。我希望保留本地截图,同时在编辑器里直接按 Command+V 插图,不必每次去目录里找文件。
2026 年 9 月 12 日,我重新检查了这台 Mac 的配置和运行日志。现在这套实现由三个部分组成:系统负责截图,后台脚本负责复制图片,VSCode 的 Paste S3 扩展负责上传。系统截图快捷键保持原映射,截图格式也仍是 PNG。
保存文件之后,多做一次复制
系统原生截图有两组相关操作:
| 快捷键 | 当前系统映射 |
|---|---|
Command+Shift+3 | 全屏截图,保存文件 |
Command+Control+Shift+3 | 全屏截图,写入剪贴板 |
Command+Shift+4 | 选择区域,保存文件 |
Command+Control+Shift+4 | 选择区域,写入剪贴板 |
Apple 的截图说明介绍了原生截图和保存选项。这台机器上,com.apple.symbolichotkeys 保存的快捷键项目 28、29、30、31 均启用,对应参数分别为 [51,20,1179648]、[51,20,1441792]、[52,21,1179648] 和 [52,21,1441792]。
我增加的是一个目录监视进程:截图先保存到 ~/Pictures/capture,进程发现新图片后,用 AppleScript(macOS 的应用自动化脚本语言)读取图片数据并写入剪贴板。文件继续留在原处。
因此,从用户操作看,Command+Shift+3 同时完成了保存和复制;从实现看,它仍然只触发系统截图,复制是稍后发生的后台动作。Command+Shift+4 保存到同一目录时也能触发复制。
先核对系统实际保存了什么
defaults 是 macOS 读写应用偏好设置的命令。只检查时运行:
defaults read com.apple.screencapture
defaults read com.apple.symbolichotkeys AppleSymbolicHotKeys
检查当天,截图偏好包含以下全部键。表中用 ~ 代替我的用户目录。
| 键 | 值 | 说明 |
|---|---|---|
location | ~/Pictures/capture | 当前保存目录 |
location-last | ~/Desktop/capture | 记录的旧位置 |
show-thumbnail | 0 | 关闭浮动缩略图 |
target | file | 保存目标为文件 |
captureDelay | 5 | 截图工具保存的延迟选项 |
showsClicks | 1 | 保存的显示点击选项 |
style | display | 保存的全屏选择模式 |
video | 1 | 截图工具保存的视频模式状态 |
last-selection | X=709,Y=100,宽976,高728 | 上次区域 |
last-selection-display | 0 | 上次选择的显示器 |
last-analytics-stamp | 810434079.9156359 | 内部统计值 |
type、disable-shadow、include-date 和 name 都没有显式设置。现有文件名包含日期和时间,抽查文件头确认图片为 PNG;这些观察不能替代对其他 macOS 版本默认值的验证。
captureDelay=5 和 video=1 属于截图工具保存的状态,不能据此认定 Command+Shift+3 会延迟五秒或开始录屏。它们也不是自动复制功能所需的配置。
复现保存目录和缩略图设置,可以执行以下命令。这里开始是安装步骤,会修改执行机器的设置;整理文章时没有重新执行这些步骤。
mkdir -p "$HOME/Pictures/capture"
defaults write com.apple.screencapture location -string "$HOME/Pictures/capture"
defaults write com.apple.screencapture show-thumbnail -bool false
重新截图,检查文件是否落到新目录。如果当前系统没有立即应用设置,可以注销后重新登录再验证。我的机器没有显式设置 type,复现当前方案也不需要写入 JPG 配置。
用 Python 监视新图片
当前脚本保存在 ~/.local/bin/screenshot-copy-clipboard.py,由 /usr/bin/python3 运行。另一台 Mac 应先确认这个解释器可用:
/usr/bin/python3 --version
mkdir -p "$HOME/.local/bin"
代码使用 Python 3.9 的语法,并通过延迟求值保存类型标注。如果该路径不存在,应先准备可用的 Python 3.9 或更新版本,并在后面的自启动配置里填写它的绝对路径。
将下面完整代码保存为 ~/.local/bin/screenshot-copy-clipboard.py。算法与当前运行版本一致,只补充了函数说明。
#!/usr/bin/env python3
"""Copy newly saved macOS screenshots to the clipboard, leaving the file in place."""
from __future__ import annotations
import os
import subprocess
import time
DIR = os.path.expanduser("~/Pictures/capture")
STATE = os.path.expanduser("~/Library/Caches/screenshot-copy-clipboard.state")
LOG = os.path.expanduser("~/Library/Logs/screenshot-copy-clipboard.log")
EXTS = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".heic"}
MAX_AGE_SECONDS = 8.0
POLL_SECONDS = 0.35
def log(message: str) -> None:
"""Append a timestamped message to the watcher log."""
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
os.makedirs(os.path.dirname(LOG), exist_ok=True)
with open(LOG, "a", encoding="utf-8") as fh:
fh.write(f"{stamp} {message}\n")
def newest_image() -> tuple[str, os.stat_result] | None:
"""Return the newest supported image and its file metadata, if any."""
best_path = None
best_stat = None
try:
names = os.listdir(DIR)
except FileNotFoundError:
return None
for name in names:
path = os.path.join(DIR, name)
if not os.path.isfile(path):
continue
if os.path.splitext(name)[1].lower() not in EXTS:
continue
try:
st = os.stat(path)
except OSError:
continue
if best_stat is None or st.st_mtime >= best_stat.st_mtime:
best_path = path
best_stat = st
if best_path is None or best_stat is None:
return None
return best_path, best_stat
def file_id(st: os.stat_result) -> str:
"""Build a deduplication marker from inode, modification time and size."""
return f"{st.st_ino}-{int(st.st_mtime)}-{st.st_size}"
def wait_stable(path: str, tries: int = 12) -> int:
"""Poll file size up to tries times; return the last positive size."""
last = -1
for _ in range(tries):
try:
size = os.path.getsize(path)
except OSError:
time.sleep(0.1)
continue
if size > 0 and size == last:
return size
last = size
time.sleep(0.1)
return last if last > 0 else 0
def copy_to_clipboard(path: str) -> None:
"""Read image data at path into the macOS clipboard using AppleScript."""
ext = os.path.splitext(path)[1].lower()
klass = "JPEG picture" if ext in {".jpg", ".jpeg"} else "«class PNGf»"
script = f'set the clipboard to (read (POSIX file "{path}") as {klass})'
result = subprocess.run(["osascript", "-e", script], capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "osascript failed")
def read_state() -> str:
"""Read the last copied file marker, or return an empty string."""
try:
with open(STATE, encoding="utf-8") as fh:
return fh.read().strip()
except FileNotFoundError:
return ""
def write_state(value: str) -> None:
"""Persist the copied file marker supplied as value."""
os.makedirs(os.path.dirname(STATE), exist_ok=True)
with open(STATE, "w", encoding="utf-8") as fh:
fh.write(value)
def main() -> None:
"""Poll the screenshot directory and copy recent, unseen images."""
os.makedirs(DIR, exist_ok=True)
last = read_state()
log("watcher started")
while True:
try:
item = newest_image()
if item is not None:
path, st = item
ident = file_id(st)
age = time.time() - st.st_mtime
if ident != last and 0 <= age <= MAX_AGE_SECONDS:
size = wait_stable(path)
st = os.stat(path)
ident = file_id(st)
if ident != last and size > 0:
copy_to_clipboard(path)
write_state(ident)
last = ident
log(f"copied {os.path.basename(path)} ({size} bytes)")
except Exception as exc: # noqa: BLE001 — keep the watcher alive
log(f"error: {exc}")
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
main()
脚本每 0.35 秒检查一次目录,只考虑修改时间距当前不超过 8 秒的最新图片。复制成功后,把文件标识保存在 ~/Library/Caches/screenshot-copy-clipboard.state,避免对同一文件重复操作。文件标识由 inode(文件系统中的文件编号)、秒级修改时间和文件大小组成。
wait_stable 每隔 0.1 秒观察文件大小,连续两次相同且大于零时返回。这只能作为写入结束的近似判断;达到尝试次数后,它仍会返回最后一次正数大小,并没有严格保证文件写完。
真正写入剪贴板的是 copy_to_clipboard:PNG 用 AppleScript 的 PNGf 类型读取,JPEG 用 JPEG picture 类型读取。这里复制的是图片数据,便于编辑器把它作为图片处理。脚本没有调用 pbcopy,没有执行 screencapture -c,也没有做 PNG/JPG 转换。
让它登录后自动运行
LaunchAgent 是 macOS 在用户会话中加载的后台任务配置,由系统的 launchd 管理。当前配置启用了 RunAtLoad 和 KeepAlive:加载时启动,进程退出后由系统再次拉起。
下面的命令会根据当前用户目录生成完整配置,避免在 plist(macOS 属性列表文件)里使用不会自动展开的 ~。仅在首次安装、目标配置不存在时执行;文件已存在时,Python 会报错并保留原文件。
/usr/bin/python3 - <<'PYCONFIG'
import os
import plistlib
from pathlib import Path
home = Path.home()
agent = home / "Library/LaunchAgents/com.alphabelt.screenshot-copy-clipboard.plist"
agent.parent.mkdir(parents=True, exist_ok=True)
(home / "Library/Logs").mkdir(parents=True, exist_ok=True)
log = str(home / "Library/Logs/screenshot-copy-clipboard.log")
config = {
"Label": "com.alphabelt.screenshot-copy-clipboard",
"ProgramArguments": [
"/usr/bin/python3",
str(home / ".local/bin/screenshot-copy-clipboard.py"),
],
"EnvironmentVariables": {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"},
"RunAtLoad": True,
"KeepAlive": True,
"StandardOutPath": log,
"StandardErrorPath": log,
}
# Exclusive creation protects an existing configuration from being overwritten.
with agent.open("xb") as stream:
plistlib.dump(config, stream)
print(agent)
PYCONFIG
plutil -lint "$HOME/Library/LaunchAgents/com.alphabelt.screenshot-copy-clipboard.plist"
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.alphabelt.screenshot-copy-clipboard.plist"
launchctl print "gui/$(id -u)/com.alphabelt.screenshot-copy-clipboard"
plutil 检查配置格式,成功时输出包含 OK;launchctl 用来加载和查看后台任务。状态输出中的 state = running 表示进程正在运行。已经加载过的任务不需要重复 bootstrap。
检查当天,这个任务为 running,进程编号是 1163。进程编号会随重启变化,不是配置的一部分。
为什么后来换成 Pictures 目录
脚本和 LaunchAgent 的创建时间都是 2026 年 8 月 28 日 17:52 左右。早期日志反复出现对 ~/Desktop/capture 的 Operation not permitted 错误;17:54 左右脚本发生修改,随后监视进程重新启动。
后续日志包含:
2026-08-28 17:54:58 watcher started
2026-08-28 17:55:00 copied watcher-test-1787910899.png (662570 bytes)
2026-08-28 17:56:59 copied Screenshot 2026-08-28 at 5.56.58 PM.png (561986 bytes)
这些证据支持这样的还原:最初监视桌面下的目录,后台进程遇到访问权限错误,于是把系统截图位置和脚本监视目录改到 Pictures/capture,之后复制成功。不能把这个结果推广成所有 Mac 的 Pictures 目录都不会遇到权限限制;换机器后仍应查看日志。
另外,com.apple.screencaptureui 还记着旧桌面目录;一个名为 com.app.screencapture 的偏好域也保存了旧路径。后者与系统使用的 com.apple.screencapture 拼写不同,可能是历史误写,但没有原始命令记录可以确认。
关闭缩略图也是当前设置。不过,文件时间和日志不足以证明它与目录迁移在同一次操作中完成,也不足以证明最初的粘贴问题完全由缩略图引起。
在 VSCode 中交给 Paste S3
这台机器安装的是 okwang.paste-s3 0.2.0。它通过 VSCode 提供的文档粘贴和拖放接口接收图片,然后上传并插入链接。S3 是对象存储常用的一组访问接口;Cloudflare R2 支持兼容接口,因此可以作为上传目的地。
博客工作区的 .vscode/settings.json 已启用插件,目标是 R2 的 lobe3 桶,图片放在 images/ 前缀下,命名方式为 UUID(随机生成的唯一标识),不保留原始文件名。
以下 JSON 是可解析的设置模板。把它合并进对应设置对象,替换账户、域名和凭据占位值后才能上传。凭据字段放在 VSCode 用户设置里,其余项目设置可放在工作区;不要把真实密钥写进准备公开的文章或版本库。
{
"pasteS3.enabled": true,
"pasteS3.uploadDestination": "s3",
"pasteS3.s3.region": "auto",
"pasteS3.s3.endpoint": "https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com",
"pasteS3.s3.bucket": "lobe3",
"pasteS3.s3.prefix": "images/",
"pasteS3.s3.publicUrlBase": "https://YOUR_PUBLIC_DOMAIN/images/",
"pasteS3.s3.forcePathStyle": true,
"pasteS3.fileNamingMethod": "uuid",
"pasteS3.keepOriginalFilename": false,
"pasteS3.mimeTypeFilter": "^image/",
"pasteS3.imageSnippet": "",
"pasteS3.s3.accessKeyId": "YOUR_ACCESS_KEY_ID",
"pasteS3.s3.secretAccessKey": "YOUR_SECRET_ACCESS_KEY"
}
prefix 决定对象键的前缀,publicUrlBase 是插入文章时使用的公开 URL 前缀,二者应指向同一组图片。mimeTypeFilter 按媒体类型筛选图片;imageSnippet 指定插入 Markdown 图片语法。forcePathStyle 则让存储桶名称出现在请求路径中。
这里的设置名依据本机安装版本的 package.json 核对,使用 pasteS3.*。该扩展附带 README 中部分示例仍写成 paste-s3.*,复制旧示例时需要核对已安装版本的设置名称。
配置生效后,在博客工作区打开 Markdown 文件,截图并等待监视进程完成复制,再按 Command+V。截图本身不会上传;上传发生在编辑器接收到图片并调用插件时。
按三个环节验证
先确认文件确实生成在 ~/Pictures/capture。再检查后台状态和日志:
launchctl print "gui/$(id -u)/com.alphabelt.screenshot-copy-clipboard"
tail -n 30 "$HOME/Library/Logs/screenshot-copy-clipboard.log"
截一张新图后,日志应新增 copied 文件名 (字节数 bytes)。检查当天,日志中最新的成功记录是 2026 年 9 月 11 日 17:02:26;这说明此前有成功复制记录,不代表本次检查重新执行过截图或上传测试。
最后在 VSCode 中粘贴,确认得到 Markdown 图片链接,并访问该图片地址。公开地址应返回 HTTP 200,Content-Type 应为预期的图片媒体类型。需要在终端检查响应头时,可以运行以下命令并输入实际图片地址:
printf 'Public image URL: '
IFS= read -r image_url
curl --location --head "$image_url"
如果日志已经出现 copied,但编辑器没有上传,应继续检查当前工作区的扩展启用状态、粘贴处理选项和 S3 连接。整理这篇记录时只核对了代码、配置和历史日志,没有进行新的网络上传测试。
现有脚本的适用边界
它适合单次截图后粘贴到文章的操作,但仍有几处限制。
首先,脚本只选择目录里最新的一张图片。连续截图或多显示器同时生成多个文件时,它可能跳过其他文件;超过 8 秒的旧文件也不会被补处理。它不是可靠传递每个文件的任务队列。
其次,任何刚写入该目录的受支持图片都可能触发复制,覆盖原有剪贴板内容。它不识别图片是否来自系统截图,也不识别当前前台应用。
扩展名集合中虽然包含 TIFF 和 HEIC,复制函数却把所有非 JPEG 文件都按 PNG 读取。当前已有 PNG 路径的成功日志,不能据此声称支持所有列出的格式。若改用其他截图格式,应补充对应处理并验证。
AppleScript 语句直接插入文件路径,含英文双引号等特殊字符的文件名可能导致错误。日志也记录过以点开头的临时截图文件,以及文件重命名期间的 No such file or directory。脚本捕获异常后继续轮询,但这些情况仍可能导致漏复制或重复复制。
排查时,常见配置位置未发现 Karabiner、BetterTouchTool、Hammerspoon 的截图重映射,也没有发现相关 shell 启动项;用户 Services 目录为空。Shortcuts 名称列表没有明显的截图任务,但数据库受到权限限制,未逐条核查内部动作。这些结果限定在本次已检查范围内。
停用自动复制,保留系统截图
临时停止任务可以卸载当前用户会话中的服务:
launchctl bootout "gui/$(id -u)/com.alphabelt.screenshot-copy-clipboard"
只卸载服务会保留 plist,下次登录仍可能自动加载。若希望之后登录也保持停用,可以先禁用任务,再卸载当前实例:
launchctl disable "gui/$(id -u)/com.alphabelt.screenshot-copy-clipboard"
launchctl bootout "gui/$(id -u)/com.alphabelt.screenshot-copy-clipboard"
如果任务已经卸载,第二条命令可能报告找不到服务。重新启用并加载:
launchctl enable "gui/$(id -u)/com.alphabelt.screenshot-copy-clipboard"
launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/com.alphabelt.screenshot-copy-clipboard.plist"
这些操作不删除截图,也不修改系统快捷键。若还想把截图位置改为桌面并重新显示缩略图,可以单独执行:
defaults write com.apple.screencapture location -string "$HOME/Desktop"
defaults write com.apple.screencapture show-thumbnail -bool true
这是设置为指定行为,不是恢复一份已保存的历史配置。之后若重新启用监视进程,记得让脚本中的 DIR 与实际截图目录保持一致。
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:让 macOS 截图同时保存文件并复制到剪贴板,配合 VSCode 上传到 R2
本文链接:https://www.sshipanoo.com/blog/tech/macos-screenshot-clipboard-vscode-r2/