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>
Showing
1 changed file
with
99 additions
and
14 deletions
| ... | @@ -6,7 +6,9 @@ QML 调 imageGen.submitTask(prompt, refs, aspect, size, mode) → 桥层把 mode | ... | @@ -6,7 +6,9 @@ QML 调 imageGen.submitTask(prompt, refs, aspect, size, mode) → 桥层把 mode |
| 6 | 然后把 result_path 通过 taskCompleted 信号转出(QML 只拿到文件路径,不传 bytes)。 | 6 | 然后把 result_path 通过 taskCompleted 信号转出(QML 只拿到文件路径,不传 bytes)。 |
| 7 | """ | 7 | """ |
| 8 | import logging | 8 | import logging |
| 9 | import platform | ||
| 9 | import shutil | 10 | import shutil |
| 11 | import subprocess | ||
| 10 | import tempfile | 12 | import tempfile |
| 11 | import time | 13 | import time |
| 12 | import uuid | 14 | import uuid |
| ... | @@ -215,22 +217,31 @@ class ImageGenBridge(QObject): | ... | @@ -215,22 +217,31 @@ class ImageGenBridge(QObject): |
| 215 | return [path.as_posix()] | 217 | return [path.as_posix()] |
| 216 | 218 | ||
| 217 | def _extract_clipboard_image(self, clipboard) -> Optional[QImage]: | 219 | def _extract_clipboard_image(self, clipboard) -> Optional[QImage]: |
| 218 | """从剪贴板拿 QImage,多路径 fallback(路径 B + C)。""" | 220 | """从剪贴板拿 QImage,多路径 fallback(路径 B + C)。 |
| 221 | |||
| 222 | macOS 26+ (Darwin 25+) 注意:Qt 的 application/x-qt-image 反序列化 / | ||
| 223 | mimeData.imageData() / clipboard.image() 会触发 NSPasteboard→NSImage | ||
| 224 | 转换,部分场景下 **native crash**(Python 层捕获不到)。所以 macOS 上: | ||
| 225 | - application/x-qt-image 跳过(即便它在 mime 列表里) | ||
| 226 | - mimeData.imageData() 跳过 | ||
| 227 | - clipboard.image() 跳过 | ||
| 228 | - 改走 osascript 让系统把剪贴板 PNG 数据写到 temp 文件,再 QImage 读盘 | ||
| 229 | """ | ||
| 230 | is_mac = platform.system() == "Darwin" | ||
| 219 | mime_data = clipboard.mimeData() | 231 | mime_data = clipboard.mimeData() |
| 220 | if mime_data is None: | ||
| 221 | try: | ||
| 222 | img = clipboard.image() | ||
| 223 | return img.copy() if img and not img.isNull() else None | ||
| 224 | except Exception: | ||
| 225 | return None | ||
| 226 | 232 | ||
| 233 | # 路径 B:mimeData.data(mime) raw bytes → QImage.loadFromData | ||
| 234 | # 这条只读字节,不触发 Qt 自动 image 反序列化,macOS 也安全 | ||
| 235 | if mime_data is not None: | ||
| 227 | available_formats = list(mime_data.formats()) | 236 | available_formats = list(mime_data.formats()) |
| 228 | self._logger.info(f"[clipboard] 可用 MIME: {available_formats}") | 237 | self._logger.info(f"[clipboard] 可用 MIME: {available_formats}") |
| 229 | 238 | ||
| 230 | # 路径 B:mimeData.data(mime) raw bytes → QImage.loadFromData | 239 | # macOS 上跳过 application/x-qt-image(Qt 内部格式,反序列化可能崩) |
| 231 | # 顺序:常见图片格式 → application/x-qt-image (Qt 内部 PNG-encoded) | 240 | safe_mimes = ["image/png", "image/jpeg", "image/bmp", "image/tiff"] |
| 232 | for mime_type in ("image/png", "image/jpeg", "image/bmp", | 241 | if not is_mac: |
| 233 | "image/tiff", "application/x-qt-image"): | 242 | safe_mimes.append("application/x-qt-image") |
| 243 | |||
| 244 | for mime_type in safe_mimes: | ||
| 234 | if mime_type in available_formats: | 245 | if mime_type in available_formats: |
| 235 | try: | 246 | try: |
| 236 | raw = mime_data.data(mime_type) | 247 | raw = mime_data.data(mime_type) |
| ... | @@ -250,7 +261,15 @@ class ImageGenBridge(QObject): | ... | @@ -250,7 +261,15 @@ class ImageGenBridge(QObject): |
| 250 | except Exception: | 261 | except Exception: |
| 251 | self._logger.exception(f"[clipboard] 读 {mime_type} 异常") | 262 | self._logger.exception(f"[clipboard] 读 {mime_type} 异常") |
| 252 | 263 | ||
| 253 | # 路径 C1:mimeData.hasImage() / imageData() | 264 | # macOS 优先走 osascript(路径 B 拿不到时唯一安全选项) |
| 265 | if is_mac: | ||
| 266 | img = self._extract_via_osascript() | ||
| 267 | if img is not None: | ||
| 268 | return img | ||
| 269 | return None # macOS 不再走有风险的 Qt API | ||
| 270 | |||
| 271 | # 路径 C1:mimeData.hasImage() / imageData()(仅非 macOS) | ||
| 272 | if mime_data is not None: | ||
| 254 | try: | 273 | try: |
| 255 | if mime_data.hasImage(): | 274 | if mime_data.hasImage(): |
| 256 | image_data = mime_data.imageData() | 275 | image_data = mime_data.imageData() |
| ... | @@ -263,17 +282,83 @@ class ImageGenBridge(QObject): | ... | @@ -263,17 +282,83 @@ class ImageGenBridge(QObject): |
| 263 | except Exception: | 282 | except Exception: |
| 264 | self._logger.exception("[clipboard] imageData() 异常") | 283 | self._logger.exception("[clipboard] imageData() 异常") |
| 265 | 284 | ||
| 266 | # 路径 C2:clipboard.image() 兜底 | 285 | # 路径 C2:clipboard.image() 兜底(仅非 macOS) |
| 267 | try: | 286 | try: |
| 268 | img = clipboard.image() | 287 | img = clipboard.image() |
| 269 | if img and not img.isNull(): | 288 | if img and not img.isNull(): |
| 270 | self._logger.info(f"[clipboard] clipboard.image() 成功") | 289 | self._logger.info("[clipboard] clipboard.image() 成功") |
| 271 | return img.copy() | 290 | return img.copy() |
| 272 | except Exception: | 291 | except Exception: |
| 273 | self._logger.exception("[clipboard] clipboard.image() 异常") | 292 | self._logger.exception("[clipboard] clipboard.image() 异常") |
| 274 | 293 | ||
| 275 | return None | 294 | return None |
| 276 | 295 | ||
| 296 | def _extract_via_osascript(self) -> Optional[QImage]: | ||
| 297 | """macOS 专用:用 AppleScript 把剪贴板 PNG 数据写到 temp 文件,再 QImage 读盘。 | ||
| 298 | |||
| 299 | 绕开 Qt clipboard API 的 NSPasteboard→NSImage 转换风险路径。 | ||
| 300 | 失败返回 None(用户复制的可能不是图片,或 osascript 报错)。 | ||
| 301 | """ | ||
| 302 | try: | ||
| 303 | tmp_dir = Path(tempfile.gettempdir()) / "nano_banana_app" | ||
| 304 | tmp_dir.mkdir(parents=True, exist_ok=True) | ||
| 305 | tmp_path = tmp_dir / "_clipboard_tmp.png" | ||
| 306 | if tmp_path.exists(): | ||
| 307 | try: | ||
| 308 | tmp_path.unlink() | ||
| 309 | except Exception: | ||
| 310 | pass | ||
| 311 | |||
| 312 | # AppleScript:把剪贴板里的 PNG 数据写到 tmp 文件 | ||
| 313 | # «class PNGf» 是 macOS 内置的 PNG 数据类型 | ||
| 314 | script = ( | ||
| 315 | 'set theFile to POSIX file "' + str(tmp_path) + '"\n' | ||
| 316 | 'try\n' | ||
| 317 | ' set imgData to the clipboard as «class PNGf»\n' | ||
| 318 | ' set fp to open for access theFile with write permission\n' | ||
| 319 | ' write imgData to fp\n' | ||
| 320 | ' close access fp\n' | ||
| 321 | 'on error\n' | ||
| 322 | ' try\n' | ||
| 323 | ' close access theFile\n' | ||
| 324 | ' end try\n' | ||
| 325 | ' error "no image"\n' | ||
| 326 | 'end try\n' | ||
| 327 | ) | ||
| 328 | result = subprocess.run( | ||
| 329 | ["osascript", "-e", script], | ||
| 330 | capture_output=True, timeout=5, | ||
| 331 | ) | ||
| 332 | if result.returncode != 0: | ||
| 333 | # 用户复制的不是图片 / 没图,正常情况 | ||
| 334 | stderr = result.stderr.decode("utf-8", errors="replace")[:200] | ||
| 335 | self._logger.info(f"[clipboard][osascript] 无图: {stderr}") | ||
| 336 | return None | ||
| 337 | if not tmp_path.exists() or tmp_path.stat().st_size == 0: | ||
| 338 | return None | ||
| 339 | |||
| 340 | img = QImage(str(tmp_path)) | ||
| 341 | if img.isNull(): | ||
| 342 | self._logger.warning(f"[clipboard][osascript] 写盘但 QImage 加载失败: {tmp_path}") | ||
| 343 | return None | ||
| 344 | |||
| 345 | self._logger.info( | ||
| 346 | f"[clipboard][osascript] 成功 {img.width()}x{img.height()} " | ||
| 347 | f"({tmp_path.stat().st_size} bytes)" | ||
| 348 | ) | ||
| 349 | return img.copy() | ||
| 350 | |||
| 351 | except subprocess.TimeoutExpired: | ||
| 352 | self._logger.warning("[clipboard][osascript] 超时 5s") | ||
| 353 | return None | ||
| 354 | except FileNotFoundError: | ||
| 355 | # osascript 找不到(理论不应发生在 macOS 上) | ||
| 356 | self._logger.warning("[clipboard][osascript] osascript 命令不存在") | ||
| 357 | return None | ||
| 358 | except Exception: | ||
| 359 | self._logger.exception("[clipboard][osascript] 异常") | ||
| 360 | return None | ||
| 361 | |||
| 277 | @Slot(str, str, result=bool) | 362 | @Slot(str, str, result=bool) |
| 278 | def saveFile(self, src: str, dest: str) -> bool: | 363 | def saveFile(self, src: str, dest: str) -> bool: |
| 279 | """复制 src 到 dest(用户从下载对话框选择目标路径)。失败返回 False。""" | 364 | """复制 src 到 dest(用户从下载对话框选择目标路径)。失败返回 False。""" | ... | ... |
-
Please register or sign in to post a comment