commit c34af538783521cbfef6735287dc4d7fdc81f531 Author: Logic Date: Wed Mar 25 09:52:20 2026 +0800 feat: add PySide6 clipboard uploader with Nuitka release tooling diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8dde0d5 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SUPERBED_TOKEN=your_token_here diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29af8cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +*.pyo +*.qml +superbed-upload.local.env +.venv/ +dist/ +build/ +*.bin +*.build +*.dist +.cache/ +nuitka-crash-report.xml diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5b954e --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# manjaro_kde_upload_tools + +Manjaro Linux + KDE 6 下的剪贴板图片上传工具。 + +## 功能 + +- 读取当前剪贴板中的最新图片 +- 上传到聚合图床 `https://api.superbed.cn/upload` +- 成功后把返回链接改写成 Markdown:`![](url)` +- 自动写回剪贴板 +- 用 PySide6 显示底部居中的圆角、半透明、淡入淡出 toast +- 可用 Nuitka 打包成单文件二进制 +- 二进制可直接运行 +- 启动脚本会优先运行 `dist/superbed-uploader.bin`,没有二进制时回退到 Python + +## 依赖 + +- 运行时: + - `wl-paste` / `wl-copy`(Wayland,推荐) + - 或 `xclip` / `xsel`(X11) +- 开发/构建时: + - Python 3 + - `uv` + - `PySide6` + - `requests` + - `Nuitka` + +说明:这套方案面向**桌面 Linux**。没有图形界面或没有剪贴板工具的服务器环境不适用。 + +## Token 配置 + +程序读取: + +```bash +/home/droid/.config/superbed-upload.env +``` + +内容: + +```bash +SUPERBED_TOKEN=你的token +``` + +也可以参考仓库里的: + +```bash +.env.example +``` + +## 初始化开发环境 + +```bash +cd /home/droid/project/manjaro_kde_upload_tools +./setup-venv.sh +``` + +## 直接运行 Python 版 + +```bash +cd /home/droid/project/manjaro_kde_upload_tools +./.venv/bin/python superbed_qt_uploader.py +``` + +## 测试 toast + +成功 toast: + +```bash +./superbed-upload.sh --test-toast-success +``` + +失败 toast: + +```bash +./superbed-upload.sh --test-toast-fail +``` + +## 手工测试 + +```bash +~/project/manjaro_kde_upload_tools/superbed-upload.sh +``` + +或者直接运行二进制: + +```bash +~/project/manjaro_kde_upload_tools/dist/superbed-uploader.bin +``` + +## KDE 6 快捷键 + +在 KDE 系统设置中把下面命令绑定到 `Alt+U`: + +```bash +/home/droid/project/manjaro_kde_upload_tools/superbed-upload.sh +``` + +## 构建单文件二进制 + +```bash +cd /home/droid/project/manjaro_kde_upload_tools +./build-nuitka.sh +``` + +成功后得到: + +```bash +/home/droid/project/manjaro_kde_upload_tools/dist/superbed-uploader.bin +``` + +此后 `superbed-upload.sh` 会优先调用这个二进制。 diff --git a/build-nuitka.sh b/build-nuitka.sh new file mode 100755 index 0000000..2e576a0 --- /dev/null +++ b/build-nuitka.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${SCRIPT_DIR}/.cache}" + +if [[ ! -x .venv/bin/python ]]; then + "${SCRIPT_DIR}/setup-venv.sh" +fi + +. .venv/bin/activate + +python -m nuitka \ + --onefile \ + --plugin-enable=pyside6 \ + --output-dir="${SCRIPT_DIR}/dist" \ + --output-filename="superbed-uploader.bin" \ + --assume-yes-for-downloads \ + --include-package=requests \ + "${SCRIPT_DIR}/superbed_qt_uploader.py" + +echo "构建完成:${SCRIPT_DIR}/dist/superbed-uploader.bin" diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..2ff7668 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,2 @@ +nuitka>=4.0 +zstandard>=0.23 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d311009 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.11,<7 +requests>=2.32,<3 diff --git a/setup-venv.sh b/setup-venv.sh new file mode 100755 index 0000000..803f71b --- /dev/null +++ b/setup-venv.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "${SCRIPT_DIR}" + +export UV_CACHE_DIR="${UV_CACHE_DIR:-/tmp/uv-cache}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-${SCRIPT_DIR}/.cache}" + +if ! command -v uv >/dev/null 2>&1; then + echo "未找到 uv,请先安装 uv。" >&2 + exit 1 +fi + +uv venv .venv +. .venv/bin/activate +uv pip install -r requirements.txt -r requirements-build.txt + +echo "虚拟环境已就绪:${SCRIPT_DIR}/.venv" diff --git a/superbed-upload.sh b/superbed-upload.sh new file mode 100755 index 0000000..10a017f --- /dev/null +++ b/superbed-upload.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +BIN_PATH="${SCRIPT_DIR}/dist/superbed-uploader.bin" +VENV_PYTHON="${SCRIPT_DIR}/.venv/bin/python" +PY_APP="${SCRIPT_DIR}/superbed_qt_uploader.py" + +if [[ -x "${BIN_PATH}" ]]; then + exec "${BIN_PATH}" "$@" +fi + +if [[ -x "${VENV_PYTHON}" ]]; then + exec "${VENV_PYTHON}" "${PY_APP}" "$@" +fi + +if command -v python3 >/dev/null 2>&1; then + exec python3 "${PY_APP}" "$@" +fi + +echo "未找到可用运行环境。请先执行 ${SCRIPT_DIR}/setup-venv.sh 或构建 dist/superbed-uploader.bin" >&2 +exit 1 diff --git a/superbed-upload.sh.example b/superbed-upload.sh.example new file mode 100755 index 0000000..32cfc88 --- /dev/null +++ b/superbed-upload.sh.example @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +exec "${SCRIPT_DIR}/superbed-upload.sh" "$@" diff --git a/superbed_clipboard_upload.py b/superbed_clipboard_upload.py new file mode 100644 index 0000000..ef25dbe --- /dev/null +++ b/superbed_clipboard_upload.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Iterable +from urllib.parse import urlparse, unquote + +import requests + + +API_URL = "https://api.superbed.cn/upload" +IMAGE_MIME_PREFERENCE = [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/bmp", + "image/tiff", +] +MIME_EXT = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", + "image/bmp": ".bmp", + "image/tiff": ".tiff", +} + + +class UploadError(RuntimeError): + pass + + +def run_command(cmd: list[str], *, input_bytes: bytes | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, + input=input_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + +def available_clipboard_types() -> list[str]: + if shutil.which("wl-paste"): + proc = run_command(["wl-paste", "--list-types"]) + if proc.returncode != 0: + raise UploadError(f"读取剪贴板类型失败: {proc.stderr.decode('utf-8', 'ignore').strip()}") + return [line.strip() for line in proc.stdout.decode("utf-8", "ignore").splitlines() if line.strip()] + + if shutil.which("xclip"): + # X11 fallback: xclip 很难完整列出所有目标类型,这里只做常见兜底 + return IMAGE_MIME_PREFERENCE + ["text/uri-list"] + + raise UploadError("未找到 wl-paste 或 xclip,无法读取剪贴板。请先安装 wl-clipboard。") + + +def clipboard_image_from_wayland() -> tuple[str, bytes] | None: + types = available_clipboard_types() + for mime in IMAGE_MIME_PREFERENCE: + if mime not in types: + continue + proc = run_command(["wl-paste", "--type", mime]) + if proc.returncode == 0 and proc.stdout: + return mime, proc.stdout + return None + + +def clipboard_image_from_x11() -> tuple[str, bytes] | None: + if not shutil.which("xclip"): + return None + proc = run_command(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"]) + if proc.returncode == 0 and proc.stdout: + return "image/png", proc.stdout + return None + + +def parse_file_uri(uri: str) -> Path | None: + parsed = urlparse(uri.strip()) + if parsed.scheme != "file": + return None + path = unquote(parsed.path) + if not path: + return None + p = Path(path) + return p if p.exists() else None + + +def clipboard_file_from_wayland() -> tuple[str, bytes, str] | None: + types = available_clipboard_types() + if "text/uri-list" not in types or not shutil.which("wl-paste"): + return None + + proc = run_command(["wl-paste", "--type", "text/uri-list"]) + if proc.returncode != 0 or not proc.stdout: + return None + + for raw in proc.stdout.decode("utf-8", "ignore").splitlines(): + raw = raw.strip() + if not raw or raw.startswith("#"): + continue + file_path = parse_file_uri(raw) + if not file_path: + continue + ext = file_path.suffix.lower() + mime = next((k for k, v in MIME_EXT.items() if v == ext), None) + if mime is None and ext == ".jpeg": + mime = "image/jpeg" + if mime is None: + continue + data = file_path.read_bytes() + return mime, data, file_path.name + return None + + +def get_clipboard_image() -> tuple[str, bytes, str]: + img = clipboard_image_from_wayland() + if img is not None: + mime, data = img + return mime, data, f"clipboard{MIME_EXT.get(mime, '.png')}" + + img = clipboard_image_from_x11() + if img is not None: + mime, data = img + return mime, data, f"clipboard{MIME_EXT.get(mime, '.png')}" + + file_clip = clipboard_file_from_wayland() + if file_clip is not None: + return file_clip + + raise UploadError("剪贴板里没有图片数据。请先复制截图或图片。") + + +def build_form(args: argparse.Namespace) -> dict[str, str]: + token = args.token or os.environ.get("SUPERBED_TOKEN", "").strip() + if not token: + raise UploadError("未提供 token。请设置环境变量 SUPERBED_TOKEN,或用 --token 传入。") + + form: dict[str, str] = {"token": token} + if args.categories: + form["categories"] = args.categories + if args.filename: + form["filename"] = args.filename + if args.watermark is not None: + form["watermark"] = "true" if args.watermark else "false" + if args.compress is not None: + form["compress"] = "true" if args.compress else "false" + if args.webp is not None: + form["webp"] = "true" if args.webp else "false" + return form + + +def upload_image(args: argparse.Namespace, mime: str, data: bytes, filename: str) -> str: + form = build_form(args) + upload_name = args.filename or filename + files = {"file": (upload_name, data, mime)} + try: + resp = requests.post(API_URL, data=form, files=files, timeout=args.timeout) + resp.raise_for_status() + except requests.RequestException as exc: + raise UploadError(f"网络请求失败: {exc}") from exc + + try: + payload = resp.json() + except json.JSONDecodeError as exc: + raise UploadError(f"接口返回的不是合法 JSON: {resp.text[:200]}") from exc + + if payload.get("err") != 0: + raise UploadError(str(payload.get("msg") or payload)) + + url = payload.get("url") + if not url: + raise UploadError(f"接口未返回 url: {payload}") + return str(url) + + +def write_clipboard_text(text: str) -> None: + encoded = text.encode("utf-8") + if shutil.which("wl-copy"): + proc = run_command(["wl-copy", "--type", "text/plain;charset=utf-8"], input_bytes=encoded) + if proc.returncode == 0: + return + if shutil.which("xclip"): + proc = run_command(["xclip", "-selection", "clipboard"], input_bytes=encoded) + if proc.returncode == 0: + return + raise UploadError("上传成功,但写回剪贴板失败:未找到 wl-copy 或 xclip。") + + +def qml_toast(message: str, success: bool) -> bool: + qmlscene = shutil.which("qmlscene6") or shutil.which("qmlscene") + if not qmlscene: + return False + + bg = "#166534" if success else "#991b1b" + border = "#4ade80" if success else "#f87171" + text = json.dumps(message, ensure_ascii=False) + qml = f"""import QtQuick 2.15 +import QtQuick.Window 2.15 + +Window {{ + id: root + visible: true + color: "transparent" + opacity: 0.0 + flags: Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.WindowDoesNotAcceptFocus + property string toastText: {text} + width: Math.min(Screen.width * 0.88, Math.max(300, label.implicitWidth + 48)) + height: Math.max(52, label.paintedHeight + 26) + + Rectangle {{ + id: bubble + anchors.fill: parent + radius: 16 + color: "{bg}" + border.width: 1 + border.color: "{border}" + opacity: 0.84 + antialiasing: true + }} + + Text {{ + id: label + anchors.centerIn: parent + width: root.width - 28 + text: root.toastText + wrapMode: Text.WrapAtWordBoundaryOrAnywhere + horizontalAlignment: Text.AlignHCenter + color: "white" + font.pixelSize: 14 + font.bold: true + }} + + Component.onCompleted: {{ + x = Math.round((Screen.width - width) / 2) + y = Math.round(Screen.height - height - Math.max(48, Screen.height * 0.08)) + fadeAnim.start() + }} + + SequentialAnimation {{ + id: fadeAnim + running: false + NumberAnimation {{ + target: root + property: "opacity" + from: 0.0 + to: 1.0 + duration: 180 + easing.type: Easing.OutCubic + }} + PauseAnimation {{ duration: 1800 }} + NumberAnimation {{ + target: root + property: "opacity" + from: 1.0 + to: 0.0 + duration: 260 + easing.type: Easing.InCubic + }} + onStopped: Qt.quit() + }} +}} +""" + tmp = None + try: + tmp = tempfile.NamedTemporaryFile("w", suffix=".qml", delete=False, encoding="utf-8") + tmp.write(qml) + tmp.flush() + tmp.close() + subprocess.Popen( + [qmlscene, tmp.name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except Exception: + return False + + +def notify_send(message: str, success: bool) -> bool: + if not shutil.which("notify-send"): + return False + title = "✅ 图片上传成功" if success else "❌ 图片上传失败" + icon = "dialog-information" if success else "dialog-error" + try: + subprocess.Popen( + ["notify-send", "-a", "Superbed Upload", "-i", icon, title, message], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except Exception: + return False + + +def show_toast(message: str, success: bool) -> None: + prefix = "✅" if success else "❌" + decorated_message = f"{prefix} {message}" + if qml_toast(decorated_message, success): + return + notify_send(decorated_message, success) + + +def parse_args(argv: Iterable[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="上传剪贴板图片到聚合图床,并把 Markdown 链接写回剪贴板。") + parser.add_argument("--token", help="图床 token;不传则读取环境变量 SUPERBED_TOKEN") + parser.add_argument("--categories", help="相册,多个用英文逗号分隔") + parser.add_argument("--filename", help="自定义上传文件名") + parser.add_argument("--timeout", type=int, default=30, help="上传超时秒数,默认 30") + parser.add_argument("--markdown-template", default="![]({url})", help="剪贴板写回模板,默认 ![]({url})") + parser.add_argument("--watermark", dest="watermark", action="store_true", help="强制开启水印") + parser.add_argument("--no-watermark", dest="watermark", action="store_false", help="强制关闭水印") + parser.set_defaults(watermark=None) + parser.add_argument("--compress", dest="compress", action="store_true", help="强制开启压缩") + parser.add_argument("--no-compress", dest="compress", action="store_false", help="强制关闭压缩") + parser.set_defaults(compress=None) + parser.add_argument("--webp", dest="webp", action="store_true", help="强制转为 webp") + parser.add_argument("--no-webp", dest="webp", action="store_false", help="强制关闭 webp") + parser.set_defaults(webp=None) + return parser.parse_args(list(argv)) + + +def main(argv: Iterable[str]) -> int: + args = parse_args(argv) + + try: + mime, data, filename = get_clipboard_image() + url = upload_image(args, mime, data, filename) + markdown = args.markdown_template.format(url=url, filename=filename) + write_clipboard_text(markdown) + print(markdown) + show_toast("图片上传成功,Markdown 已复制到剪贴板", True) + return 0 + except UploadError as exc: + msg = f"图片上传失败,原因:{exc}" + print(msg, file=sys.stderr) + show_toast(msg, False) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/superbed_qt_uploader.py b/superbed_qt_uploader.py new file mode 100644 index 0000000..f96824e --- /dev/null +++ b/superbed_qt_uploader.py @@ -0,0 +1,713 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable +from urllib.parse import unquote, urlparse + +import requests +from PySide6.QtCore import ( + QByteArray, + QEasingCurve, + QBuffer, + QIODevice, + QMessageLogContext, + QMimeDatabase, + QObject, + QPoint, + QPropertyAnimation, + QParallelAnimationGroup, + QPauseAnimation, + QSequentialAnimationGroup, + QThread, + QTimer, + Qt, + QtMsgType, + Signal, + Slot, + qInstallMessageHandler, +) +from PySide6.QtGui import ( + QClipboard, + QColor, + QCursor, + QGuiApplication, + QImage, + QImageWriter, +) +from PySide6.QtWidgets import ( + QApplication, + QFrame, + QGraphicsDropShadowEffect, + QGraphicsOpacityEffect, + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + + +API_URL = "https://api.superbed.cn/upload" +SUPPORTED_IMAGE_MIME = [ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "image/bmp", + "image/tiff", +] +MIME_TO_EXT = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", + "image/gif": ".gif", + "image/bmp": ".bmp", + "image/tiff": ".tiff", +} +MIME_TO_QT_FORMAT = { + "image/png": "PNG", + "image/jpeg": "JPEG", + "image/webp": "WEBP", + "image/gif": "GIF", + "image/bmp": "BMP", + "image/tiff": "TIFF", +} + + +class UploadError(RuntimeError): + pass + + +def install_qt_message_filter() -> None: + def handler(mode: QtMsgType, context: QMessageLogContext, message: str) -> None: + if "Failed to compute left/right minimum bearings for \"Droid Sans\"" in message: + return + stream = sys.stderr + try: + stream.write(f"{message}\n") + stream.flush() + except Exception: + pass + + qInstallMessageHandler(handler) + + +@dataclass(slots=True) +class UploadOptions: + token: str + categories: str | None + filename: str | None + timeout: int + markdown_template: str + watermark: bool | None + compress: bool | None + webp: bool | None + test_toast: str | None = None + + +@dataclass(slots=True) +class ClipboardImage: + data: bytes + mime: str + filename: str + + +def strip_optional_quotes(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def read_env_value(paths: list[Path], key: str) -> str | None: + for path in paths: + if not path.exists(): + continue + for raw_line in path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + env_key, env_value = line.split("=", 1) + if env_key.strip() == key: + return strip_optional_quotes(env_value) + return None + + +def supported_qt_formats() -> set[str]: + return {bytes(fmt).decode("ascii").upper() for fmt in QImageWriter.supportedImageFormats()} + + +def choose_mime_from_formats(formats: list[str]) -> str: + lower_formats = {fmt.lower() for fmt in formats} + for mime in SUPPORTED_IMAGE_MIME: + if mime in lower_formats: + return mime + return "image/png" + + +def image_to_bytes(image: QImage, mime: str) -> tuple[bytes, str]: + if image.isNull(): + raise UploadError("剪贴板里的图片无效。") + + qt_formats = supported_qt_formats() + requested = MIME_TO_QT_FORMAT.get(mime, "PNG") + fmt = requested if requested in qt_formats else "PNG" + actual_mime = mime if fmt == requested else "image/png" + ext = MIME_TO_EXT.get(actual_mime, ".png") + + payload = QByteArray() + buffer = QBuffer(payload) + if not buffer.open(QIODevice.OpenModeFlag.WriteOnly): + raise UploadError("无法为图片编码创建内存缓冲区。") + + ok = image.save(buffer, fmt) + buffer.close() + if not ok: + raise UploadError(f"无法把剪贴板图片编码为 {fmt}。") + + return bytes(payload.data()), f"clipboard{ext}" + + +def parse_local_file_url(url_text: str) -> Path | None: + parsed = urlparse(url_text) + if parsed.scheme != "file": + return None + path = unquote(parsed.path) + if not path: + return None + candidate = Path(path) + return candidate if candidate.exists() else None + + +def run_command(cmd: list[str], input_bytes: bytes | None = None) -> subprocess.CompletedProcess[bytes]: + return subprocess.run( + cmd, + input=input_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + +def mime_to_ext(mime: str) -> str: + if mime == "image/jpg": + return ".jpg" + if mime == "image/tif": + return ".tiff" + return MIME_TO_EXT.get(mime, ".png") + + +def available_clipboard_types() -> list[str]: + if not shutil.which("wl-paste"): + return [] + result = run_command(["wl-paste", "--list-types"]) + if result.returncode != 0: + return [] + return [line.strip() for line in result.stdout.decode("utf-8", "ignore").splitlines() if line.strip()] + + +def read_clipboard_image_from_tools() -> ClipboardImage | None: + types = available_clipboard_types() + if types: + for mime in SUPPORTED_IMAGE_MIME: + if mime not in types: + continue + result = run_command(["wl-paste", "--type", mime]) + if result.returncode == 0 and result.stdout: + return ClipboardImage( + data=result.stdout, + mime=mime, + filename=f"clipboard{mime_to_ext(mime)}", + ) + + if "text/uri-list" in types: + result = run_command(["wl-paste", "--type", "text/uri-list"]) + if result.returncode == 0 and result.stdout: + mime_db = QMimeDatabase() + for raw in result.stdout.decode("utf-8", "ignore").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + path = parse_local_file_url(line) + if path is None or not path.is_file(): + continue + mime = mime_db.mimeTypeForFile(str(path)).name() + if mime.startswith("image/"): + return ClipboardImage(data=path.read_bytes(), mime=mime, filename=path.name) + + if shutil.which("xclip"): + for mime in SUPPORTED_IMAGE_MIME: + result = run_command(["xclip", "-selection", "clipboard", "-t", mime, "-o"]) + if result.returncode == 0 and result.stdout: + return ClipboardImage( + data=result.stdout, + mime=mime, + filename=f"clipboard{mime_to_ext(mime)}", + ) + + return None + + +def read_clipboard_image(clipboard: QClipboard) -> ClipboardImage: + clipboard_image = read_clipboard_image_from_tools() + if clipboard_image is not None: + return clipboard_image + + mime_data = clipboard.mimeData(QClipboard.Mode.Clipboard) + if mime_data is None: + raise UploadError("当前会话无法读取系统剪贴板。") + + if mime_data.hasImage(): + mime = choose_mime_from_formats(mime_data.formats()) + image = clipboard.image(QClipboard.Mode.Clipboard) + if image.isNull(): + image_data = mime_data.imageData() + if isinstance(image_data, QImage): + image = image_data + data, filename = image_to_bytes(image, mime) + return ClipboardImage(data=data, mime=mime, filename=filename) + + if mime_data.hasUrls(): + mime_db = QMimeDatabase() + for url in mime_data.urls(): + if not url.isLocalFile(): + continue + path = Path(url.toLocalFile()) + if not path.is_file(): + continue + mime = mime_db.mimeTypeForFile(str(path)).name() + if not mime.startswith("image/"): + continue + return ClipboardImage(data=path.read_bytes(), mime=mime, filename=path.name) + + text = clipboard.text(QClipboard.Mode.Clipboard).strip() + if text.startswith("file://"): + path = parse_local_file_url(text) + if path is not None: + mime = QMimeDatabase().mimeTypeForFile(str(path)).name() + if mime.startswith("image/"): + return ClipboardImage(data=path.read_bytes(), mime=mime, filename=path.name) + + raise UploadError("剪贴板里没有图片数据。请先复制截图或图片。") + + +def write_text_to_clipboard(text: str, clipboard: QClipboard) -> None: + encoded = text.encode("utf-8") + + if shutil.which("wl-copy"): + process = subprocess.Popen( + ["wl-copy", "--type", "text/plain;charset=utf-8"], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + assert process.stdin is not None + process.stdin.write(encoded) + process.stdin.close() + if process.poll() in {None, 0}: + return + + if shutil.which("xclip"): + process = subprocess.Popen( + ["xclip", "-selection", "clipboard"], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + assert process.stdin is not None + process.stdin.write(encoded) + process.stdin.close() + if process.poll() in {None, 0}: + return + + if shutil.which("xsel"): + process = subprocess.Popen( + ["xsel", "--clipboard", "--input"], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + assert process.stdin is not None + process.stdin.write(encoded) + process.stdin.close() + if process.poll() in {None, 0}: + return + + clipboard.setText(text, QClipboard.Mode.Clipboard) + if clipboard.supportsSelection(): + clipboard.setText(text, QClipboard.Mode.Selection) + + +def upload_to_superbed(options: UploadOptions, image: ClipboardImage) -> str: + form: dict[str, str] = {"token": options.token} + if options.categories: + form["categories"] = options.categories + if options.filename: + form["filename"] = options.filename + if options.watermark is not None: + form["watermark"] = "true" if options.watermark else "false" + if options.compress is not None: + form["compress"] = "true" if options.compress else "false" + if options.webp is not None: + form["webp"] = "true" if options.webp else "false" + + upload_name = options.filename or image.filename + files = { + "file": ( + upload_name, + image.data, + image.mime, + ) + } + + try: + response = requests.post(API_URL, data=form, files=files, timeout=options.timeout) + response.raise_for_status() + except requests.RequestException as exc: + raise UploadError(f"网络请求失败: {exc}") from exc + + try: + payload = response.json() + except ValueError as exc: + raise UploadError(f"接口返回的不是合法 JSON: {response.text[:200]}") from exc + + if payload.get("err") != 0: + raise UploadError(str(payload.get("msg") or payload)) + + url = payload.get("url") + if not url: + raise UploadError(f"接口未返回 url: {payload}") + return str(url) + + +class UploadWorker(QObject): + success = Signal(str) + failure = Signal(str) + finished = Signal() + + def __init__(self, options: UploadOptions, image: ClipboardImage) -> None: + super().__init__() + self.options = options + self.image = image + + @Slot() + def run(self) -> None: + try: + url = upload_to_superbed(self.options, self.image) + except Exception as exc: # noqa: BLE001 + self.failure.emit(str(exc)) + else: + self.success.emit(url) + finally: + self.finished.emit() + + +class ToastWindow(QWidget): + done = Signal() + + def __init__(self, message: str, success: bool, duration_ms: int = 2200) -> None: + super().__init__(None) + self.duration_ms = duration_ms + self.success = success + self._setup_window() + self._build_ui(message, success) + self._animations: list[QObject] = [] + + def _setup_window(self) -> None: + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating, True) + self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self.setWindowFlags( + Qt.WindowType.Tool + | Qt.WindowType.FramelessWindowHint + | Qt.WindowType.WindowStaysOnTopHint + | Qt.WindowType.WindowDoesNotAcceptFocus + ) + self.setFocusPolicy(Qt.FocusPolicy.NoFocus) + + def _build_ui(self, message: str, success: bool) -> None: + palette = { + True: { + "background": "rgba(22, 101, 52, 214)", + "border": "#4ade80", + "icon": "✅", + }, + False: { + "background": "rgba(153, 27, 27, 214)", + "border": "#f87171", + "icon": "❌", + }, + }[success] + + root = QVBoxLayout(self) + root.setContentsMargins(14, 10, 14, 16) + + self.container = QFrame(self) + self.container.setObjectName("container") + self.container.setStyleSheet("background: transparent; border: 0;") + + container_layout = QVBoxLayout(self.container) + container_layout.setContentsMargins(0, 0, 0, 0) + + self.panel = QFrame(self.container) + self.panel.setObjectName("panel") + self.panel.setStyleSheet( + f""" + QFrame#panel {{ + background-color: {palette["background"]}; + border: 1px solid {palette["border"]}; + border-radius: 16px; + }} + QLabel {{ + color: white; + background: transparent; + }} + """ + ) + + shadow = QGraphicsDropShadowEffect(self.container) + shadow.setBlurRadius(32) + shadow.setOffset(0, 10) + shadow.setColor(QColor(0, 0, 0, 95)) + self.container.setGraphicsEffect(shadow) + + panel_layout = QHBoxLayout(self.panel) + panel_layout.setContentsMargins(16, 14, 16, 14) + panel_layout.setSpacing(10) + + icon_label = QLabel(palette["icon"], self.panel) + icon_label.setStyleSheet("font-size: 17px;") + icon_label.setAlignment(Qt.AlignmentFlag.AlignTop) + panel_layout.addWidget(icon_label, 0, Qt.AlignmentFlag.AlignTop) + + self.message_label = QLabel(message, self.panel) + self.message_label.setWordWrap(True) + self.message_label.setMinimumWidth(220) + self.message_label.setMaximumWidth(640) + self.message_label.setStyleSheet("font-size: 14px; font-weight: 700;") + panel_layout.addWidget(self.message_label) + + container_layout.addWidget(self.panel) + root.addWidget(self.container) + + self.opacity_effect = QGraphicsOpacityEffect(self.panel) + self.opacity_effect.setOpacity(0.0) + self.panel.setGraphicsEffect(self.opacity_effect) + + self.adjustSize() + + def showAnimated(self) -> None: + self.adjustSize() + screen = QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen() + geometry = screen.availableGeometry() if screen else QGuiApplication.primaryScreen().availableGeometry() + self.resize(self.sizeHint()) + end_pos = QPoint( + geometry.x() + (geometry.width() - self.width()) // 2, + geometry.bottom() - self.height() - max(18, int(geometry.height() * 0.03)), + ) + start_pos = end_pos + QPoint(0, 18) + self.move(start_pos) + self.show() + self.raise_() + + fade_in = QPropertyAnimation(self.opacity_effect, b"opacity", self) + fade_in.setDuration(180) + fade_in.setStartValue(0.0) + fade_in.setEndValue(1.0) + fade_in.setEasingCurve(QEasingCurve.Type.OutCubic) + + move_in = QPropertyAnimation(self, b"pos", self) + move_in.setDuration(220) + move_in.setStartValue(start_pos) + move_in.setEndValue(end_pos) + move_in.setEasingCurve(QEasingCurve.Type.OutCubic) + + enter = QParallelAnimationGroup(self) + enter.addAnimation(fade_in) + enter.addAnimation(move_in) + + fade_out = QPropertyAnimation(self.opacity_effect, b"opacity", self) + fade_out.setDuration(260) + fade_out.setStartValue(1.0) + fade_out.setEndValue(0.0) + fade_out.setEasingCurve(QEasingCurve.Type.InCubic) + + move_out = QPropertyAnimation(self, b"pos", self) + move_out.setDuration(260) + move_out.setStartValue(end_pos) + move_out.setEndValue(end_pos - QPoint(0, 8)) + move_out.setEasingCurve(QEasingCurve.Type.InCubic) + + leave = QParallelAnimationGroup(self) + leave.addAnimation(fade_out) + leave.addAnimation(move_out) + + sequence = QSequentialAnimationGroup(self) + sequence.addAnimation(enter) + sequence.addAnimation(QPauseAnimation(max(300, self.duration_ms - 440), self)) + sequence.addAnimation(leave) + sequence.finished.connect(self._finish) + self._animations = [sequence, enter, leave, fade_in, fade_out, move_in, move_out] + sequence.start() + + @Slot() + def _finish(self) -> None: + self.close() + self.done.emit() + + +class UploadController(QObject): + def __init__(self, app: QApplication, options: UploadOptions) -> None: + super().__init__() + self.app = app + self.options = options + self.clipboard = QApplication.clipboard() + self.thread: QThread | None = None + self.worker: UploadWorker | None = None + self.toast: ToastWindow | None = None + self.current_filename = "clipboard" + + @Slot() + def start(self) -> None: + if self.options.test_toast == "success": + self.show_toast("图片上传成功,Markdown 已复制到剪贴板", True) + return + if self.options.test_toast == "fail": + self.show_toast("图片上传失败,原因:这是一个失败 toast 测试", False) + return + + try: + clipboard_image = read_clipboard_image(self.clipboard) + except UploadError as exc: + print(str(exc), file=sys.stderr, flush=True) + self.show_toast(f"图片上传失败,原因:{exc}", False) + return + self.current_filename = clipboard_image.filename + + self.thread = QThread(self) + self.worker = UploadWorker(self.options, clipboard_image) + self.worker.moveToThread(self.thread) + self.thread.started.connect(self.worker.run) + self.worker.success.connect(self.on_success) + self.worker.failure.connect(self.on_failure) + self.worker.finished.connect(self.thread.quit) + self.worker.finished.connect(self.worker.deleteLater) + self.thread.finished.connect(self.thread.deleteLater) + self.thread.start() + + @Slot(str) + def on_success(self, url: str) -> None: + markdown = self.options.markdown_template.format(url=url, filename=self.options.filename or self.current_filename) + write_text_to_clipboard(markdown, self.clipboard) + print(markdown, flush=True) + self.show_toast("图片上传成功,Markdown 已复制到剪贴板", True) + + @Slot(str) + def on_failure(self, error: str) -> None: + print(error, file=sys.stderr, flush=True) + self.show_toast(f"图片上传失败,原因:{error}", False) + + def show_toast(self, message: str, success: bool) -> None: + if self.toast is not None: + self.toast.close() + self.toast.deleteLater() + self.toast = ToastWindow(message, success) + self.toast.done.connect(self.app.quit) + self.toast.showAnimated() + + +def parse_args(argv: Iterable[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="上传剪贴板图片到聚合图床,并把 Markdown 链接写回剪贴板。") + parser.add_argument("--token", help="图床 token;不传则按优先级读取环境变量和配置文件") + parser.add_argument("--categories", help="相册,多个用英文逗号分隔") + parser.add_argument("--filename", help="指定上传文件名") + parser.add_argument("--timeout", type=int, default=30, help="上传超时秒数,默认 30") + parser.add_argument("--markdown-template", default="![]({url})", help="剪贴板写回模板,默认 ![]({url})") + parser.add_argument("--watermark", dest="watermark", action="store_true", help="强制开启水印") + parser.add_argument("--no-watermark", dest="watermark", action="store_false", help="强制关闭水印") + parser.set_defaults(watermark=None) + parser.add_argument("--compress", dest="compress", action="store_true", help="强制开启压缩") + parser.add_argument("--no-compress", dest="compress", action="store_false", help="强制关闭压缩") + parser.set_defaults(compress=None) + parser.add_argument("--webp", dest="webp", action="store_true", help="强制转为 webp") + parser.add_argument("--no-webp", dest="webp", action="store_false", help="强制关闭 webp") + parser.set_defaults(webp=None) + parser.add_argument("--test-toast-success", action="store_true", help="只显示成功 toast,不上传") + parser.add_argument("--test-toast-fail", action="store_true", help="只显示失败 toast,不上传") + return parser.parse_args(list(argv)) + + +def make_options(args: argparse.Namespace) -> UploadOptions: + home_config = Path.home() / ".config" / "superbed-upload.env" + token = ( + args.token + or os.environ.get("SUPERBED_TOKEN") + or read_env_value([home_config], "SUPERBED_TOKEN") + or "" + ).strip() + if not token and not (args.test_toast_success or args.test_toast_fail): + raise UploadError(f"未提供 token。请设置 {home_config}、环境变量 SUPERBED_TOKEN,或用 --token 传入。") + + test_toast = None + if args.test_toast_success: + test_toast = "success" + elif args.test_toast_fail: + test_toast = "fail" + + return UploadOptions( + token=token, + categories=args.categories, + filename=args.filename, + timeout=args.timeout, + markdown_template=args.markdown_template, + watermark=args.watermark, + compress=args.compress, + webp=args.webp, + test_toast=test_toast, + ) + + +def main(argv: Iterable[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else list(argv) + args = parse_args(argv) + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + install_qt_message_filter() + + app = QApplication([sys.argv[0], *argv]) + app.setQuitOnLastWindowClosed(False) + + try: + options = make_options(args) + except UploadError as exc: + print(str(exc), file=sys.stderr, flush=True) + holder: dict[str, ToastWindow] = {} + + def show_error_toast() -> None: + toast = ToastWindow(f"图片上传失败,原因:{exc}", False) + holder["toast"] = toast + toast.done.connect(app.quit) + toast.showAnimated() + + QTimer.singleShot(0, show_error_toast) + return app.exec() + + controller = UploadController(app, options) + QTimer.singleShot(60, controller.start) + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main())