#!/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())