From 638f5b39677b257aa765e2a3430d537b619966c5 Mon Sep 17 00:00:00 2001 From: Jiang Chaoqun Date: Tue, 14 Jul 2026 17:09:40 +0800 Subject: [PATCH] feat: add Alfred remote upload workflow --- .github/workflows/alfred-workflow.yml | 58 +++ .github/workflows/release.yml | 35 +- .gitignore | 1 + AGENTS.md | 12 + README.MD | 14 +- docs/plugins/alfred_remote_upload.md | 109 +++++ docs/release.md | 27 +- .../2026-07-14-alfred-remote-upload-design.md | 84 ++++ plugins/alfred_remote_upload/build.sh | 22 + .../tests/clipboard_fixture.js | 135 ++++++ .../tests/clipboard_integration.sh | 90 ++++ .../tests/fakes/clipboard | 14 + .../alfred_remote_upload/tests/fakes/notify | 5 + .../alfred_remote_upload/tests/fakes/pbcopy | 9 + plugins/alfred_remote_upload/tests/fakes/scp | 26 ++ plugins/alfred_remote_upload/tests/fakes/ssh | 27 ++ plugins/alfred_remote_upload/tests/run.sh | 421 ++++++++++++++++++ .../alfred_remote_upload/workflow/info.plist | 181 ++++++++ .../workflow/scripts/clipboard.js | 155 +++++++ .../workflow/scripts/hosts.js | 234 ++++++++++ .../workflow/scripts/list_hosts.sh | 6 + .../workflow/scripts/remote_helper.sh | 107 +++++ .../workflow/scripts/upload.sh | 244 ++++++++++ 23 files changed, 2004 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/alfred-workflow.yml create mode 100644 docs/plugins/alfred_remote_upload.md create mode 100644 docs/specs/2026-07-14-alfred-remote-upload-design.md create mode 100755 plugins/alfred_remote_upload/build.sh create mode 100644 plugins/alfred_remote_upload/tests/clipboard_fixture.js create mode 100755 plugins/alfred_remote_upload/tests/clipboard_integration.sh create mode 100755 plugins/alfred_remote_upload/tests/fakes/clipboard create mode 100755 plugins/alfred_remote_upload/tests/fakes/notify create mode 100755 plugins/alfred_remote_upload/tests/fakes/pbcopy create mode 100755 plugins/alfred_remote_upload/tests/fakes/scp create mode 100755 plugins/alfred_remote_upload/tests/fakes/ssh create mode 100755 plugins/alfred_remote_upload/tests/run.sh create mode 100644 plugins/alfred_remote_upload/workflow/info.plist create mode 100644 plugins/alfred_remote_upload/workflow/scripts/clipboard.js create mode 100644 plugins/alfred_remote_upload/workflow/scripts/hosts.js create mode 100755 plugins/alfred_remote_upload/workflow/scripts/list_hosts.sh create mode 100755 plugins/alfred_remote_upload/workflow/scripts/remote_helper.sh create mode 100755 plugins/alfred_remote_upload/workflow/scripts/upload.sh diff --git a/.github/workflows/alfred-workflow.yml b/.github/workflows/alfred-workflow.yml new file mode 100644 index 0000000..e426516 --- /dev/null +++ b/.github/workflows/alfred-workflow.yml @@ -0,0 +1,58 @@ +name: Alfred Workflow + +on: + pull_request: + paths: + - '.github/workflows/alfred-workflow.yml' + - '.github/workflows/release.yml' + - 'plugins/alfred_remote_upload/**' + - 'docs/plugins/alfred_remote_upload.md' + - 'docs/specs/2026-07-14-alfred-remote-upload-design.md' + - 'docs/release.md' + - 'README.MD' + - 'AGENTS.md' + push: + branches: + - master + paths: + - '.github/workflows/alfred-workflow.yml' + - 'plugins/alfred_remote_upload/**' + +permissions: + contents: read + +jobs: + test: + name: Test and package Alfred workflow + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Check shell syntax + run: | + bash -n plugins/alfred_remote_upload/build.sh + bash -n plugins/alfred_remote_upload/tests/clipboard_integration.sh + bash -n plugins/alfred_remote_upload/tests/run.sh + bash -n plugins/alfred_remote_upload/tests/fakes/* + bash -n plugins/alfred_remote_upload/workflow/scripts/*.sh + + - name: Run offline tests + run: plugins/alfred_remote_upload/tests/run.sh + + - name: Run clipboard integration tests + run: plugins/alfred_remote_upload/tests/clipboard_integration.sh + + - name: Build and inspect package + run: | + artifact="$RUNNER_TEMP/life_tools_alfred_remote_upload_ci.alfredworkflow" + plugins/alfred_remote_upload/build.sh "$artifact" + plutil -lint plugins/alfred_remote_upload/workflow/info.plist + unzip -t "$artifact" + + - name: Upload dry-run artifact + uses: actions/upload-artifact@v4 + with: + name: life_tools_alfred_remote_upload_ci + path: ${{ runner.temp }}/life_tools_alfred_remote_upload_ci.alfredworkflow + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa602c6..95d60e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/release.yml' - '.github/workflows/go-test.yml' - '.github/workflows/python-test.yml' + - '.github/workflows/alfred-workflow.yml' - 'docs/**' - 'README.MD' - 'AGENTS.md' @@ -14,13 +15,14 @@ on: - '**/*.go' - 'cli/video_subtitle/**' - 'emby_plugins/video_subtitle/**' + - 'plugins/alfred_remote_upload/**' - 'sample/life_tools/**' push: tags: - 'v*' permissions: - contents: write + contents: read jobs: release: @@ -140,23 +142,46 @@ jobs: zip -qr "$dist/$plugin_pkg.zip" "$plugin_pkg" ) + plugins/alfred_remote_upload/build.sh "$dist/life_tools_alfred_remote_upload_${tag}.alfredworkflow" + ( cd "$dist" - sha256sum *.zip > checksums.txt + sha256sum *.zip *.alfredworkflow > checksums.txt ) ls -lh "$dist" + - name: Upload release assets + uses: actions/upload-artifact@v4 + with: + name: release-assets + path: dist/ + if-no-files-found: error + + publish: + name: Publish GitHub release + if: github.ref_type == 'tag' + needs: release + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Download release assets + uses: actions/download-artifact@v4 + with: + name: release-assets + path: dist + - name: Create GitHub release - if: github.ref_type == 'tag' env: GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} run: | set -euo pipefail tag="${GITHUB_REF_NAME}" title="life_tools ${tag}" if gh release view "$tag" >/dev/null 2>&1; then - gh release upload "$tag" dist/*.zip dist/checksums.txt --clobber + gh release upload "$tag" dist/*.zip dist/*.alfredworkflow dist/checksums.txt --clobber else - gh release create "$tag" dist/*.zip dist/checksums.txt --title "$title" --generate-notes + gh release create "$tag" dist/*.zip dist/*.alfredworkflow dist/checksums.txt --title "$title" --generate-notes fi diff --git a/.gitignore b/.gitignore index 84a3b76..08d730f 100644 --- a/.gitignore +++ b/.gitignore @@ -108,5 +108,6 @@ gui/**/.swiftpm/ gui/**/dist/ gui/**/*.xcodeproj/ gui/**/*.xcworkspace/ +plugins/**/dist/ *.app *.dSYM/ diff --git a/AGENTS.md b/AGENTS.md index 96d0898..90053ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ - `cli/codex_inspector/`:本机 Codex 历史会话、活跃度和记忆内容只读查看工具,当前是实验 CLI,不进默认安装清单。 - `cli/video_subtitle/`:单视频自动生成中文字幕工具,详细说明见 `docs/cli/video_subtitle.md`。 - `emby_plugins/video_subtitle/`:Emby Server 插件,后端调用 `video_subtitle` 生成字幕,详细说明见 `docs/plugins/emby_video_subtitle.md`。 +- `plugins/alfred_remote_upload/`:Alfred 5 Workflow,将剪贴板中的图片或 Finder 单文件上传到 SSH 主机,详细说明见 `docs/plugins/alfred_remote_upload.md`。 - `gui/interview_timer/`:macOS 面试悬浮计时 GUI 应用,说明见 `docs/gui/interview_timer.md`。 - `docs/cli/`:CLI 工具文档。 - `docs/plugins/`:插件程序文档。 @@ -69,6 +70,15 @@ swift build --product InterviewTimerApp ./scripts/build_app.sh ``` +Alfred Workflow 也不走根目录 `build.sh`。修改 `plugins/alfred_remote_upload` 时运行: + +```bash +plugins/alfred_remote_upload/tests/run.sh +plugins/alfred_remote_upload/tests/clipboard_integration.sh +plugins/alfred_remote_upload/build.sh +plutil -lint plugins/alfred_remote_upload/workflow/info.plist +``` + ## Go 代码规则 - 保持 Go 1.18 兼容,不随手升级语言版本。 @@ -160,6 +170,7 @@ emby_plugins/video_subtitle/install.sh --help `.github/workflows/release.yml` 负责 tag 发布,不是普通 CI。修改发布流程时要同时关注 Go、Python `video_subtitle` 和 Emby 插件三类产物。 `.github/workflows/swift-mac-app.yml` 负责 `gui/interview_timer` 的 Swift 单测、编译和未签名 `.app` 发布。 +`.github/workflows/alfred-workflow.yml` 负责 `plugins/alfred_remote_upload` 的 macOS 离线测试、plist 校验和打包验证。 - tag 触发规则保持 `v*`,避免普通分支 push 意外创建 Release;`pull_request` 只能做 dry-run,不能创建 Release。 - Go 测试放在 `.github/workflows/go-test.yml`,PR 时必须真实运行 `go test ./...`,不能做成只提醒不阻塞的 reminder。 @@ -168,6 +179,7 @@ emby_plugins/video_subtitle/install.sh --help - `video_subtitle` 只能按源码包发布,不能宣传成免依赖二进制;它仍依赖 Python、ffmpeg、TOS、ASR 和 LLM 配置。 - Emby 插件包只放 `LifeTools.Emby.VideoSubtitle.Emby.dll` 和文档,不要把 `MediaBrowser.*`、`Emby.*` 或核心库 DLL 打进插件发布包。 - `InterviewTimer` macOS App 只在 macOS runner 上构建,tag 发布包为未签名的 `InterviewTimer.app` zip,不要把它塞进根目录 `install.sh` 或 Go 二进制发布包。 +- Alfred Workflow 只依赖 macOS/JXA/OpenSSH,发布资产名为 `life_tools_alfred_remote_upload_.alfredworkflow`;用户主机配置和 MRU 状态不能进入安装包,也不要把它加入根目录 CLI 安装脚本。 - 新增 CLI、构建入口、release 产物或测试要求时,必须检查 `.github/workflows/*` 是否需要同步,避免代码合入后没有 CI 或 tag 发布产物缺失。 - 修改发布包内容时同步更新 `docs/release.md` 和 README 的发布入口。 - 发布 workflow 需要 `permissions: contents: write`,不要扩大到无关权限。 diff --git a/README.MD b/README.MD index 2dbb2ba..a359e7d 100644 --- a/README.MD +++ b/README.MD @@ -1,6 +1,6 @@ # life_tools -个人日常使用的小工具集合,按应用类型组织源码:命令行程序放在 `cli/`,插件程序放在 `emby_plugins/`,图形界面程序放在 `gui/`。 +个人日常使用的小工具集合,按应用类型组织源码:命令行程序放在 `cli/`,独立插件放在 `plugins/`,Emby 插件放在 `emby_plugins/`,图形界面程序放在 `gui/`。 ## 界面预览 @@ -58,6 +58,7 @@ | CLI | `file_share` | `cli/file_share/` | `file_share` | 临时启动只读 HTTP 文件分享服务 | [docs/cli/file_share.md](docs/cli/file_share.md) | | CLI | `video_subtitle` | `cli/video_subtitle/` | `video_subtitle` | 为单个视频生成简体中文字幕 | [docs/cli/video_subtitle.md](docs/cli/video_subtitle.md) | | Experimental CLI | `codex_inspector` | `cli/codex_inspector/` | `codex_inspector` | 本机只读查看 Codex 会话、token 用量、活跃度和记忆内容 | [docs/cli/codex_inspector.md](docs/cli/codex_inspector.md) | +| Plugin | Alfred Remote Upload | `plugins/alfred_remote_upload/` | `Remote Upload.alfredworkflow` | 将剪贴板图片或 Finder 单文件上传到 SSH 主机,并复制远端路径 | [docs/plugins/alfred_remote_upload.md](docs/plugins/alfred_remote_upload.md) | | Plugin | Emby 字幕插件 | `emby_plugins/video_subtitle/` | `LifeTools.Emby.VideoSubtitle.Emby.dll` | 在 Emby 后台调用 `video_subtitle` 生成字幕 | [docs/plugins/emby_video_subtitle.md](docs/plugins/emby_video_subtitle.md) | | GUI | InterviewTimer | `gui/interview_timer/` | `InterviewTimer.app` | macOS 面试悬浮计时器 | [docs/gui/interview_timer.md](docs/gui/interview_timer.md) | @@ -66,6 +67,7 @@ | 路径 | 内容 | |---|---| | `cli/` | 命令行工具源码。Go CLI 和 Python CLI 都放在这里。 | +| `plugins/` | 独立插件源码。Alfred Workflow 放在这里。 | | `emby_plugins/` | 插件类应用源码。当前只有 Emby 字幕插件。 | | `gui/` | GUI 应用源码。当前只有 InterviewTimer macOS App。 | | `docs/cli/` | CLI 工具的设计、结构、流程和使用文档。 | @@ -140,8 +142,16 @@ swift build --product InterviewTimerApp ./scripts/build_app.sh ``` +Alfred Remote Upload 验证与打包: + +```bash +plugins/alfred_remote_upload/tests/run.sh +plugins/alfred_remote_upload/tests/clipboard_integration.sh +plugins/alfred_remote_upload/build.sh +``` + 具体工具的测试和排障入口见各自详细文档。 ## 发布 -推送 `v*` tag 会通过 GitHub Actions 构建 GitHub Release 资产,包括 Linux/macOS Go 二进制包、`video_subtitle` 源码包、Emby 插件包和未签名的 `InterviewTimer.app` 包。Go 二进制包包含 `codex_inspector`,但它仍是实验工具,不进入默认安装清单。发布流程见 [docs/release.md](docs/release.md)。 +推送 `v*` tag 会通过 GitHub Actions 构建 GitHub Release 资产,包括 Linux/macOS Go 二进制包、`video_subtitle` 源码包、Emby 插件包、Alfred Workflow 和未签名的 `InterviewTimer.app` 包。Go 二进制包包含 `codex_inspector`,但它仍是实验工具,不进入默认安装清单。发布流程见 [docs/release.md](docs/release.md)。 diff --git a/docs/plugins/alfred_remote_upload.md b/docs/plugins/alfred_remote_upload.md new file mode 100644 index 0000000..48815fc --- /dev/null +++ b/docs/plugins/alfred_remote_upload.md @@ -0,0 +1,109 @@ +# Alfred Remote Upload + +`Remote Upload` 把 macOS 剪贴板中的单张图片或 Finder 单个普通文件上传到 SSH 主机,并把远端绝对路径写回剪贴板。典型用途是把本地截图上传到运行 Codex 的开发机,再把 `/home/user/tmp_share/20260714_1.png` 直接粘贴到对话中。 + +## 工作流程 + +```mermaid +flowchart TD + A["复制 Finder 单文件或截图到剪贴板"] --> B["打开 Alfred,输入 up"] + B --> C["按名称或 SSH Host 过滤"] + C --> D["选择目标主机"] + D --> E["SSH 分配路径并用 SCP 上传"] + E --> F["远端文件权限设为 0644"] + F --> G["远端绝对路径写回剪贴板"] +``` + +## 环境要求 + +| 依赖 | 要求 | +|---|---| +| macOS | 提供 JXA、`pbcopy` 和 OpenSSH | +| Alfred | Alfred 5 Powerpack,可运行 Workflow | +| SSH | OpenSSH 客户端支持 `scp -s`,Host key 已确认,密钥或 agent 可以无交互登录 | +| 远端 | Linux 或 macOS,启用 SFTP,并提供 POSIX `sh`、`mkdir`、`rm`、`test`、`chmod` | + +Workflow 不读取密码,也不管理密钥。用户、端口、IdentityFile 和 ProxyJump 都继续配置在 `~/.ssh/config`。 + +## 构建与安装 + +在仓库根目录执行: + +```bash +plugins/alfred_remote_upload/tests/run.sh +plugins/alfred_remote_upload/tests/clipboard_integration.sh +plugins/alfred_remote_upload/build.sh +open "plugins/alfred_remote_upload/dist/Remote Upload.alfredworkflow" +``` + +打开产物后由 Alfred 完成安装。升级 Workflow 时,Alfred Workflow Configuration 中的用户值独立保存,不会被仓库中的默认值覆盖。 + +## 配置 + +在 Alfred Preferences 的 `Remote Upload` Workflow 配置页填写: + +| 变量 | 必填 | 默认值 | 规则 | +|---|---:|---:|---| +| `hosts_json` | 是 | `[]` | JSON 数组;每项包含唯一 `name`、安全 SSH `host` 别名和绝对 `remote_dir` | +| `max_size_mb` | 否 | `100` | 普通文件和图片共用的大小上限,单位 MiB,必须为正整数 | + +完整示例: + +```json +[ + { + "name": "devbox", + "host": "devbox", + "remote_dir": "/home/jiangchaoqun.true/tmp_share" + }, + { + "name": "devbox-test", + "host": "devbox", + "remote_dir": "/tmp/life_tools_alfred_remote_upload_e2e" + } +] +``` + +`host` 必须是 `~/.ssh/config` 中可直接使用的别名,不能写 `user@host`、端口参数或任意 shell 片段。可先验证无交互连接: + +```bash +ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=10 devbox true +``` + +## 使用方式 + +1. 在 Finder 中复制一个普通文件,或把一张图片放入剪贴板。 +2. 打开 Alfred,输入 `up`;继续输入名称或 Host 可以过滤主机。 +3. 选择目标主机并回车。 +4. 收到成功通知后直接粘贴,剪贴板内容是远端绝对路径。 + +最近成功使用的主机会排在前面。未使用主机保持配置顺序;MRU 状态损坏时自动回退,不影响上传。 + +## 文件处理规则 + +| 场景 | 行为 | +|---|---| +| PNG、JPEG、GIF、WebP | 原样上传 | +| TIFF、HEIC、未知但可解码的图片 | 转换为 PNG | +| 截图命名 | `/YYYYMMDD_N.ext`,跨后缀统一递增 | +| 普通文件命名 | `/YYYYMMDD/原名.ext` | +| 普通文件重名 | 扩展名前追加 `_1`、`_2` | +| 上传成功 | 远端权限为 `0644`,剪贴板替换为远端绝对路径 | +| 上传失败 | 不修改剪贴板;上传或权限失败会清理远端残片 | + +不支持目录、多文件、特殊文件、失效 Finder 路径、纯文本和文件名中的控制字符。 + +## 安全与排障 + +Workflow 强制非交互 SSH、严格 Host key 校验、10 秒连接超时,并用 `scp -s` 锁定 SFTP 协议,避免 legacy SCP 通过远端 shell 解释文件名。远端命名先占位再上传,已有文件不会被覆盖。 + +| 提示或现象 | 检查项 | +|---|---| +| `主机配置错误` | 用 JSON 校验器检查 `hosts_json`,确认字段齐全、名称唯一、目录是绝对路径 | +| `无法在远端创建上传路径` | 确认 `remote_dir` 的父目录可写、磁盘未满、远端提供 POSIX `sh` | +| `SCP 上传失败` | 直接运行 SSH 探测命令,检查密钥、端口、ProxyJump 和网络 | +| `远端权限设置失败` | 检查目标文件属主和远端文件系统是否允许 `chmod` | +| `远端文件已上传,但写入剪贴板失败` | 错误中包含已保留的远端路径;手动复制该路径,MRU 不会更新 | +| 纯文本剪贴板无法上传 | 这是明确边界;请复制 Finder 文件或图片数据 | + +实现细节和失败语义见[设计文档](../specs/2026-07-14-alfred-remote-upload-design.md)。 diff --git a/docs/release.md b/docs/release.md index 1a0f28f..50b582c 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,6 +1,6 @@ # 发布说明 -本仓库通过 GitHub Actions 在推送 `v*` tag 时自动构建发布包,并把 zip 上传到 GitHub Release。PR 会执行 Go 单测和发布包打包 dry-run,但不会创建 Release。Python 单元测试在单独 workflow 里运行,失败只作为提醒,不阻塞发布包流程。Swift macOS App 使用独立 workflow 验证和上传未签名 `.app` 产物。 +本仓库通过 GitHub Actions 在推送 `v*` tag 时自动构建发布包,并上传到 GitHub Release。PR 会执行 Go 单测和发布包打包 dry-run,但不会创建 Release。Python 单元测试在单独 workflow 里运行,失败只作为提醒,不阻塞发布包流程。Swift macOS App 和 Alfred Workflow 使用独立的 macOS workflow 验证。 ## 触发方式 @@ -18,6 +18,7 @@ workflow 文件: ```text .github/workflows/release.yml .github/workflows/swift-mac-app.yml +.github/workflows/alfred-workflow.yml ``` 触发条件: @@ -37,16 +38,19 @@ on: - '**/*.go' - 'cli/video_subtitle/**' - 'emby_plugins/video_subtitle/**' + - 'plugins/alfred_remote_upload/**' - 'sample/life_tools/**' push: tags: - 'v*' ``` -`pull_request` 在 release workflow 中只做 Python 编译检查、Emby 插件测试和打包 dry-run;只有 tag push 才执行 `gh release create` 或 `gh release upload`。Go 测试由 `.github/workflows/go-test.yml` 单独执行,失败会阻塞 PR。Python 单元测试由 `.github/workflows/python-test.yml` 单独执行,失败时只写 GitHub warning 和 summary,本身仍返回成功,不阻塞发布包流程。 +`pull_request` 在 release workflow 中只做 Python 编译检查、Emby 插件测试和打包 dry-run;只有 tag push 才进入独立的 `Publish GitHub release` job,执行 `gh release create` 或 `gh release upload`。Go 测试由 `.github/workflows/go-test.yml` 单独执行,失败会阻塞 PR。Python 单元测试由 `.github/workflows/python-test.yml` 单独执行,失败时只写 GitHub warning 和 summary,本身仍返回成功,不阻塞发布包流程。 `swift-mac-app.yml` 使用 `macos-latest` runner。PR 和 `master` 推送会验证 `gui/interview_timer` 的 Swift 单测、可执行产物构建和 `.app` 打包;`v*` tag 会额外上传未签名的 `InterviewTimer.app` zip。 +`alfred-workflow.yml` 使用 `macos-latest` runner。相关 PR 会检查 JXA、Shell、plist、离线测试和 `.alfredworkflow` 打包,并上传 dry-run artifact;不会创建 tag 或 Release。 + ## 发布操作流程 1. 先把发布相关 PR 合并到 `master`。 @@ -77,6 +81,7 @@ life_tools_darwin_amd64_.zip life_tools_darwin_arm64_.zip life_tools_video_subtitle_source_.zip life_tools_emby_video_subtitle_plugin_.zip +life_tools_alfred_remote_upload_.alfredworkflow life_tools_interview_timer_macos_.zip life_tools_interview_timer_macos_.sha256 checksums.txt @@ -116,6 +121,8 @@ InterviewTimer.app 该包由 `gui/interview_timer/scripts/build_app.sh` 生成,当前不做代码签名和 notarization。macOS 首次打开时可能需要用户在系统安全设置中手动允许。 +Alfred 产物是可直接导入 Alfred 5 的 `life_tools_alfred_remote_upload_.alfredworkflow`。用户的 `hosts_json` 和 MRU 状态由 Alfred 及其 workflow data 目录管理,不进入发布包。 + ## CI 验证 发布前 release workflow 会运行: @@ -147,15 +154,25 @@ Python 测试提示 workflow 会运行: python3 -m unittest cli/video_subtitle/video_subtitle_test.py ``` +Alfred Workflow macOS CI 会运行: + +```bash +plugins/alfred_remote_upload/tests/run.sh +plugins/alfred_remote_upload/tests/clipboard_integration.sh +plugins/alfred_remote_upload/build.sh +plutil -lint plugins/alfred_remote_upload/workflow/info.plist +unzip -t /tmp/life_tools_alfred_remote_upload_ci.alfredworkflow +``` + Go 测试失败会阻塞 PR;Python 测试失败时只写 GitHub warning 和 summary,不阻塞 release workflow,也不阻止 tag 发布资产。 ## 权限 -Release workflow 需要: +Release workflow 顶层和构建 job 只需要只读权限: ```yaml permissions: - contents: write + contents: read ``` -这是 `gh release create` 和 `gh release upload` 上传资产所需的最小仓库权限。 +只有 tag 触发的 `publish` job 提升为 `contents: write`,并通过 `GH_REPO` 显式指定当前仓库,用于 `gh release create` 和 `gh release upload`。PR dry-run 不具备仓库写权限。 diff --git a/docs/specs/2026-07-14-alfred-remote-upload-design.md b/docs/specs/2026-07-14-alfred-remote-upload-design.md new file mode 100644 index 0000000..4972b60 --- /dev/null +++ b/docs/specs/2026-07-14-alfred-remote-upload-design.md @@ -0,0 +1,84 @@ +# Alfred 远端上传 Workflow 设计 + +## 1. 目标与边界 + +`Remote Upload` 解决本地 macOS 截图或 Finder 文件无法被 SSH 远端 Codex 直接读取的问题。用户通过 Alfred 关键字 `up` 选择主机,Workflow 上传剪贴板内容,并在成功后把远端绝对路径写回剪贴板。 + +当前方案只支持 macOS、Alfred 5 Powerpack、OpenSSH、单个普通文件或单张图片。不支持目录、多文件、文本路径、Windows SSH、公网 URL、Markdown 包装和自动过期。 + +## 2. 组件与数据流 + +```mermaid +flowchart LR + A["剪贴板:Finder 单文件或图片"] --> B["up Script Filter"] + B --> C["hosts.js:校验配置、过滤、MRU 排序"] + C --> D["upload.sh:大小检查与流程编排"] + D --> E["clipboard.js:读取或转码"] + D --> F["SSH:原子分配远端文件名"] + F --> G["SCP:上传文件"] + G --> H["SSH:chmod 0644"] + H --> I["pbcopy:写入远端绝对路径"] + I --> J["hosts.js:更新 MRU"] +``` + +| 组件 | 职责 | 持久化数据 | +|---|---|---| +| `info.plist` | 定义 Workflow 配置和 `up → Script Filter → Upload` 连线 | 无 | +| `hosts.js` | 校验主机列表、过滤、排序、更新最近使用状态 | `recent_hosts.json` | +| `clipboard.js` | Finder 文件识别、图片格式保留或 PNG 转换 | 仅临时文件 | +| `upload.sh` | 大小限制、SSH/SCP 调度、清理、剪贴板和通知 | 无 | +| `remote_helper.sh` | 分配文件名、占位、权限设置和失败清理 | 远端目标文件 | + +## 3. 配置与状态 + +主机配置由 Alfred Workflow Configuration 管理,不写入安装包。`hosts_json` 是对象数组,每项包含: + +| 字段 | 约束 | 用途 | +|---|---|---| +| `name` | 非空且唯一 | Alfred 列表显示名称 | +| `host` | `A-Z`、`a-z`、数字、点、下划线或连字符,首字符必须是字母或数字 | `~/.ssh/config` 中的 Host 别名 | +| `remote_dir` | 不含控制字符的绝对路径 | 上传根目录 | + +`max_size_mb` 默认 `100`,必须为正整数。最近成功使用顺序写入 `alfred_workflow_data/recent_hosts.json`,最多保留 50 项;状态缺失、损坏或与当前配置不匹配时回退到配置顺序。 + +## 4. 剪贴板与命名规则 + +Finder 单个普通文件优先于图片数据。目录、多文件、特殊文件、失效路径和纯文本均返回失败。 + +| 输入 | 处理 | 远端路径示例 | +|---|---|---| +| PNG、JPEG、GIF、WebP 图片 | 保留原始编码 | `/share/20260714_1.png` | +| TIFF、HEIC、其他可解码图片 | 转为 PNG | `/share/20260714_2.png` | +| 普通文件 | 保留文件名,放入日期子目录 | `/share/20260714/report.txt` | +| 普通文件重名 | 扩展名前追加递增序号 | `/share/20260714/report_1.txt` | + +截图序号从 1 开始,同一天跨扩展名统一递增。远端通过 `noclobber` 创建 reservation 和占位文件,避免并发上传覆盖已有文件。占位初始权限为 `0600`,上传完成后显式改为 `0644`。reserve 结果使用固定协议标记,并在 SCP 前校验目录前缀、日期、文件名、序号和 reservation 路径,异常输出不能改变上传目标。 + +## 5. 成功边界与失败语义 + +完整成功顺序是:远端占位、SCP、`chmod 0644`、`pbcopy`、MRU 更新。前三步失败会清理 reservation、占位和上传残片,且不修改剪贴板。`pbcopy` 失败时远端文件已经完成,因此保留文件并返回其路径用于排障,但不更新 MRU。MRU 写入失败只影响排序,不回滚已上传文件。 + +SSH 固定启用: + +```text +BatchMode=yes +NumberOfPasswordPrompts=0 +StrictHostKeyChecking=yes +ConnectTimeout=10 +``` + +端口、密钥、用户、ProxyJump 和其他认证策略由用户现有的 `~/.ssh/config` 管理。上传固定使用 `scp -s` 的 SFTP 协议,不允许 legacy SCP 把文件名交给远端 shell 解析。主机别名和远端目录经过约束,其他传入远端 shell 的动态值使用单引号安全编码。 + +## 6. 验证策略 + +离线测试使用假的 SSH、SCP、pbcopy 和通知程序,不连接真实主机,覆盖配置错误、MRU 降级、格式策略、大小边界、Unicode 与单引号文件名、重名、跨格式序号、权限以及各阶段失败清理。 + +```bash +plugins/alfred_remote_upload/tests/run.sh +plugins/alfred_remote_upload/tests/clipboard_integration.sh +plugins/alfred_remote_upload/build.sh +plutil -lint plugins/alfred_remote_upload/workflow/info.plist +unzip -t plugins/alfred_remote_upload/dist/Remote\ Upload.alfredworkflow +``` + +真实验收使用临时远端目录,完成后删除测试文件;正式目录只进行可识别的受控冒烟上传,不删除目录本身。 diff --git a/plugins/alfred_remote_upload/build.sh b/plugins/alfred_remote_upload/build.sh new file mode 100755 index 0000000..a6e5e00 --- /dev/null +++ b/plugins/alfred_remote_upload/build.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")" && pwd)" +WORKFLOW_DIR="$PLUGIN_DIR/workflow" +requested_output="${1:-$PLUGIN_DIR/dist/Remote Upload.alfredworkflow}" +output_dir="$(dirname "$requested_output")" +output_name="$(basename "$requested_output")" + +mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd)" +output_path="$output_dir/$output_name" +/bin/rm -f "$output_path" + +( + cd "$WORKFLOW_DIR" + /usr/bin/zip -qry "$output_path" . \ + -x '*.DS_Store' 'prefs.plist' 'recent_hosts.json' '*.alfredpreferences' +) + +printf '%s\n' "$output_path" diff --git a/plugins/alfred_remote_upload/tests/clipboard_fixture.js b/plugins/alfred_remote_upload/tests/clipboard_fixture.js new file mode 100644 index 0000000..4852382 --- /dev/null +++ b/plugins/alfred_remote_upload/tests/clipboard_fixture.js @@ -0,0 +1,135 @@ +ObjC.import("AppKit"); +ObjC.import("Foundation"); + +function pasteboard() { + return $.NSPasteboard.generalPasteboard; +} + +function writePasteboardItems(items) { + const board = pasteboard(); + board.clearContents; + if (!board.writeObjects(items)) { + throw new Error("无法写入测试剪贴板"); + } +} + +function snapshot(path) { + const serialized = []; + const items = pasteboard().pasteboardItems; + if (items) { + for (let itemIndex = 0; itemIndex < Number(items.count); itemIndex += 1) { + const item = items.objectAtIndex(itemIndex); + const values = []; + const types = item.types; + for (let typeIndex = 0; typeIndex < Number(types.count); typeIndex += 1) { + const type = types.objectAtIndex(typeIndex); + const data = item.dataForType(type); + if (data) { + values.push({ + type: ObjC.unwrap(type), + data: ObjC.unwrap(data.base64EncodedStringWithOptions(0)) + }); + } + } + serialized.push(values); + } + } + const content = $(JSON.stringify(serialized)); + if (!content.writeToFileAtomicallyEncodingError($(path), true, $.NSUTF8StringEncoding, $())) { + throw new Error("无法保存剪贴板快照"); + } +} + +function restore(path) { + const content = $.NSString.stringWithContentsOfFileEncodingError( + $(path), + $.NSUTF8StringEncoding, + $() + ); + if (!content) { + throw new Error("无法读取剪贴板快照"); + } + const serialized = JSON.parse(ObjC.unwrap(content)); + const items = $.NSMutableArray.array; + serialized.forEach(function (values) { + const item = $.NSPasteboardItem.alloc.init; + values.forEach(function (value) { + const data = $.NSData.alloc.initWithBase64EncodedStringOptions($(value.data), 0); + if (!data || !item.setDataForType(data, $(value.type))) { + throw new Error("无法恢复剪贴板数据类型:" + value.type); + } + }); + items.addObject(item); + }); + const board = pasteboard(); + board.clearContents; + if (Number(items.count) > 0 && !board.writeObjects(items)) { + throw new Error("无法恢复剪贴板"); + } +} + +function setImage(path, type) { + const data = $.NSData.dataWithContentsOfFile($(path)); + if (!data) { + throw new Error("无法读取图片 fixture"); + } + let pasteboardData = data; + if (type === "public.tiff") { + const image = $.NSImage.alloc.initWithData(data); + pasteboardData = image ? image.TIFFRepresentation : null; + } + if (!pasteboardData) { + throw new Error("无法生成 TIFF fixture"); + } + const item = $.NSPasteboardItem.alloc.init; + if (!item.setDataForType(pasteboardData, $(type))) { + throw new Error("无法设置图片类型:" + type); + } + writePasteboardItems($.NSArray.arrayWithObject(item)); +} + +function setFiles(rawPaths) { + const paths = JSON.parse(rawPaths); + const urls = $.NSMutableArray.array; + paths.forEach(function (path) { + urls.addObject($.NSURL.fileURLWithPath($(path))); + }); + writePasteboardItems(urls); +} + +function setText(value) { + const item = $.NSPasteboardItem.alloc.init; + if (!item.setStringForType($(value), $.NSPasteboardTypeString)) { + throw new Error("无法设置文本剪贴板"); + } + writePasteboardItems($.NSArray.arrayWithObject(item)); +} + +function run(argv) { + const command = argv[0]; + if (command === "snapshot") { + snapshot(argv[1]); + return ""; + } + if (command === "restore") { + restore(argv[1]); + return ""; + } + if (command === "set-png") { + setImage(argv[1], "public.png"); + return ""; + } + if (command === "set-tiff") { + setImage(argv[1], "public.tiff"); + return ""; + } + if (command === "set-files") { + setFiles(argv[1]); + return ""; + } + if (command === "set-text") { + setText(argv[1]); + return ""; + } + throw new Error("unknown command: " + command); +} diff --git a/plugins/alfred_remote_upload/tests/clipboard_integration.sh b/plugins/alfred_remote_upload/tests/clipboard_integration.sh new file mode 100755 index 0000000..840c206 --- /dev/null +++ b/plugins/alfred_remote_upload/tests/clipboard_integration.sh @@ -0,0 +1,90 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CLIPBOARD_SCRIPT="$ROOT_DIR/workflow/scripts/clipboard.js" +FIXTURE_SCRIPT="$ROOT_DIR/tests/clipboard_fixture.js" +TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/alfred-remote-upload-clipboard.XXXXXX")" +SNAPSHOT="$TEST_TMP/pasteboard.json" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +restore_clipboard() { + local status=$? + trap - EXIT + if [[ -f "$SNAPSHOT" ]]; then + if ! /usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" restore "$SNAPSHOT" >/dev/null 2>&1; then + echo "FAIL: unable to restore clipboard snapshot" >&2 + status=1 + fi + fi + rm -rf "$TEST_TMP" + exit "$status" +} +trap restore_clipboard EXIT + +read_clipboard() { + /usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" read "$TEST_TMP/output-$1" +} + +assert_result() { + local result="$1" + local expected_kind="$2" + local expected_extension="$3" + IFS=$'\x1f' read -r result_kind result_path result_extension result_temporary <<< "$result" + [[ "$result_kind" == "$expected_kind" ]] || fail "expected kind $expected_kind, got $result_kind" + [[ "$result_extension" == "$expected_extension" ]] || fail "expected extension $expected_extension, got $result_extension" + [[ -f "$result_path" ]] || fail "clipboard output file does not exist: $result_path" +} + +/usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" snapshot "$SNAPSHOT" +chmod 0600 "$SNAPSHOT" + +png_path="$TEST_TMP/source.png" +printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' | /usr/bin/base64 -D > "$png_path" + +/usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" set-png "$png_path" >/dev/null +png_result="$(read_clipboard png)" +assert_result "$png_result" image png +IFS=$'\x1f' read -r _ preserved_path _ _ <<< "$png_result" +/usr/bin/cmp "$png_path" "$preserved_path" || fail "PNG clipboard bytes should be preserved" + +/usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" set-tiff "$png_path" >/dev/null +tiff_result="$(read_clipboard tiff)" +assert_result "$tiff_result" image png +IFS=$'\x1f' read -r _ converted_path _ _ <<< "$tiff_result" +[[ "$(/usr/bin/xxd -p -l 8 "$converted_path")" == "89504e470d0a1a0a" ]] || fail "TIFF clipboard should be converted to PNG" + +finder_file="$TEST_TMP/Finder 文件.txt" +printf 'finder file' > "$finder_file" +files_json="$(/usr/bin/python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' "$finder_file")" +/usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" set-files "$files_json" >/dev/null +file_result="$(read_clipboard file)" +IFS=$'\x1f' read -r file_kind file_path file_extension file_temporary <<< "$file_result" +[[ "$file_kind" == "file" && "$file_path" == "$finder_file" ]] || fail "single Finder file should be returned unchanged" +[[ -z "$file_extension" && "$file_temporary" == "0" ]] || fail "Finder file metadata should mark a non-temporary file" + +second_file="$TEST_TMP/second.txt" +printf 'second' > "$second_file" +files_json="$(/usr/bin/python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' "$finder_file" "$second_file")" +/usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" set-files "$files_json" >/dev/null +if read_clipboard multiple >/dev/null 2>&1; then + fail "multiple Finder files should be rejected" +fi + +files_json="$(/usr/bin/python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' "$TEST_TMP")" +/usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" set-files "$files_json" >/dev/null +if read_clipboard directory >/dev/null 2>&1; then + fail "Finder directory should be rejected" +fi + +/usr/bin/osascript -l JavaScript "$FIXTURE_SCRIPT" set-text "plain text" >/dev/null +if read_clipboard text >/dev/null 2>&1; then + fail "plain text clipboard should be rejected" +fi + +echo "PASS: Alfred remote upload clipboard integration" diff --git a/plugins/alfred_remote_upload/tests/fakes/clipboard b/plugins/alfred_remote_upload/tests/fakes/clipboard new file mode 100755 index 0000000..3cc91cf --- /dev/null +++ b/plugins/alfred_remote_upload/tests/fakes/clipboard @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "${FAKE_CLIPBOARD_FAIL:-0}" == "1" ]]; then + echo "剪贴板中没有可上传的单个文件或图片" >&2 + exit 1 +fi + +printf '%s\037%s\037%s\037%s\n' \ + "${FAKE_SOURCE_KIND:?}" \ + "${FAKE_SOURCE_PATH:?}" \ + "${FAKE_SOURCE_EXTENSION:-}" \ + "${FAKE_SOURCE_TEMPORARY:-0}" diff --git a/plugins/alfred_remote_upload/tests/fakes/notify b/plugins/alfred_remote_upload/tests/fakes/notify new file mode 100755 index 0000000..5cec8ab --- /dev/null +++ b/plugins/alfred_remote_upload/tests/fakes/notify @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euo pipefail + +printf '%s\t%s\n' "$1" "$2" >> "${FAKE_NOTIFICATION_OUTPUT:?}" diff --git a/plugins/alfred_remote_upload/tests/fakes/pbcopy b/plugins/alfred_remote_upload/tests/fakes/pbcopy new file mode 100755 index 0000000..fd46755 --- /dev/null +++ b/plugins/alfred_remote_upload/tests/fakes/pbcopy @@ -0,0 +1,9 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "${FAKE_PBCOPY_FAIL:-0}" == "1" ]]; then + exit 73 +fi + +/bin/cat > "${FAKE_CLIPBOARD_OUTPUT:?}" diff --git a/plugins/alfred_remote_upload/tests/fakes/scp b/plugins/alfred_remote_upload/tests/fakes/scp new file mode 100755 index 0000000..ecc010f --- /dev/null +++ b/plugins/alfred_remote_upload/tests/fakes/scp @@ -0,0 +1,26 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "${FAKE_SCP_FAIL:-0}" == "1" ]]; then + exit 72 +fi + +previous="" +current="" +sftp_mode=false +for argument in "$@"; do + if [[ "$argument" == "-s" ]]; then + sftp_mode=true + fi + previous="$current" + current="$argument" +done + +if [[ "${FAKE_REQUIRE_SFTP:-0}" == "1" && "$sftp_mode" != "true" ]]; then + exit 74 +fi + +source_path="$previous" +remote_path="${current#*:}" +/bin/cp "$source_path" "$remote_path" diff --git a/plugins/alfred_remote_upload/tests/fakes/ssh b/plugins/alfred_remote_upload/tests/fakes/ssh new file mode 100755 index 0000000..94fd3b7 --- /dev/null +++ b/plugins/alfred_remote_upload/tests/fakes/ssh @@ -0,0 +1,27 @@ +#!/bin/bash + +set -euo pipefail + +if [[ "${FAKE_SSH_FAIL:-0}" == "1" ]]; then + exit 70 +fi + +script_file="$(mktemp "${TMPDIR:-/tmp}/alfred-remote-upload-fake-ssh.XXXXXX")" +trap 'rm -f "$script_file"' EXIT +/bin/cat > "$script_file" + +if [[ "${FAKE_SSH_FAIL_COMMIT:-0}" == "1" ]] && /usr/bin/grep -q "^action='commit'$" "$script_file"; then + exit 71 +fi + +if [[ "${FAKE_RESERVE_CHMOD_FAIL:-0}" == "1" ]] && /usr/bin/grep -q "^action='reserve'$" "$script_file"; then + /usr/bin/sed 's/chmod 0600 /false /' "$script_file" > "$script_file.failed-chmod" + /bin/mv "$script_file.failed-chmod" "$script_file" +fi + +if [[ -n "${FAKE_TAMPER_RESERVE_PATH:-}" ]] && /usr/bin/grep -q "^action='reserve'$" "$script_file"; then + printf 'ARU1\037%s\037\n' "$FAKE_TAMPER_RESERVE_PATH" + exit 0 +fi + +/bin/sh "$script_file" diff --git a/plugins/alfred_remote_upload/tests/run.sh b/plugins/alfred_remote_upload/tests/run.sh new file mode 100755 index 0000000..d6b979a --- /dev/null +++ b/plugins/alfred_remote_upload/tests/run.sh @@ -0,0 +1,421 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +HOSTS_SCRIPT="$ROOT_DIR/workflow/scripts/hosts.js" +CLIPBOARD_SCRIPT="$ROOT_DIR/workflow/scripts/clipboard.js" +UPLOAD_SCRIPT="$ROOT_DIR/workflow/scripts/upload.sh" +INFO_PLIST="$ROOT_DIR/workflow/info.plist" +BUILD_SCRIPT="$ROOT_DIR/build.sh" +FAKES_DIR="$ROOT_DIR/tests/fakes" +TEST_TMP="$(mktemp -d "${TMPDIR:-/tmp}/alfred-remote-upload-tests.XXXXXX")" +DATA_DIR="$TEST_TMP/data" + +cleanup() { + rm -rf "$TEST_TMP" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local message="$3" + if [[ "$expected" != "$actual" ]]; then + fail "$message: expected '$expected', got '$actual'" + fi +} + +json_get() { + local json="$1" + local path="$2" + printf '%s' "$json" | /usr/bin/plutil -extract "$path" raw -o - - +} + +json_item_count() { + /usr/bin/python3 -c 'import json,sys; print(len(json.load(sys.stdin)["items"]))' +} + +run_list() { + local config="$1" + local max_size="$2" + local query="${3:-}" + env \ + hosts_json="$config" \ + max_size_mb="$max_size" \ + alfred_workflow_data="$DATA_DIR" \ + /usr/bin/osascript -l JavaScript "$HOSTS_SCRIPT" list "$query" +} + +mark_used() { + local host="$1" + local remote_dir="$2" + env \ + alfred_workflow_data="$DATA_DIR" \ + /usr/bin/osascript -l JavaScript "$HOSTS_SCRIPT" mark-used "$host" "$remote_dir" +} + +test_valid_config_and_filter() { + local config output + config='[{"name":"开发机","host":"devbox","remote_dir":"/home/user/tmp_share"},{"name":"生产机","host":"prod","remote_dir":"/srv/share"}]' + output="$(run_list "$config" 100)" + + assert_eq "2" "$(printf '%s' "$output" | json_item_count)" "valid config should return two hosts" + assert_eq "开发机" "$(json_get "$output" 'items.0.title')" "config order should be preserved" + assert_eq "devbox" "$(json_get "$output" 'items.0.variables.upload_host')" "host variable should be passed" + assert_eq "/home/user/tmp_share" "$(json_get "$output" 'items.0.variables.upload_remote_dir')" "remote directory should be passed" + + output="$(run_list "$config" 100 'prod')" + assert_eq "生产机" "$(json_get "$output" 'items.0.title')" "query should filter by SSH host" +} + +test_mru_sort_and_corrupt_state_fallback() { + local config changed_config output + config='[{"name":"开发机","host":"devbox","remote_dir":"/home/user/tmp_share"},{"name":"生产机","host":"prod","remote_dir":"/srv/share"}]' + + mark_used "prod" "/srv/share" + output="$(run_list "$config" 100)" + assert_eq "生产机" "$(json_get "$output" 'items.0.title')" "most recently used host should be first" + + changed_config='[{"name":"开发机","host":"devbox","remote_dir":"/home/user/tmp_share"},{"name":"测试机","host":"test","remote_dir":"/tmp/share"}]' + output="$(run_list "$changed_config" 100)" + assert_eq "开发机" "$(json_get "$output" 'items.0.title')" "removed MRU hosts should not change configured order" + + printf '{broken' > "$DATA_DIR/recent_hosts.json" + output="$(run_list "$config" 100)" + assert_eq "开发机" "$(json_get "$output" 'items.0.title')" "corrupt state should fall back to config order" +} + +assert_config_error() { + local config="$1" + local max_size="$2" + local expected_message="$3" + local output + output="$(run_list "$config" "$max_size")" + assert_eq "false" "$(json_get "$output" 'items.0.valid')" "invalid config result must not be actionable" + [[ "$(json_get "$output" 'items.0.subtitle')" == *"$expected_message"* ]] || fail "expected config error containing '$expected_message'" +} + +test_config_validation() { + local output + output="$(run_list '[{"name":"A","host":"devbox","remote_dir":"/a"}]' '')" + assert_eq "A" "$(json_get "$output" 'items.0.title')" "empty max_size_mb should use the default" + + output="$(run_list '[{"name":"toString","host":"devbox","remote_dir":"/a"}]' 100)" + assert_eq "toString" "$(json_get "$output" 'items.0.title')" "valid names must not collide with Object prototype keys" + + assert_config_error '{bad json' 100 "JSON" + assert_config_error '[]' 100 "至少配置一台主机" + assert_config_error '[{"name":"开发机","host":"devbox"}]' 100 "remote_dir" + assert_config_error '[{"name":"A","host":"devbox","remote_dir":"/a"},{"name":"A","host":"prod","remote_dir":"/b"}]' 100 "name 不能重复" + assert_config_error '[{"name":"A","host":"-bad","remote_dir":"/a"}]' 100 "SSH Host" + assert_config_error '[{"name":"A","host":"devbox","remote_dir":"relative"}]' 100 "绝对路径" + assert_config_error '[{"name":"A","host":"devbox","remote_dir":"/a\tb"}]' 100 "控制字符" + assert_config_error '[{"name":"A","host":"devbox","remote_dir":"/a"}]' 0 "max_size_mb" + assert_config_error '[{"name":"A","host":"devbox","remote_dir":"/a"}]' abc "max_size_mb" + assert_config_error '[{"name":"A","host":"devbox","remote_dir":"/a"}]' 999999999999999999 "max_size_mb" +} + +test_clipboard_type_policy() { + local output + output="$(/usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" classify-types '["public.png","public.tiff"]')" + assert_eq "png" "$(json_get "$output" 'extension')" "PNG should be preserved" + assert_eq "true" "$(json_get "$output" 'preserve')" "PNG should not be converted" + + output="$(/usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" classify-types '["public.jpeg"]')" + assert_eq "jpg" "$(json_get "$output" 'extension')" "JPEG should use jpg extension" + + output="$(/usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" classify-types '["com.compuserve.gif"]')" + assert_eq "gif" "$(json_get "$output" 'extension')" "GIF should be preserved" + + output="$(/usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" classify-types '["org.webmproject.webp"]')" + assert_eq "webp" "$(json_get "$output" 'extension')" "WebP should be preserved" + + output="$(/usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" classify-types '["public.tiff"]')" + assert_eq "png" "$(json_get "$output" 'extension')" "TIFF should fall back to PNG" + assert_eq "false" "$(json_get "$output" 'preserve')" "TIFF should be converted" +} + +file_paths_json() { + /usr/bin/python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' "$@" +} + +test_clipboard_file_validation() { + local regular_file="$TEST_TMP/no-extension" + local directory="$TEST_TMP/clipboard-directory" + local fifo="$TEST_TMP/clipboard-fifo" + local output + printf 'file' > "$regular_file" + mkdir -p "$directory" + /usr/bin/mkfifo "$fifo" + + output="$(/usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" validate-file-paths "$(file_paths_json "$regular_file")")" + assert_eq "file" "${output%%$'\x1f'*}" "single regular file should be accepted" + [[ "$output" == *"$regular_file"* ]] || fail "regular file result should contain its path" + + if /usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" validate-file-paths "$(file_paths_json "$directory")" >/dev/null 2>&1; then + fail "directories should be rejected" + fi + if /usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" validate-file-paths "$(file_paths_json "$regular_file" "$directory")" >/dev/null 2>&1; then + fail "multiple Finder items should be rejected" + fi + if /usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" validate-file-paths "$(file_paths_json "$fifo")" >/dev/null 2>&1; then + fail "special files should be rejected" + fi + if /usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" validate-file-paths "$(file_paths_json "$TEST_TMP/missing")" >/dev/null 2>&1; then + fail "missing Finder files should be rejected" + fi +} + +run_upload() { + local kind="$1" + local source_path="$2" + local extension="$3" + local data_dir="$4" + local remote_dir="$5" + local max_size="$6" + + env \ + upload_name="devbox" \ + upload_host="devbox" \ + upload_remote_dir="$remote_dir" \ + max_size_mb="$max_size" \ + alfred_workflow_data="$data_dir" \ + alfred_workflow_cache="$TEST_TMP/cache" \ + ALFRED_REMOTE_UPLOAD_DATE="${ALFRED_REMOTE_UPLOAD_DATE:-20260714}" \ + CLIPBOARD_HELPER="$FAKES_DIR/clipboard" \ + SSH_BIN="$FAKES_DIR/ssh" \ + SCP_BIN="$FAKES_DIR/scp" \ + PBCOPY_BIN="$FAKES_DIR/pbcopy" \ + NOTIFY_BIN="$FAKES_DIR/notify" \ + HOSTS_SCRIPT="$HOSTS_SCRIPT" \ + FAKE_SOURCE_KIND="$kind" \ + FAKE_SOURCE_PATH="$source_path" \ + FAKE_SOURCE_EXTENSION="$extension" \ + FAKE_SOURCE_TEMPORARY="0" \ + FAKE_CLIPBOARD_OUTPUT="$TEST_TMP/clipboard.txt" \ + FAKE_NOTIFICATION_OUTPUT="$TEST_TMP/notifications.txt" \ + "$UPLOAD_SCRIPT" +} + +test_image_sequence_and_permissions() { + local source_png="$TEST_TMP/source.png" + local source_jpg="$TEST_TMP/source.jpg" + local remote_dir="$TEST_TMP/remote-images" + local data_dir="$TEST_TMP/data-images" + local output + printf 'png-data' > "$source_png" + printf 'jpg-data' > "$source_jpg" + + output="$(run_upload image "$source_png" png "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714_1.png" "$output" "first screenshot should use sequence 1" + /usr/bin/cmp "$source_png" "$remote_dir/20260714_1.png" || fail "uploaded PNG content should match" + assert_eq "644" "$(/usr/bin/stat -f '%Lp' "$remote_dir/20260714_1.png")" "uploaded screenshot should be mode 0644" + assert_eq "$output" "$(<"$TEST_TMP/clipboard.txt")" "success should copy remote path" + + output="$(run_upload image "$source_jpg" jpg "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714_2.jpg" "$output" "screenshot sequence should span extensions" + [[ ! -e "$remote_dir/.alfred_remote_upload_20260714_1.reserve" ]] || fail "reservation should be removed after success" + assert_eq "devbox" "$(/usr/bin/plutil -extract recent.0.host raw -o - "$data_dir/recent_hosts.json")" "success should update MRU" +} + +test_file_name_collisions() { + local source_dir="$TEST_TMP/source-files" + local source_path="$source_dir/报告's file.txt" + local hidden_path="$source_dir/.env" + local extensionless_path="$source_dir/README" + local shell_meta_path="$source_dir/literal \$(touch injected) \`echo bad\`.txt" + local remote_dir="$TEST_TMP/remote-files" + local data_dir="$TEST_TMP/data-files" + local output + mkdir -p "$source_dir" + printf 'report' > "$source_path" + printf 'secret' > "$hidden_path" + printf 'readme' > "$extensionless_path" + printf 'literal shell characters' > "$shell_meta_path" + + output="$(run_upload file "$source_path" '' "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714/报告's file.txt" "$output" "ordinary file should preserve its name" + output="$(run_upload file "$source_path" '' "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714/报告's file_1.txt" "$output" "ordinary file collision should add suffix before extension" + + output="$(run_upload file "$hidden_path" '' "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714/.env" "$output" "hidden file should preserve its name" + output="$(run_upload file "$hidden_path" '' "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714/.env_1" "$output" "hidden file collision should append suffix" + + output="$(run_upload file "$extensionless_path" '' "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714/README" "$output" "extensionless file should preserve its name" + output="$(run_upload file "$extensionless_path" '' "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714/README_1" "$output" "extensionless collision should append suffix" + + output="$(FAKE_REQUIRE_SFTP=1 run_upload file "$shell_meta_path" '' "$data_dir" "$remote_dir" 100)" + assert_eq "$remote_dir/20260714/$(basename "$shell_meta_path")" "$output" "SFTP should treat shell metacharacters as literal path characters" + [[ ! -e "$source_dir/injected" ]] || fail "remote filename must not execute local shell syntax" +} + +test_concurrent_image_reservation() { + local source_a="$TEST_TMP/concurrent-a.png" + local source_b="$TEST_TMP/concurrent-b.png" + local remote_dir="$TEST_TMP/remote-concurrent" + local output_a="$TEST_TMP/concurrent-a.out" + local output_b="$TEST_TMP/concurrent-b.out" + local pid_a pid_b paths + printf 'image-a' > "$source_a" + printf 'image-b' > "$source_b" + + run_upload image "$source_a" png "$TEST_TMP/data-concurrent-a" "$remote_dir" 100 > "$output_a" & + pid_a=$! + run_upload image "$source_b" png "$TEST_TMP/data-concurrent-b" "$remote_dir" 100 > "$output_b" & + pid_b=$! + wait "$pid_a" + wait "$pid_b" + + paths="$(printf '%s\n%s\n' "$(<"$output_a")" "$(<"$output_b")" | /usr/bin/sort)" + assert_eq "$remote_dir/20260714_1.png +$remote_dir/20260714_2.png" "$paths" "concurrent uploads should reserve different paths" + assert_eq "2" "$(find "$remote_dir" -type f -name '20260714_*.png' | wc -l | tr -d ' ')" "both concurrent uploads should remain" +} + +test_size_and_failure_cleanup() { + local source_path="$TEST_TMP/large.bin" + local remote_dir="$TEST_TMP/remote-failures" + local data_dir="$TEST_TMP/data-failures" + local boundary_path="$TEST_TMP/boundary.bin" + local boundary_remote="$TEST_TMP/remote-boundary" + local outside_sentinel="$TEST_TMP/outside-sentinel" + local overflow_error + /bin/dd if=/dev/zero of="$boundary_path" bs=1048576 count=1 2>/dev/null + /bin/dd if=/dev/zero of="$source_path" bs=1048576 count=2 2>/dev/null + printf 'original' > "$TEST_TMP/clipboard.txt" + + run_upload file "$boundary_path" '' "$TEST_TMP/data-boundary" "$boundary_remote" 1 >/dev/null + [[ -f "$boundary_remote/20260714/boundary.bin" ]] || fail "file exactly at max_size_mb should succeed" + printf 'original' > "$TEST_TMP/clipboard.txt" + + if overflow_error="$(run_upload file "$boundary_path" '' "$data_dir" "$remote_dir" 999999999999999999 2>&1)"; then + fail "overflowing max_size_mb should be rejected" + fi + [[ "$overflow_error" == *"max_size_mb"* ]] || fail "overflowing max_size_mb should report a configuration error" + + if run_upload file "$source_path" '' "$data_dir" "$remote_dir"$'\tbad' 100 >/dev/null 2>&1; then + fail "upload should reject control characters in remote_dir" + fi + + if run_upload file "$source_path" '' "$data_dir" "$remote_dir" 1 >/dev/null 2>&1; then + fail "oversized file should fail" + fi + assert_eq "original" "$(<"$TEST_TMP/clipboard.txt")" "size failure should preserve clipboard" + [[ ! -d "$remote_dir" ]] || fail "size failure should not create remote directory" + + printf 'small' > "$source_path" + if ALFRED_REMOTE_UPLOAD_DATE='../escape' run_upload image "$source_path" png "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "invalid upload date override should be rejected" + fi + [[ ! -d "$remote_dir" ]] || fail "invalid upload date should not create remote files" + + printf 'sentinel' > "$outside_sentinel" + if FAKE_TAMPER_RESERVE_PATH="$outside_sentinel" run_upload image "$source_path" png "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "reserve path outside remote_dir should fail the upload" + fi + assert_eq "sentinel" "$(<"$outside_sentinel")" "invalid reserve response must not overwrite files outside remote_dir" + + if FAKE_SSH_FAIL=1 run_upload image "$source_path" png "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "SSH reserve failure should fail the upload" + fi + [[ ! -d "$remote_dir" ]] || fail "SSH reserve failure should not create remote files" + + if FAKE_RESERVE_CHMOD_FAIL=1 run_upload image "$source_path" png "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "reserve chmod failure should fail the upload" + fi + assert_eq "0" "$(find "$remote_dir" -type f 2>/dev/null | wc -l | tr -d ' ')" "reserve chmod failure should clean placeholders and reservations" + if FAKE_RESERVE_CHMOD_FAIL=1 run_upload file "$source_path" '' "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "ordinary file reserve chmod failure should fail the upload" + fi + assert_eq "0" "$(find "$remote_dir" -type f 2>/dev/null | wc -l | tr -d ' ')" "ordinary reserve chmod failure should clean its placeholder" + + if FAKE_SCP_FAIL=1 run_upload image "$source_path" png "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "SCP failure should fail the upload" + fi + assert_eq "original" "$(<"$TEST_TMP/clipboard.txt")" "SCP failure should preserve clipboard" + assert_eq "0" "$(find "$remote_dir" -type f 2>/dev/null | wc -l | tr -d ' ')" "SCP failure should clean placeholders" + + if FAKE_SSH_FAIL_COMMIT=1 run_upload image "$source_path" png "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "chmod failure should fail the upload" + fi + assert_eq "0" "$(find "$remote_dir" -type f 2>/dev/null | wc -l | tr -d ' ')" "chmod failure should clean partial files" +} + +test_pbcopy_failure_keeps_remote_file_without_mru() { + local source_path="$TEST_TMP/pbcopy.png" + local remote_dir="$TEST_TMP/remote-pbcopy" + local data_dir="$TEST_TMP/data-pbcopy" + printf 'small' > "$source_path" + printf 'original' > "$TEST_TMP/clipboard.txt" + + if FAKE_PBCOPY_FAIL=1 run_upload image "$source_path" png "$data_dir" "$remote_dir" 100 >/dev/null 2>&1; then + fail "pbcopy failure should fail the workflow" + fi + [[ -f "$remote_dir/20260714_1.png" ]] || fail "pbcopy failure should keep completed remote upload" + assert_eq "original" "$(<"$TEST_TMP/clipboard.txt")" "pbcopy failure should preserve clipboard" + [[ ! -e "$data_dir/recent_hosts.json" ]] || fail "pbcopy failure should not update MRU" +} + +test_workflow_manifest_and_package() { + local artifact="$TEST_TMP/Remote Upload.alfredworkflow" + [[ -f "$INFO_PLIST" ]] || fail "workflow/info.plist is missing" + /usr/bin/plutil -lint "$INFO_PLIST" >/dev/null || fail "workflow/info.plist is invalid" + /usr/bin/python3 - "$INFO_PLIST" <<'PY' +import plistlib +import sys + +with open(sys.argv[1], "rb") as source: + workflow = plistlib.load(source) + +assert workflow["name"] == "Remote Upload" +assert workflow["bundleid"] == "com.mcoder2014.life-tools.alfred-remote-upload" +configuration = {item["variable"]: item for item in workflow["userconfigurationconfig"]} +assert configuration["hosts_json"]["config"]["required"] is True +assert configuration["max_size_mb"]["config"]["default"] == "100" + +objects = {item["uid"]: item for item in workflow["objects"]} +filters = [item for item in objects.values() if item["type"] == "alfred.workflow.input.scriptfilter"] +assert len(filters) == 1 +assert filters[0]["config"]["keyword"] == "up" +assert filters[0]["config"]["type"] == 8 +assert filters[0]["config"]["scriptfile"] == "scripts/list_hosts.sh" +connections = workflow["connections"][filters[0]["uid"]] +assert len(connections) == 1 +action = objects[connections[0]["destinationuid"]] +assert action["type"] == "alfred.workflow.action.script" +assert action["config"]["type"] == 8 +assert action["config"]["scriptfile"] == "scripts/upload.sh" +PY + + [[ -x "$BUILD_SCRIPT" ]] || fail "build.sh is missing or not executable" + "$BUILD_SCRIPT" "$artifact" >/dev/null + /usr/bin/unzip -t "$artifact" >/dev/null || fail "workflow package is invalid" + /usr/bin/unzip -Z1 "$artifact" | /usr/bin/grep -qx 'info.plist' || fail "package must contain info.plist at root" + if /usr/bin/unzip -Z1 "$artifact" | /usr/bin/grep -Eq '(^|/)(prefs\.plist|recent_hosts\.json)$'; then + fail "package must not contain user configuration or MRU state" + fi +} + +test_valid_config_and_filter +test_mru_sort_and_corrupt_state_fallback +test_config_validation +test_clipboard_type_policy +test_clipboard_file_validation +test_image_sequence_and_permissions +test_file_name_collisions +test_concurrent_image_reservation +test_size_and_failure_cleanup +test_pbcopy_failure_keeps_remote_file_without_mru +test_workflow_manifest_and_package + +echo "PASS: Alfred remote upload tests" diff --git a/plugins/alfred_remote_upload/workflow/info.plist b/plugins/alfred_remote_upload/workflow/info.plist new file mode 100644 index 0000000..265a773 --- /dev/null +++ b/plugins/alfred_remote_upload/workflow/info.plist @@ -0,0 +1,181 @@ + + + + + bundleid + com.mcoder2014.life-tools.alfred-remote-upload + category + Productivity + connections + + 119C7A3B-E9D5-4CE4-9070-FA41449775A8 + + + destinationuid + D621CA69-B775-47E2-955A-E9EDB9C6A16E + modifiers + 0 + modifiersubtext + + vitoclose + + + + + createdby + mcoder2014 + description + 将剪贴板中的图片或 Finder 单个文件上传到 SSH 主机,并把远端绝对路径写回剪贴板。 + disabled + + name + Remote Upload + objects + + + config + + alfredfiltersresults + + alfredfiltersresultsmatchmode + 0 + argumenttreatemptyqueryasnil + + argumenttrimmode + 0 + argumenttype + 1 + escaping + 0 + keyword + up + queuedelaycustom + 3 + queuedelayimmediatelyinitially + + queuedelaymode + 0 + queuemode + 1 + runningsubtext + 正在读取主机配置… + script + + scriptargtype + 1 + scriptfile + scripts/list_hosts.sh + subtext + 选择目标主机并上传剪贴板内容 + title + Remote Upload + type + 8 + withspace + + + type + alfred.workflow.input.scriptfilter + uid + 119C7A3B-E9D5-4CE4-9070-FA41449775A8 + version + 3 + + + config + + concurrently + + escaping + 0 + script + + scriptargtype + 1 + scriptfile + scripts/upload.sh + type + 8 + + type + alfred.workflow.action.script + uid + D621CA69-B775-47E2-955A-E9EDB9C6A16E + version + 2 + + + readme + 复制 Finder 中的单个普通文件,或复制/截取一张图片,然后输入 `up` 选择目标主机。上传成功后,剪贴板会被替换为远端绝对路径。 + uidata + + 119C7A3B-E9D5-4CE4-9070-FA41449775A8 + + xpos + 120 + ypos + 120 + + D621CA69-B775-47E2-955A-E9EDB9C6A16E + + xpos + 420 + ypos + 120 + + + userconfigurationconfig + + + config + + default + [] + placeholder + [{"name":"devbox","host":"devbox","remote_dir":"/home/user/tmp_share"}] + required + + trim + + + description + JSON 数组;name 唯一,host 使用 ~/.ssh/config 中的安全别名,remote_dir 使用绝对路径。 + label + 远端主机列表 + type + textfield + variable + hosts_json + + + config + + default + 100 + placeholder + 100 + required + + trim + + + description + 普通文件和剪贴板图片共用的大小上限,单位 MiB,必须为正整数。 + label + 最大文件大小(MiB) + type + textfield + variable + max_size_mb + + + variablesdontexport + + hosts_json + + version + 1.0.0 + webaddress + https://github.com/mcoder2014/life_tools + + diff --git a/plugins/alfred_remote_upload/workflow/scripts/clipboard.js b/plugins/alfred_remote_upload/workflow/scripts/clipboard.js new file mode 100644 index 0000000..236aab2 --- /dev/null +++ b/plugins/alfred_remote_upload/workflow/scripts/clipboard.js @@ -0,0 +1,155 @@ +ObjC.import("AppKit"); +ObjC.import("Foundation"); + +const PRESERVED_TYPES = [ + { types: ["public.png"], extension: "png" }, + { types: ["public.jpeg", "public.jpg"], extension: "jpg" }, + { types: ["com.compuserve.gif", "public.gif"], extension: "gif" }, + { types: ["org.webmproject.webp", "public.webp"], extension: "webp" } +]; + +function imagePolicy(types) { + for (let index = 0; index < PRESERVED_TYPES.length; index += 1) { + const candidate = PRESERVED_TYPES[index]; + for (let typeIndex = 0; typeIndex < candidate.types.length; typeIndex += 1) { + if (types.indexOf(candidate.types[typeIndex]) !== -1) { + return { + type: candidate.types[typeIndex], + extension: candidate.extension, + preserve: true + }; + } + } + } + + return { type: "", extension: "png", preserve: false }; +} + +function clipboardTypes(pasteboard) { + const result = []; + const types = pasteboard.types; + for (let index = 0; index < Number(types.count); index += 1) { + result.push(ObjC.unwrap(types.objectAtIndex(index))); + } + return result; +} + +function finderFilePaths(pasteboard) { + const classes = $.NSArray.arrayWithObject($.NSURL); + const options = $.NSDictionary.dictionaryWithObjectForKey( + $.NSNumber.numberWithBool(true), + $.NSPasteboardURLReadingFileURLsOnlyKey + ); + const urls = pasteboard.readObjectsForClassesOptions(classes, options); + const paths = []; + if (!urls) { + return paths; + } + for (let index = 0; index < Number(urls.count); index += 1) { + const url = urls.objectAtIndex(index); + paths.push(ObjC.unwrap(url.path)); + } + return paths; +} + +function assertRegularFile(path) { + if (/[\x00-\x1F]/.test(path)) { + throw new Error("暂不支持文件名中包含控制字符的文件"); + } + + const attributes = $.NSFileManager.defaultManager.attributesOfItemAtPathError($(path), $()); + if (!attributes) { + throw new Error("Finder 文件不存在或无法读取:" + path); + } + const fileType = ObjC.unwrap(attributes.objectForKey($.NSFileType)); + if (fileType !== ObjC.unwrap($.NSFileTypeRegular)) { + throw new Error("只支持单个普通文件,不支持目录或特殊文件"); + } +} + +function finderFileResult(filePaths) { + if (filePaths.length > 1) { + throw new Error("一次只能上传一个 Finder 文件"); + } + if (filePaths.length === 0) { + return ""; + } + assertRegularFile(filePaths[0]); + return "file\u001f" + filePaths[0] + "\u001f\u001f0"; +} + +function writePreservedImage(pasteboard, policy, outputBase) { + const data = pasteboard.dataForType($(policy.type)); + if (!data) { + throw new Error("无法读取剪贴板图片数据"); + } + const outputPath = outputBase + "." + policy.extension; + if (!data.writeToFileAtomically($(outputPath), true)) { + throw new Error("无法写入临时图片"); + } + return outputPath; +} + +function writeConvertedPng(pasteboard, outputBase) { + const image = $.NSImage.alloc.initWithPasteboard(pasteboard); + if (!image) { + throw new Error("剪贴板中没有可上传的单个文件或图片"); + } + const tiffData = image.TIFFRepresentation; + const bitmap = tiffData ? $.NSBitmapImageRep.imageRepWithData(tiffData) : null; + const pngData = bitmap + ? bitmap.representationUsingTypeProperties( + $.NSBitmapImageFileTypePNG, + $.NSDictionary.dictionary + ) + : null; + if (!pngData) { + throw new Error("无法把剪贴板图片转换为 PNG"); + } + const outputPath = outputBase + ".png"; + if (!pngData.writeToFileAtomically($(outputPath), true)) { + throw new Error("无法写入临时 PNG 图片"); + } + return outputPath; +} + +function readClipboard(outputBase) { + if (!outputBase || /[\r\n\t\0]/.test(outputBase)) { + throw new Error("临时图片路径无效"); + } + + const pasteboard = $.NSPasteboard.generalPasteboard; + const filePaths = finderFilePaths(pasteboard); + const fileResult = finderFileResult(filePaths); + if (fileResult) { + return fileResult; + } + + const policy = imagePolicy(clipboardTypes(pasteboard)); + const outputPath = policy.preserve + ? writePreservedImage(pasteboard, policy, outputBase) + : writeConvertedPng(pasteboard, outputBase); + return "image\u001f" + outputPath + "\u001f" + policy.extension + "\u001f1"; +} + +function run(argv) { + const command = argv.length > 0 ? argv[0] : "read"; + if (command === "classify-types") { + const types = JSON.parse(argv[1] || "[]"); + if (!Array.isArray(types)) { + throw new Error("types 必须是数组"); + } + return JSON.stringify(imagePolicy(types)); + } + if (command === "read") { + return readClipboard(argv[1]); + } + if (command === "validate-file-paths") { + const paths = JSON.parse(argv[1] || "[]"); + if (!Array.isArray(paths) || paths.length === 0) { + throw new Error("paths 必须是非空数组"); + } + return finderFileResult(paths); + } + throw new Error("unknown command: " + command); +} diff --git a/plugins/alfred_remote_upload/workflow/scripts/hosts.js b/plugins/alfred_remote_upload/workflow/scripts/hosts.js new file mode 100644 index 0000000..4016f0c --- /dev/null +++ b/plugins/alfred_remote_upload/workflow/scripts/hosts.js @@ -0,0 +1,234 @@ +ObjC.import("Foundation"); + +function environment(name) { + const value = $.NSProcessInfo.processInfo.environment.objectForKey(name); + return value ? ObjC.unwrap(value) : ""; +} + +function statePath() { + const dataDir = environment("alfred_workflow_data"); + return dataDir ? dataDir + "/recent_hosts.json" : ""; +} + +function readRecentHosts() { + const path = statePath(); + if (!path) { + return []; + } + + try { + const content = $.NSString.stringWithContentsOfFileEncodingError( + $(path), + $.NSUTF8StringEncoding, + $() + ); + if (!content) { + return []; + } + const parsed = JSON.parse(ObjC.unwrap(content)); + if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.recent)) { + return []; + } + return parsed.recent.filter(function (item) { + return item && typeof item.host === "string" && typeof item.remote_dir === "string"; + }); + } catch (_) { + return []; + } +} + +function writeRecentHosts(recent) { + const dataDir = environment("alfred_workflow_data"); + if (!dataDir) { + throw new Error("Alfred workflow data 目录不可用"); + } + + const fileManager = $.NSFileManager.defaultManager; + const created = fileManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError( + $(dataDir), + true, + $(), + $() + ); + if (!created) { + throw new Error("无法创建 Alfred workflow data 目录"); + } + + const content = $(JSON.stringify({ version: 1, recent: recent.slice(0, 50) })); + const written = content.writeToFileAtomicallyEncodingError( + $(statePath()), + true, + $.NSUTF8StringEncoding, + $() + ); + if (!written) { + throw new Error("无法写入最近使用主机状态"); + } +} + +function validateConfig() { + const rawConfig = environment("hosts_json"); + let hosts; + try { + hosts = JSON.parse(rawConfig); + } catch (_) { + throw new Error("hosts_json 不是合法 JSON"); + } + + if (!Array.isArray(hosts) || hosts.length === 0) { + throw new Error("hosts_json 至少配置一台主机"); + } + + const names = Object.create(null); + const targets = Object.create(null); + const normalized = hosts.map(function (item, index) { + if (!item || typeof item !== "object" || Array.isArray(item)) { + throw new Error("第 " + (index + 1) + " 项必须是对象"); + } + + ["name", "host", "remote_dir"].forEach(function (field) { + if (typeof item[field] !== "string" || item[field].trim() === "") { + throw new Error("第 " + (index + 1) + " 项缺少必填字段 " + field); + } + }); + + const name = item.name.trim(); + const host = item.host.trim(); + const remoteDir = item.remote_dir.trim(); + if (names[name]) { + throw new Error("name 不能重复:" + name); + } + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(host)) { + throw new Error("SSH Host 只能包含字母、数字、点、下划线和连字符"); + } + if (remoteDir.charAt(0) !== "/" || /[\x00-\x1f]/.test(remoteDir)) { + throw new Error("remote_dir 必须是无控制字符的绝对路径"); + } + + const targetKey = host + "\n" + remoteDir; + if (targets[targetKey]) { + throw new Error("host 与 remote_dir 组合不能重复:" + name); + } + names[name] = true; + targets[targetKey] = true; + return { name: name, host: host, remote_dir: remoteDir, config_index: index }; + }); + + const maxSize = environment("max_size_mb") || "100"; + const maxSafeMiB = "8796093022207"; + if ( + !/^[1-9][0-9]*$/.test(maxSize) || + maxSize.length > maxSafeMiB.length || + (maxSize.length === maxSafeMiB.length && maxSize > maxSafeMiB) + ) { + throw new Error("max_size_mb 必须是不会导致大小计算溢出的正整数"); + } + + return normalized; +} + +function sortByRecentUse(hosts) { + const recent = readRecentHosts(); + const positions = Object.create(null); + recent.forEach(function (item, index) { + positions[item.host + "\n" + item.remote_dir] = index; + }); + + return hosts.slice().sort(function (left, right) { + const leftKey = left.host + "\n" + left.remote_dir; + const rightKey = right.host + "\n" + right.remote_dir; + const leftRecent = Object.prototype.hasOwnProperty.call(positions, leftKey) + ? positions[leftKey] + : Number.MAX_SAFE_INTEGER; + const rightRecent = Object.prototype.hasOwnProperty.call(positions, rightKey) + ? positions[rightKey] + : Number.MAX_SAFE_INTEGER; + if (leftRecent !== rightRecent) { + return leftRecent - rightRecent; + } + return left.config_index - right.config_index; + }); +} + +function errorResult(message) { + return JSON.stringify({ + items: [ + { + title: "主机配置错误", + subtitle: message, + valid: false + } + ] + }); +} + +function listHosts(query) { + let hosts; + try { + hosts = sortByRecentUse(validateConfig()); + } catch (error) { + return errorResult(error.message || String(error)); + } + + const normalizedQuery = (query || "").trim().toLowerCase(); + if (normalizedQuery) { + hosts = hosts.filter(function (item) { + return ( + item.name.toLowerCase().indexOf(normalizedQuery) !== -1 || + item.host.toLowerCase().indexOf(normalizedQuery) !== -1 + ); + }); + } + + if (hosts.length === 0) { + return JSON.stringify({ + items: [ + { + title: "没有匹配的远端主机", + subtitle: "请修改搜索词或检查 hosts_json", + valid: false + } + ] + }); + } + + return JSON.stringify({ + items: hosts.map(function (item) { + return { + title: item.name, + subtitle: item.host + " → " + item.remote_dir, + arg: item.name, + match: item.name + " " + item.host, + valid: true, + variables: { + upload_name: item.name, + upload_host: item.host, + upload_remote_dir: item.remote_dir + } + }; + }) + }); +} + +function markUsed(host, remoteDir) { + if (!host || !remoteDir) { + throw new Error("mark-used 缺少 host 或 remote_dir"); + } + const recent = readRecentHosts().filter(function (item) { + return item.host !== host || item.remote_dir !== remoteDir; + }); + recent.unshift({ host: host, remote_dir: remoteDir }); + writeRecentHosts(recent); + return ""; +} + +function run(argv) { + const command = argv.length > 0 ? argv[0] : "list"; + if (command === "list") { + return listHosts(argv.length > 1 ? argv[1] : ""); + } + if (command === "mark-used") { + return markUsed(argv[1], argv[2]); + } + throw new Error("unknown command: " + command); +} diff --git a/plugins/alfred_remote_upload/workflow/scripts/list_hosts.sh b/plugins/alfred_remote_upload/workflow/scripts/list_hosts.sh new file mode 100755 index 0000000..766f35d --- /dev/null +++ b/plugins/alfred_remote_upload/workflow/scripts/list_hosts.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec /usr/bin/osascript -l JavaScript "$SCRIPT_DIR/hosts.js" list "${1:-}" diff --git a/plugins/alfred_remote_upload/workflow/scripts/remote_helper.sh b/plugins/alfred_remote_upload/workflow/scripts/remote_helper.sh new file mode 100755 index 0000000..20a3dd8 --- /dev/null +++ b/plugins/alfred_remote_upload/workflow/scripts/remote_helper.sh @@ -0,0 +1,107 @@ +#!/bin/sh + +set -eu + +case "${action:-}" in + reserve) + if [ "$remote_dir" = "/" ]; then + remote_base="" + else + remote_base="$remote_dir" + fi + if [ "$source_kind" = "image" ]; then + mkdir -p "$remote_dir" + sequence=1 + while [ "$sequence" -le 999999 ]; do + reservation_path="$remote_base/.alfred_remote_upload_${upload_date}_${sequence}.reserve" + if (set -C; : > "$reservation_path") 2>/dev/null; then + existing=false + for existing_path in "$remote_base/${upload_date}_${sequence}".*; do + if [ -e "$existing_path" ]; then + existing=true + break + fi + done + if [ "$existing" = true ]; then + rm -f "$reservation_path" + sequence=$((sequence + 1)) + continue + fi + + remote_path="$remote_base/${upload_date}_${sequence}.${source_extension}" + if (set -C; : > "$remote_path") 2>/dev/null; then + if ! chmod 0600 "$remote_path"; then + rm -f "$remote_path" "$reservation_path" || true + echo "无法设置截图占位文件权限" >&2 + exit 1 + fi + printf 'ARU1\037%s\037%s\n' "$remote_path" "$reservation_path" + exit 0 + fi + rm -f "$reservation_path" + fi + sequence=$((sequence + 1)) + done + echo "无法为当天截图分配文件名" >&2 + exit 1 + fi + + target_dir="$remote_base/$upload_date" + mkdir -p "$target_dir" + stem="$source_name" + extension="" + case "$source_name" in + .*) + case "${source_name#?}" in + *.*) + stem="${source_name%.*}" + extension=".${source_name##*.}" + ;; + esac + ;; + *.*) + stem="${source_name%.*}" + extension=".${source_name##*.}" + ;; + esac + + suffix=0 + while [ "$suffix" -le 999999 ]; do + if [ "$suffix" -eq 0 ]; then + candidate="$target_dir/$source_name" + else + candidate="$target_dir/${stem}_${suffix}${extension}" + fi + if (set -C; : > "$candidate") 2>/dev/null; then + if ! chmod 0600 "$candidate"; then + rm -f "$candidate" || true + echo "无法设置普通文件占位权限" >&2 + exit 1 + fi + printf 'ARU1\037%s\037\n' "$candidate" + exit 0 + fi + suffix=$((suffix + 1)) + done + echo "无法为普通文件分配文件名" >&2 + exit 1 + ;; + commit) + chmod 0644 "$remote_path" + if [ -n "${reservation_path:-}" ]; then + rm -f "$reservation_path" + fi + ;; + cleanup) + if [ -n "${remote_path:-}" ]; then + rm -f "$remote_path" + fi + if [ -n "${reservation_path:-}" ]; then + rm -f "$reservation_path" + fi + ;; + *) + echo "未知远端操作" >&2 + exit 1 + ;; +esac diff --git a/plugins/alfred_remote_upload/workflow/scripts/upload.sh b/plugins/alfred_remote_upload/workflow/scripts/upload.sh new file mode 100755 index 0000000..6e9f5ea --- /dev/null +++ b/plugins/alfred_remote_upload/workflow/scripts/upload.sh @@ -0,0 +1,244 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLIPBOARD_SCRIPT="$SCRIPT_DIR/clipboard.js" +REMOTE_HELPER_SCRIPT="$SCRIPT_DIR/remote_helper.sh" +HOSTS_SCRIPT="${HOSTS_SCRIPT:-$SCRIPT_DIR/hosts.js}" +SSH_BIN="${SSH_BIN:-/usr/bin/ssh}" +SCP_BIN="${SCP_BIN:-/usr/bin/scp}" +PBCOPY_BIN="${PBCOPY_BIN:-/usr/bin/pbcopy}" +NOTIFY_BIN="${NOTIFY_BIN:-}" +SSH_OPTIONS=(-o BatchMode=yes -o NumberOfPasswordPrompts=0 -o StrictHostKeyChecking=yes -o ConnectTimeout=10) +SCP_OPTIONS=(-s "${SSH_OPTIONS[@]}") + +temp_dir="" +remote_path="" +reservation_path="" +remote_pending=0 + +shell_quote() { + printf "'" + printf '%s' "$1" | /usr/bin/sed "s/'/'\"'\"'/g" + printf "'" +} + +notify() { + local title="$1" + local message="$2" + if [[ -n "$NOTIFY_BIN" ]]; then + "$NOTIFY_BIN" "$title" "$message" + return + fi + ARU_NOTIFY_TITLE="$title" ARU_NOTIFY_MESSAGE="$message" \ + /usr/bin/osascript -l JavaScript -e ' + ObjC.import("Foundation"); + const env = $.NSProcessInfo.processInfo.environment; + const app = Application.currentApplication(); + app.includeStandardAdditions = true; + app.displayNotification(ObjC.unwrap(env.objectForKey("ARU_NOTIFY_MESSAGE")), { + withTitle: ObjC.unwrap(env.objectForKey("ARU_NOTIFY_TITLE")) + }); + ' >/dev/null +} + +run_remote() { + local operation="$1" + { + printf 'action=%s\n' "$(shell_quote "$operation")" + printf 'source_kind=%s\n' "$(shell_quote "${source_kind:-}")" + printf 'source_name=%s\n' "$(shell_quote "${source_name:-}")" + printf 'source_extension=%s\n' "$(shell_quote "${source_extension:-}")" + printf 'remote_dir=%s\n' "$(shell_quote "${upload_remote_dir:-}")" + printf 'upload_date=%s\n' "$(shell_quote "${upload_date:-}")" + printf 'remote_path=%s\n' "$(shell_quote "${remote_path:-}")" + printf 'reservation_path=%s\n' "$(shell_quote "${reservation_path:-}")" + /bin/cat "$REMOTE_HELPER_SCRIPT" + } | "$SSH_BIN" "${SSH_OPTIONS[@]}" "$upload_host" /bin/sh +} + +cleanup() { + if [[ "$remote_pending" == "1" ]]; then + run_remote cleanup >/dev/null 2>&1 || true + fi + if [[ -n "$temp_dir" ]]; then + rm -rf "$temp_dir" + fi +} +trap cleanup EXIT + +fail_upload() { + local message="$1" + notify "Remote Upload 失败" "$message" >/dev/null 2>&1 || true + echo "$message" >&2 + exit 1 +} + +if [[ ! "${upload_host:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + fail_upload "SSH Host 配置无效" +fi +if [[ "${upload_remote_dir:-}" != /* ]] || [[ "$upload_remote_dir" =~ [[:cntrl:]] ]]; then + fail_upload "远端目录必须是无控制字符的绝对路径" +fi +mru_remote_dir="$upload_remote_dir" +upload_remote_dir="${upload_remote_dir%/}" +if [[ -z "$upload_remote_dir" ]]; then + upload_remote_dir="/" +fi + +max_size_mb="${max_size_mb:-100}" +max_safe_mib="8796093022207" +if [[ ! "$max_size_mb" =~ ^[1-9][0-9]*$ ]] || + (( ${#max_size_mb} > ${#max_safe_mib} )) || + { (( ${#max_size_mb} == ${#max_safe_mib} )) && [[ "$max_size_mb" > "$max_safe_mib" ]]; }; then + fail_upload "max_size_mb 必须是不会导致大小计算溢出的正整数" +fi + +cache_root="${alfred_workflow_cache:-${TMPDIR:-/tmp}}" +mkdir -p "$cache_root" +temp_dir="$(mktemp -d "$cache_root/upload.XXXXXX")" + +if [[ -n "${CLIPBOARD_HELPER:-}" ]]; then + if ! clipboard_result="$("$CLIPBOARD_HELPER" read "$temp_dir/clipboard")"; then + fail_upload "剪贴板中没有可上传的单个文件或图片" + fi +else + if ! clipboard_result="$(/usr/bin/osascript -l JavaScript "$CLIPBOARD_SCRIPT" read "$temp_dir/clipboard")"; then + fail_upload "剪贴板中没有可上传的单个文件或图片" + fi +fi + +IFS=$'\x1f' read -r source_kind source_path source_extension source_temporary <<< "$clipboard_result" +if [[ "$source_kind" != "file" && "$source_kind" != "image" ]]; then + fail_upload "剪贴板数据类型无效" +fi +if [[ ! -f "$source_path" ]]; then + fail_upload "剪贴板文件不存在或不是普通文件" +fi +if [[ "$source_path" =~ [[:cntrl:]] ]]; then + fail_upload "暂不支持路径中包含控制字符的文件" +fi + +file_size="$(/usr/bin/stat -f '%z' "$source_path")" +limit_bytes=$((max_size_mb * 1024 * 1024)) +if (( file_size > limit_bytes )); then + fail_upload "文件大小超过 ${max_size_mb} MiB 限制" +fi + +source_name="$(/usr/bin/basename "$source_path")" +if [[ "$source_kind" == "image" ]]; then + if [[ ! "$source_extension" =~ ^[a-z0-9]+$ ]]; then + fail_upload "剪贴板图片后缀无效" + fi +else + source_extension="" +fi +upload_date="${ALFRED_REMOTE_UPLOAD_DATE:-$(/bin/date +%Y%m%d)}" +if [[ ! "$upload_date" =~ ^[0-9]{8}$ ]]; then + fail_upload "上传日期格式无效" +fi + +if ! reserve_result="$(run_remote reserve)"; then + fail_upload "无法在远端创建上传路径" +fi +protocol_prefix=$'ARU1\x1f' +field_separator=$'\x1f' +if [[ "$reserve_result" == *$'\n'* || "$reserve_result" == *$'\r'* ]] || + [[ "$reserve_result" != "$protocol_prefix"* ]]; then + fail_upload "远端返回的上传路径协议无效" +fi +reserve_fields="${reserve_result#"$protocol_prefix"}" +if [[ "$reserve_fields" != *"$field_separator"* ]]; then + fail_upload "远端返回的上传路径字段不完整" +fi +remote_path="${reserve_fields%%"$field_separator"*}" +reservation_path="${reserve_fields#*"$field_separator"}" +if [[ -z "$remote_path" || "$remote_path" != /* ]] || + [[ "$remote_path" =~ [[:cntrl:]] ]] || + [[ "$reservation_path" =~ [[:cntrl:]] ]] || + [[ "$reservation_path" == *"$field_separator"* ]]; then + fail_upload "远端返回的上传路径字段无效" +fi + +if [[ "$upload_remote_dir" == "/" ]]; then + remote_prefix="/" +else + remote_prefix="$upload_remote_dir/" +fi + +if [[ "$source_kind" == "image" ]]; then + if [[ "$remote_path" != "$remote_prefix"* ]]; then + fail_upload "远端截图路径超出配置目录" + fi + relative_path="${remote_path#"$remote_prefix"}" + image_prefix="${upload_date}_" + image_suffix=".${source_extension}" + if [[ "$relative_path" == */* || "$relative_path" != "$image_prefix"*"$image_suffix" ]]; then + fail_upload "远端截图文件名无效" + fi + image_sequence="${relative_path#"$image_prefix"}" + image_sequence="${image_sequence%"$image_suffix"}" + if [[ ! "$image_sequence" =~ ^[1-9][0-9]*$ ]] || (( image_sequence > 999999 )); then + fail_upload "远端截图序号无效" + fi + expected_reservation="${remote_prefix}.alfred_remote_upload_${upload_date}_${image_sequence}.reserve" + if [[ "$reservation_path" != "$expected_reservation" ]]; then + fail_upload "远端截图 reservation 路径无效" + fi +else + file_prefix="${remote_prefix}${upload_date}/" + if [[ "$remote_path" != "$file_prefix"* ]] || [[ -n "$reservation_path" ]]; then + fail_upload "远端普通文件路径无效" + fi + remote_name="${remote_path#"$file_prefix"}" + if [[ -z "$remote_name" || "$remote_name" == */* ]]; then + fail_upload "远端普通文件名无效" + fi + if [[ "$remote_name" != "$source_name" ]]; then + stem="$source_name" + extension="" + if [[ "$source_name" == .* && "${source_name#?}" == *.* ]] || + [[ "$source_name" != .* && "$source_name" == *.* ]]; then + stem="${source_name%.*}" + extension=".${source_name##*.}" + fi + collision_prefix="${stem}_" + if [[ "$remote_name" != "$collision_prefix"*"$extension" ]]; then + fail_upload "远端普通文件重名后缀无效" + fi + collision_sequence="${remote_name#"$collision_prefix"}" + if [[ -n "$extension" ]]; then + collision_sequence="${collision_sequence%"$extension"}" + fi + if [[ ! "$collision_sequence" =~ ^[1-9][0-9]*$ ]] || (( collision_sequence > 999999 )); then + fail_upload "远端普通文件重名序号无效" + fi + fi +fi +remote_pending=1 + +if ! "$SCP_BIN" "${SCP_OPTIONS[@]}" -- "$source_path" "${upload_host}:${remote_path}"; then + fail_upload "SCP 上传失败" +fi +if ! run_remote commit >/dev/null; then + fail_upload "远端权限设置失败" +fi +remote_pending=0 + +if ! printf '%s' "$remote_path" | "$PBCOPY_BIN"; then + fail_upload "远端文件已上传,但写入剪贴板失败:$remote_path" +fi + +mru_updated=true +if ! env alfred_workflow_data="${alfred_workflow_data:-}" \ + /usr/bin/osascript -l JavaScript "$HOSTS_SCRIPT" mark-used "$upload_host" "$mru_remote_dir" >/dev/null; then + mru_updated=false +fi + +if [[ "$mru_updated" == "true" ]]; then + notify "Remote Upload 完成" "$remote_path" >/dev/null 2>&1 || true +else + notify "Remote Upload 完成" "$remote_path(最近使用排序未更新)" >/dev/null 2>&1 || true +fi +printf '%s\n' "$remote_path"