feat: add PySide6 clipboard uploader with Nuitka release tooling

This commit is contained in:
Logic
2026-03-25 09:52:20 +08:00
commit c34af53878
11 changed files with 1262 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
SUPERBED_TOKEN=your_token_here

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
*.pyc
*.pyo
*.qml
superbed-upload.local.env
.venv/
dist/
build/
*.bin
*.build
*.dist
.cache/
nuitka-crash-report.xml

111
README.md Normal file
View File

@@ -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` 会优先调用这个二进制。

23
build-nuitka.sh Executable file
View File

@@ -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"

2
requirements-build.txt Normal file
View File

@@ -0,0 +1,2 @@
nuitka>=4.0
zstandard>=0.23

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
PySide6>=6.11,<7
requests>=2.32,<3

19
setup-venv.sh Executable file
View File

@@ -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"

22
superbed-upload.sh Executable file
View File

@@ -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

5
superbed-upload.sh.example Executable file
View File

@@ -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" "$@"

View File

@@ -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:]))

713
superbed_qt_uploader.py Normal file
View File

@@ -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())