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 ...@@ -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,65 +217,148 @@ class ImageGenBridge(QObject): ...@@ -215,65 +217,148 @@ 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
227 available_formats = list(mime_data.formats())
228 self._logger.info(f"[clipboard] 可用 MIME: {available_formats}")
229 232
230 # 路径 B:mimeData.data(mime) raw bytes → QImage.loadFromData 233 # 路径 B:mimeData.data(mime) raw bytes → QImage.loadFromData
231 # 顺序:常见图片格式 → application/x-qt-image (Qt 内部 PNG-encoded) 234 # 这条只读字节,不触发 Qt 自动 image 反序列化,macOS 也安全
232 for mime_type in ("image/png", "image/jpeg", "image/bmp", 235 if mime_data is not None:
233 "image/tiff", "application/x-qt-image"): 236 available_formats = list(mime_data.formats())
234 if mime_type in available_formats: 237 self._logger.info(f"[clipboard] 可用 MIME: {available_formats}")
235 try: 238
236 raw = mime_data.data(mime_type) 239 # macOS 上跳过 application/x-qt-image(Qt 内部格式,反序列化可能崩)
237 if raw and len(raw) > 0: 240 safe_mimes = ["image/png", "image/jpeg", "image/bmp", "image/tiff"]
238 img = QImage() 241 if not is_mac:
239 if img.loadFromData(raw): 242 safe_mimes.append("application/x-qt-image")
240 self._logger.info( 243
241 f"[clipboard] 从 {mime_type} 加载成功, " 244 for mime_type in safe_mimes:
242 f"{img.width()}x{img.height()}, {len(raw)} bytes" 245 if mime_type in available_formats:
243 ) 246 try:
244 return img.copy() 247 raw = mime_data.data(mime_type)
245 else: 248 if raw and len(raw) > 0:
246 self._logger.warning( 249 img = QImage()
247 f"[clipboard] {mime_type} loadFromData 失败" 250 if img.loadFromData(raw):
248 f" ({len(raw)} bytes)" 251 self._logger.info(
249 ) 252 f"[clipboard] 从 {mime_type} 加载成功, "
250 except Exception: 253 f"{img.width()}x{img.height()}, {len(raw)} bytes"
251 self._logger.exception(f"[clipboard] 读 {mime_type} 异常") 254 )
252 255 return img.copy()
253 # 路径 C1:mimeData.hasImage() / imageData() 256 else:
254 try: 257 self._logger.warning(
255 if mime_data.hasImage(): 258 f"[clipboard] {mime_type} loadFromData 失败"
256 image_data = mime_data.imageData() 259 f" ({len(raw)} bytes)"
257 if isinstance(image_data, QImage) and not image_data.isNull(): 260 )
258 self._logger.info( 261 except Exception:
259 f"[clipboard] imageData() 成功, " 262 self._logger.exception(f"[clipboard] 读 {mime_type} 异常")
260 f"{image_data.width()}x{image_data.height()}" 263
261 ) 264 # macOS 优先走 osascript(路径 B 拿不到时唯一安全选项)
262 return image_data.copy() 265 if is_mac:
263 except Exception: 266 img = self._extract_via_osascript()
264 self._logger.exception("[clipboard] imageData() 异常") 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:
273 try:
274 if mime_data.hasImage():
275 image_data = mime_data.imageData()
276 if isinstance(image_data, QImage) and not image_data.isNull():
277 self._logger.info(
278 f"[clipboard] imageData() 成功, "
279 f"{image_data.width()}x{image_data.height()}"
280 )
281 return image_data.copy()
282 except Exception:
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。"""
......