a442e36d by 柴进

fix(clipboard): macOS 26+ 用 osascript 绕开 Qt clipboard native crash

补 _safe_get_clipboard_image 老代码的 macOS 分支:
  - macOS 上 mimeData.imageData() / clipboard.image() / application/x-qt-image
    反序列化会触发 NSPasteboard→NSImage 转换,部分场景下直接 native crash,
    Python 层捕获不到(只看到应用闪退)
  - 解决:macOS 上跳过这些路径,改用 osascript 让系统把剪贴板 PNG 数据
    写到 temp 文件,再 QImage(path) 读盘 — 完全绕开 Qt clipboard API

ImageGenBridge._extract_clipboard_image 改造:
  - 路径 B raw bytes:macOS 上 mime 候选不含 application/x-qt-image
  - macOS 上路径 B 拿不到 → 直接 osascript(不再走 C1/C2)
  - 非 macOS 不变:B raw → C1 imageData → C2 clipboard.image

新增 _extract_via_osascript:
  - AppleScript 「set imgData to the clipboard as «class PNGf»」
  - subprocess.run(['osascript', '-e', script], timeout=5)
  - 写到 {tempdir}/nano_banana_app/_clipboard_tmp.png 再 QImage 读盘
  - 无图 / 超时 / osascript 不存在等失败场景都返回 None 不抛

Windows / Linux 行为完全不变(is_mac=False 走原 3 层 fallback)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent fc3cf071
......@@ -6,7 +6,9 @@ QML 调 imageGen.submitTask(prompt, refs, aspect, size, mode) → 桥层把 mode
然后把 result_path 通过 taskCompleted 信号转出(QML 只拿到文件路径,不传 bytes)。
"""
import logging
import platform
import shutil
import subprocess
import tempfile
import time
import uuid
......@@ -215,22 +217,31 @@ class ImageGenBridge(QObject):
return [path.as_posix()]
def _extract_clipboard_image(self, clipboard) -> Optional[QImage]:
"""从剪贴板拿 QImage,多路径 fallback(路径 B + C)。"""
"""从剪贴板拿 QImage,多路径 fallback(路径 B + C)。
macOS 26+ (Darwin 25+) 注意:Qt 的 application/x-qt-image 反序列化 /
mimeData.imageData() / clipboard.image() 会触发 NSPasteboard→NSImage
转换,部分场景下 **native crash**(Python 层捕获不到)。所以 macOS 上:
- application/x-qt-image 跳过(即便它在 mime 列表里)
- mimeData.imageData() 跳过
- clipboard.image() 跳过
- 改走 osascript 让系统把剪贴板 PNG 数据写到 temp 文件,再 QImage 读盘
"""
is_mac = platform.system() == "Darwin"
mime_data = clipboard.mimeData()
if mime_data is None:
try:
img = clipboard.image()
return img.copy() if img and not img.isNull() else None
except Exception:
return None
# 路径 B:mimeData.data(mime) raw bytes → QImage.loadFromData
# 这条只读字节,不触发 Qt 自动 image 反序列化,macOS 也安全
if mime_data is not None:
available_formats = list(mime_data.formats())
self._logger.info(f"[clipboard] 可用 MIME: {available_formats}")
# 路径 B:mimeData.data(mime) raw bytes → QImage.loadFromData
# 顺序:常见图片格式 → application/x-qt-image (Qt 内部 PNG-encoded)
for mime_type in ("image/png", "image/jpeg", "image/bmp",
"image/tiff", "application/x-qt-image"):
# macOS 上跳过 application/x-qt-image(Qt 内部格式,反序列化可能崩)
safe_mimes = ["image/png", "image/jpeg", "image/bmp", "image/tiff"]
if not is_mac:
safe_mimes.append("application/x-qt-image")
for mime_type in safe_mimes:
if mime_type in available_formats:
try:
raw = mime_data.data(mime_type)
......@@ -250,7 +261,15 @@ class ImageGenBridge(QObject):
except Exception:
self._logger.exception(f"[clipboard] 读 {mime_type} 异常")
# 路径 C1:mimeData.hasImage() / imageData()
# macOS 优先走 osascript(路径 B 拿不到时唯一安全选项)
if is_mac:
img = self._extract_via_osascript()
if img is not None:
return img
return None # macOS 不再走有风险的 Qt API
# 路径 C1:mimeData.hasImage() / imageData()(仅非 macOS)
if mime_data is not None:
try:
if mime_data.hasImage():
image_data = mime_data.imageData()
......@@ -263,17 +282,83 @@ class ImageGenBridge(QObject):
except Exception:
self._logger.exception("[clipboard] imageData() 异常")
# 路径 C2:clipboard.image() 兜底
# 路径 C2:clipboard.image() 兜底(仅非 macOS)
try:
img = clipboard.image()
if img and not img.isNull():
self._logger.info(f"[clipboard] clipboard.image() 成功")
self._logger.info("[clipboard] clipboard.image() 成功")
return img.copy()
except Exception:
self._logger.exception("[clipboard] clipboard.image() 异常")
return None
def _extract_via_osascript(self) -> Optional[QImage]:
"""macOS 专用:用 AppleScript 把剪贴板 PNG 数据写到 temp 文件,再 QImage 读盘。
绕开 Qt clipboard API 的 NSPasteboard→NSImage 转换风险路径。
失败返回 None(用户复制的可能不是图片,或 osascript 报错)。
"""
try:
tmp_dir = Path(tempfile.gettempdir()) / "nano_banana_app"
tmp_dir.mkdir(parents=True, exist_ok=True)
tmp_path = tmp_dir / "_clipboard_tmp.png"
if tmp_path.exists():
try:
tmp_path.unlink()
except Exception:
pass
# AppleScript:把剪贴板里的 PNG 数据写到 tmp 文件
# «class PNGf» 是 macOS 内置的 PNG 数据类型
script = (
'set theFile to POSIX file "' + str(tmp_path) + '"\n'
'try\n'
' set imgData to the clipboard as «class PNGf»\n'
' set fp to open for access theFile with write permission\n'
' write imgData to fp\n'
' close access fp\n'
'on error\n'
' try\n'
' close access theFile\n'
' end try\n'
' error "no image"\n'
'end try\n'
)
result = subprocess.run(
["osascript", "-e", script],
capture_output=True, timeout=5,
)
if result.returncode != 0:
# 用户复制的不是图片 / 没图,正常情况
stderr = result.stderr.decode("utf-8", errors="replace")[:200]
self._logger.info(f"[clipboard][osascript] 无图: {stderr}")
return None
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
return None
img = QImage(str(tmp_path))
if img.isNull():
self._logger.warning(f"[clipboard][osascript] 写盘但 QImage 加载失败: {tmp_path}")
return None
self._logger.info(
f"[clipboard][osascript] 成功 {img.width()}x{img.height()} "
f"({tmp_path.stat().st_size} bytes)"
)
return img.copy()
except subprocess.TimeoutExpired:
self._logger.warning("[clipboard][osascript] 超时 5s")
return None
except FileNotFoundError:
# osascript 找不到(理论不应发生在 macOS 上)
self._logger.warning("[clipboard][osascript] osascript 命令不存在")
return None
except Exception:
self._logger.exception("[clipboard][osascript] 异常")
return None
@Slot(str, str, result=bool)
def saveFile(self, src: str, dest: str) -> bool:
"""复制 src 到 dest(用户从下载对话框选择目标路径)。失败返回 False。"""
......