已经是最新一篇文章了!
已经是最后一篇文章了!

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-thumbnail0关闭浮动缩略图
targetfile保存目标为文件
captureDelay5截图工具保存的延迟选项
showsClicks1保存的显示点击选项
styledisplay保存的全屏选择模式
video1截图工具保存的视频模式状态
last-selectionX=709,Y=100,宽976,高728上次区域
last-selection-display0上次选择的显示器
last-analytics-stamp810434079.9156359内部统计值

typedisable-shadowinclude-datename 都没有显式设置。现有文件名包含日期和时间,抽查文件头确认图片为 PNG;这些观察不能替代对其他 macOS 版本默认值的验证。

captureDelay=5video=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 管理。当前配置启用了 RunAtLoadKeepAlive:加载时启动,进程退出后由系统再次拉起。

下面的命令会根据当前用户目录生成完整配置,避免在 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 检查配置格式,成功时输出包含 OKlaunchctl 用来加载和查看后台任务。状态输出中的 state = running 表示进程正在运行。已经加载过的任务不需要重复 bootstrap

检查当天,这个任务为 running,进程编号是 1163。进程编号会随重启变化,不是配置的一部分。

为什么后来换成 Pictures 目录

脚本和 LaunchAgent 的创建时间都是 2026 年 8 月 28 日 17:52 左右。早期日志反复出现对 ~/Desktop/captureOperation 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": "![${1:$TM_SELECTED_TEXT}](${url})",
  "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/