From bce11417c7dfffb24cfbb1af83471166b41eb353 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 19 Aug 2026 12:29:31 +0800 Subject: [PATCH 1/7] fix(mobile): restore native share webview fallback Signed-off-by: David --- apps/mobile/app/sessions/[sessionId].tsx | 74 ++++++++++++++++++- .../conversationShareWebViewHtml.test.ts | 4 + 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index cb65aaee05..ad38786eba 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -34,6 +34,7 @@ import { } from 'expo-audio'; import { addScreenshotListener, + renderConversationShareHtmlToPng, } from 'xdt-screenshot-monitor'; import { useFocusEffect, useLocalSearchParams, useNavigation, useRouter } from 'expo-router'; import { forwardRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject, type SetStateAction } from 'react'; @@ -100,6 +101,7 @@ import { type ShareableMessageViewport, } from '@/session/MessageRenderer'; import { + bundledAssetToDataUri, deleteConversationSharePngTemp, writeConversationSharePngTemp, } from '@/session/ConversationShareWebView'; @@ -108,6 +110,7 @@ import { type ConversationShareSvgHandle, } from '@/session/ConversationShareSvg'; import { + buildConversationShareHtml, type ConversationShareMessage, type ConversationShareWebViewColors, } from '@/session/conversationShareWebViewHtml'; @@ -608,6 +611,14 @@ const REOPEN_MESSAGE_WINDOW_LIMITS = [20, 10, 5, 1] as const; const TAIL_RETRY_HIDE_TIMEOUT_MS = 15_000; const SCREENSHOT_SHARE_ACTIVATION_DEBOUNCE_MS = 1_200; +// 原生 WKWebView 只能稳定读取 data URI;SVG 兜底直接使用同一组 bundle asset。 +// eslint-disable-next-line @typescript-eslint/no-require-imports +const shareCharacterAsset = require('../../assets/share/cindy-share-character.jpg'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const shareLogoLightAsset = require('../../assets/login/login-wordmark.png'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const shareLogoDarkAsset = require('../../assets/login/login-wordmark-dark.png'); + /** * 排队消息「复用 composer 编辑」的会话内状态:clientId 定位队列条目, * stashed* 暂存进入编辑前用户的草稿与附件托盘(保存/放弃/条目消失时恢复)。 @@ -1001,6 +1012,9 @@ export default function SessionScreen() { const shareOperationSeqRef = useRef(0); const [conversationShareBusy, setConversationShareBusy] = useState(false); const [shareSelectionTriggeredByScreenshot, setShareSelectionTriggeredByScreenshot] = useState(false); + const [shareCharacterSrc, setShareCharacterSrc] = useState(null); + const [shareLogoSrc, setShareLogoSrc] = useState(null); + const shareLogoModeRef = useRef(null); // chat-text-quote:待随下一条消息发送的选中文字引用(全局 store,消息流选区 // 按钮 / 文件预览页写入;发送时拼进正文,命中本地命令时保留)。 const quotes = useSessionQuotes(sessionId); @@ -1080,6 +1094,28 @@ export default function SessionScreen() { }; }, [sessionId]), ); + useEffect(() => { + if (!shareSelectionActive) return undefined; + let cancelled = false; + const logoNeedsLoad = shareLogoModeRef.current !== mode || !shareLogoSrc; + void Promise.all([ + shareCharacterSrc + ? Promise.resolve(shareCharacterSrc) + : bundledAssetToDataUri(shareCharacterAsset, 'image/jpeg'), + logoNeedsLoad + ? bundledAssetToDataUri( + mode === 'dark' ? shareLogoDarkAsset : shareLogoLightAsset, + 'image/png', + ) + : Promise.resolve(shareLogoSrc), + ]).then(([character, logo]) => { + if (cancelled) return; + shareLogoModeRef.current = mode; + setShareCharacterSrc(character); + setShareLogoSrc(logo); + }); + return () => { cancelled = true; }; + }, [mode, shareCharacterSrc, shareLogoSrc, shareSelectionActive]); const [composerFocused, setComposerFocused] = useState(false); const [composerInputContentHeight, setComposerInputContentHeight] = useState(COMPOSER_INPUT_SINGLE_LINE_CONTENT_HEIGHT); const [voiceDraftCaretFrame, setVoiceDraftCaretFrame] = useState({ left: 0, top: 0 }); @@ -6000,6 +6036,26 @@ export default function SessionScreen() { textTertiary: colors.textTertiary, dark: mode === 'dark', }), [colors, mode]); + const conversationShareHtml = useMemo(() => { + if (!shareSelectionActive || selectedShareMessages.length === 0) return ''; + return buildConversationShareHtml({ + allShareableIds, + characterSrc: shareCharacterSrc ?? undefined, + colors: conversationShareColors, + contentWidth: windowDimensions.width, + logoSrc: shareLogoModeRef.current === mode ? shareLogoSrc ?? undefined : undefined, + selectedMessages: selectedShareMessages, + }); + }, [ + allShareableIds, + conversationShareColors, + mode, + selectedShareMessages, + shareCharacterSrc, + shareLogoSrc, + shareSelectionActive, + windowDimensions.width, + ]); const enterShareSelection = useCallback((clientId: string) => { Keyboard.dismiss(); setShareSelectionTriggeredByScreenshot(false); @@ -6012,10 +6068,26 @@ export default function SessionScreen() { shareSelectionStore.exit(); }, []); const exportConversationSharePng = useCallback(async () => { + const nativeShareAssetsReady = Boolean( + shareCharacterSrc + && shareLogoSrc + && shareLogoModeRef.current === mode, + ); + if (conversationShareHtml && nativeShareAssetsReady) { + try { + const nativeBase64 = await renderConversationShareHtmlToPng({ + html: conversationShareHtml, + width: windowDimensions.width, + }); + if (nativeBase64) return nativeBase64; + } catch (error) { + console.warn('[conversation-share] native webview export failed; falling back to svg', error); + } + } const svg = conversationShareSvgRef.current; if (!svg) throw new Error('conversation share svg renderer is unavailable'); return svg.exportPng(); - }, []); + }, [conversationShareHtml, mode, shareCharacterSrc, shareLogoSrc, windowDimensions.width]); const shareSelectedConversation = useCallback(async () => { if ( conversationShareBusy diff --git a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts index 3e654e8ce9..9724d45a42 100644 --- a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts +++ b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts @@ -176,6 +176,10 @@ describe('buildConversationShareHtml 富内容导出', () => { ); expect(sessionSource).toContain("localUri && Platform.OS !== 'android'"); expect(sessionSource).toContain(' { From fdb66a991d4568765d5cdb0d1ed204d99483bb15 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 19 Aug 2026 12:42:33 +0800 Subject: [PATCH 2/7] fix(mobile): enable native and OTA share webviews Signed-off-by: David --- apps/mobile/app/sessions/[sessionId].tsx | 18 ++++++++++ .../ios/XdtScreenshotMonitorModule.swift | 35 ++++++++++++++++++- .../conversationShareWebViewHtml.test.ts | 6 ++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index ad38786eba..2dc6b569fe 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -102,8 +102,10 @@ import { } from '@/session/MessageRenderer'; import { bundledAssetToDataUri, + ConversationShareWebView, deleteConversationSharePngTemp, writeConversationSharePngTemp, + type ConversationShareWebViewHandle, } from '@/session/ConversationShareWebView'; import { ConversationShareSvg, @@ -997,6 +999,7 @@ export default function SessionScreen() { draftRef.current = nextDraft; } const conversationShareSvgRef = useRef(null); + const conversationShareWebViewRef = useRef(null); const topOverlayRef = useRef(null); const bottomOverlayRef = useRef(null); const visibleShareableMessageIdsReaderRef = useRef<( @@ -6084,6 +6087,14 @@ export default function SessionScreen() { console.warn('[conversation-share] native webview export failed; falling back to svg', error); } } + const webView = conversationShareWebViewRef.current; + if (webView) { + try { + return await webView.exportPng(); + } catch (error) { + console.warn('[conversation-share] OTA webview export failed; falling back to svg', error); + } + } const svg = conversationShareSvgRef.current; if (!svg) throw new Error('conversation share svg renderer is unavailable'); return svg.exportPng(); @@ -9661,6 +9672,13 @@ export default function SessionScreen() { + {shareSelectionActive && selectedShareMessages.length > 0 ? ( + + ) : null} {shareSelectionActive && selectedShareMessages.length > 0 ? ( { ); expect(nativeSource).toContain('conversationShareMaxSourcePixels'); + expect(nativeSource).toContain('UIWindow(windowScene: windowScene)'); + expect(nativeSource).toContain('hostingWindow.rootViewController = viewController'); + expect(nativeSource).toContain('hostingWindow?.isHidden = true'); + expect(nativeSource).toContain('no active window scene'); expect(nativeSource).toContain( 'captureWidth * captureHeight <= conversationShareMaxSourcePixels', ); @@ -179,6 +183,8 @@ describe('buildConversationShareHtml 富内容导出', () => { expect(sessionSource).toContain('renderConversationShareHtmlToPng({'); expect(sessionSource).toContain('nativeShareAssetsReady'); expect(sessionSource).toContain('falling back to svg'); + expect(sessionSource).toContain('OTA webview export failed; falling back to svg'); + expect(sessionSource).toContain(' Date: Wed, 19 Aug 2026 12:50:21 +0800 Subject: [PATCH 3/7] fix(mobile): remove unsupported OTA webview fallback Signed-off-by: David --- apps/mobile/app/sessions/[sessionId].tsx | 18 ------------------ .../conversationShareWebViewHtml.test.ts | 3 +-- 2 files changed, 1 insertion(+), 20 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 2dc6b569fe..ad38786eba 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -102,10 +102,8 @@ import { } from '@/session/MessageRenderer'; import { bundledAssetToDataUri, - ConversationShareWebView, deleteConversationSharePngTemp, writeConversationSharePngTemp, - type ConversationShareWebViewHandle, } from '@/session/ConversationShareWebView'; import { ConversationShareSvg, @@ -999,7 +997,6 @@ export default function SessionScreen() { draftRef.current = nextDraft; } const conversationShareSvgRef = useRef(null); - const conversationShareWebViewRef = useRef(null); const topOverlayRef = useRef(null); const bottomOverlayRef = useRef(null); const visibleShareableMessageIdsReaderRef = useRef<( @@ -6087,14 +6084,6 @@ export default function SessionScreen() { console.warn('[conversation-share] native webview export failed; falling back to svg', error); } } - const webView = conversationShareWebViewRef.current; - if (webView) { - try { - return await webView.exportPng(); - } catch (error) { - console.warn('[conversation-share] OTA webview export failed; falling back to svg', error); - } - } const svg = conversationShareSvgRef.current; if (!svg) throw new Error('conversation share svg renderer is unavailable'); return svg.exportPng(); @@ -9672,13 +9661,6 @@ export default function SessionScreen() { - {shareSelectionActive && selectedShareMessages.length > 0 ? ( - - ) : null} {shareSelectionActive && selectedShareMessages.length > 0 ? ( { expect(sessionSource).toContain('renderConversationShareHtmlToPng({'); expect(sessionSource).toContain('nativeShareAssetsReady'); expect(sessionSource).toContain('falling back to svg'); - expect(sessionSource).toContain('OTA webview export failed; falling back to svg'); - expect(sessionSource).toContain(' Date: Wed, 19 Aug 2026 18:15:25 +0800 Subject: [PATCH 4/7] fix(mobile): stabilize native webview share export Signed-off-by: David --- apps/mobile/app/sessions/[sessionId].tsx | 11 +- .../ios/XdtScreenshotMonitorModule.swift | 218 ++++++++++++++++-- .../conversationShareWebViewHtml.test.ts | 13 +- 3 files changed, 222 insertions(+), 20 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index ad38786eba..ea7444fd7f 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -102,7 +102,6 @@ import { } from '@/session/MessageRenderer'; import { bundledAssetToDataUri, - deleteConversationSharePngTemp, writeConversationSharePngTemp, } from '@/session/ConversationShareWebView'; import { @@ -6079,7 +6078,10 @@ export default function SessionScreen() { html: conversationShareHtml, width: windowDimensions.width, }); - if (nativeBase64) return nativeBase64; + if (nativeBase64) { + console.info('[conversation-share] native webview export succeeded'); + return nativeBase64; + } } catch (error) { console.warn('[conversation-share] native webview export failed; falling back to svg', error); } @@ -6121,9 +6123,8 @@ export default function SessionScreen() { console.warn('[conversation-share] failed to generate or open share image', error); Alert.alert(t('session.screen.shareFailedTitle'), t('session.screen.shareImageFailed')); } finally { - if (localUri && Platform.OS !== 'android') { - await deleteConversationSharePngTemp(localUri); - } + // shareAsync 返回后,iOS 分享扩展仍可能继续读取该 URL。成功写入的文件留在 + // cache 目录交给系统回收;写入失败的半成品由 writeConversationSharePngTemp 清理。 if (shareOperationSeqRef.current === operationSeq) setConversationShareBusy(false); } }, [conversationShareBusy, exportConversationSharePng, selectedShareMessages.length, shareSelectionActive, shareSelectionRevision, t]); diff --git a/apps/mobile/modules/xdt-screenshot-monitor/ios/XdtScreenshotMonitorModule.swift b/apps/mobile/modules/xdt-screenshot-monitor/ios/XdtScreenshotMonitorModule.swift index bb34fb6dec..bca472c7a8 100644 --- a/apps/mobile/modules/xdt-screenshot-monitor/ios/XdtScreenshotMonitorModule.swift +++ b/apps/mobile/modules/xdt-screenshot-monitor/ios/XdtScreenshotMonitorModule.swift @@ -6,6 +6,7 @@ private let onScreenshot = "onScreenshot" private let conversationShareRenderTimeout: TimeInterval = 20 private let conversationShareMaxOutputPixels: CGFloat = 12_000_000 private let conversationShareMaxSourcePixels: CGFloat = 12_000_000 +private let conversationShareViewportHeight: CGFloat = 760 public class XdtScreenshotMonitorModule: Module { private var screenshotObserver: NSObjectProtocol? @@ -112,18 +113,26 @@ private final class ConversationShareHtmlRenderer: NSObject, WKNavigationDelegat let configuration = WKWebViewConfiguration() configuration.websiteDataStore = .nonPersistent() - let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: width, height: 1), configuration: configuration) + let viewportHeight = min(conversationShareViewportHeight, max(1, width * 2)) + let webView = WKWebView( + frame: CGRect(x: 0, y: 0, width: width, height: viewportHeight), + configuration: configuration + ) webView.isOpaque = false - webView.scrollView.isScrollEnabled = false + webView.scrollView.isScrollEnabled = true + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.showsHorizontalScrollIndicator = false webView.backgroundColor = .clear // WKWebView 的离屏 snapshot 仍需要挂在可见的 UIKit window 层级中。 // 不把它挂到业务页面,避免导出期间改变用户当前页面的布局或焦点。 let hostingWindow = UIWindow(windowScene: windowScene) - hostingWindow.frame = windowScene.coordinateSpace.bounds - hostingWindow.windowLevel = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue - 1) + hostingWindow.frame = CGRect(x: 0, y: 0, width: width, height: viewportHeight) + // 放到主窗口上方才能让 WebKit 进入可合成状态;极低透明度避免导出时闪屏。 + // takeSnapshot 直接读取 WKWebView 内容,不会继承 hostingWindow 的透明度。 + hostingWindow.windowLevel = UIWindow.Level(rawValue: UIWindow.Level.normal.rawValue + 1) hostingWindow.backgroundColor = .clear - hostingWindow.alpha = 1 + hostingWindow.alpha = 0.01 hostingWindow.isUserInteractionEnabled = false let viewController = UIViewController() viewController.view.backgroundColor = .clear @@ -215,29 +224,171 @@ private final class ConversationShareHtmlRenderer: NSObject, WKNavigationDelegat self.finish(.failure(ConversationShareRenderError("Conversation share content is too large."))) return } - webView.frame = CGRect(x: 0, y: 0, width: captureWidth, height: captureHeight) - self.hostingWindow?.rootViewController?.view.frame = self.hostingWindow?.bounds ?? .zero + guard captureWidth <= webView.bounds.width + 1 else { + self.finish(.failure(ConversationShareRenderError("Conversation share width changed unexpectedly."))) + return + } + let viewportHeight = webView.bounds.height webView.setNeedsLayout() webView.layoutIfNeeded() + self.hostingWindow?.rootViewController?.view.setNeedsLayout() + self.hostingWindow?.rootViewController?.view.layoutIfNeeded() let requestedScale = max(0.25, self.scale) let maxScale = sqrt( conversationShareMaxOutputPixels / max(1, captureWidth * captureHeight) ) let effectiveScale = min(requestedScale, maxScale) let snapshot = WKSnapshotConfiguration() - snapshot.rect = CGRect(x: 0, y: 0, width: captureWidth, height: captureHeight) + snapshot.rect = webView.bounds snapshot.snapshotWidth = NSNumber(value: Double(captureWidth * effectiveScale)) snapshot.afterScreenUpdates = true - webView.takeSnapshot(with: snapshot) { image, error in - if let error { + // 长页面不能用超出 WKWebView.bounds 的 rect 一次截图;按固定视口滚动分片, + // 每片的 rect 始终位于 bounds 内,再在原生侧拼接成完整 PNG。 + CATransaction.flush() + self.captureTiles( + webView: webView, + snapshot: snapshot, + contentWidth: captureWidth, + contentHeight: captureHeight, + viewportHeight: viewportHeight, + effectiveScale: effectiveScale + ) { [weak self] result in + guard let self else { return } + switch result { + case .success(let image): + guard let data = image.pngData(), !data.isEmpty else { + self.finish(.failure(ConversationShareRenderError("Conversation share PNG is empty."))) + return + } + self.finish(.success(data.base64EncodedString())) + case .failure(let error): self.finish(.failure(error)) + } + } + } + } + + private func captureTiles( + webView: WKWebView, + snapshot: WKSnapshotConfiguration, + contentWidth: CGFloat, + contentHeight: CGFloat, + viewportHeight: CGFloat, + effectiveScale: CGFloat, + completion: @escaping (Result) -> Void + ) { + let outputSize = CGSize( + width: contentWidth * effectiveScale, + height: contentHeight * effectiveScale + ) + guard outputSize.width > 0, outputSize.height > 0 else { + completion(.failure(ConversationShareRenderError("Conversation share output is empty."))) + return + } + let tileOffsets: [CGFloat] = { + if contentHeight <= viewportHeight { return [0] } + var offsets = stride(from: CGFloat(0), through: contentHeight - viewportHeight, by: viewportHeight).map { $0 } + let lastOffset = contentHeight - viewportHeight + if offsets.last != lastOffset { offsets.append(lastOffset) } + return offsets + }() + captureTile( + index: 0, + offsets: tileOffsets, + webView: webView, + snapshot: snapshot, + effectiveScale: effectiveScale, + outputSize: outputSize, + tiles: [], + completion: completion + ) + } + + private func captureTile( + index: Int, + offsets: [CGFloat], + webView: WKWebView, + snapshot: WKSnapshotConfiguration, + effectiveScale: CGFloat, + outputSize: CGSize, + tiles: [(offset: CGFloat, image: UIImage)], + completion: @escaping (Result) -> Void + ) { + guard !completed else { return } + guard !offsets.isEmpty else { + completion(.failure(ConversationShareRenderError("Conversation share produced no tiles."))) + return + } + guard index < offsets.count else { + let format = UIGraphicsImageRendererFormat() + // outputSize 已包含 effectiveScale;renderer 再采用屏幕 scale 会把像素数额外 + // 放大 4~9 倍,长图会在合成阶段超时或触发内存压力。 + format.scale = 1 + let renderer = UIGraphicsImageRenderer(size: outputSize, format: format) + let merged = renderer.image { _ in + for tile in tiles { + tile.image.draw(in: CGRect( + x: 0, + y: tile.offset * effectiveScale, + width: outputSize.width, + height: snapshot.rect.height * effectiveScale + )) + } + } + guard merged.hasVisibleVariation else { + completion(.failure(ConversationShareRenderError("Conversation share PNG is blank."))) + return + } + completion(.success(merged)) + return + } + let offset = offsets[index] + webView.scrollView.setContentOffset(CGPoint(x: 0, y: offset), animated: false) + waitForWebContentPaint(webView) { [weak self, weak webView] result in + guard let self, let webView else { return } + guard case .success = result else { + if case .failure(let error) = result { completion(.failure(error)) } + return + } + webView.takeSnapshot(with: snapshot) { [weak self] image, error in + guard let self else { return } + if let error { + completion(.failure(error)) return } - guard let data = image?.pngData(), !data.isEmpty else { - self.finish(.failure(ConversationShareRenderError("Conversation share PNG is empty."))) + guard let image else { + completion(.failure(ConversationShareRenderError("Conversation share tile is empty."))) return } - self.finish(.success(data.base64EncodedString())) + self.captureTile( + index: index + 1, + offsets: offsets, + webView: webView, + snapshot: snapshot, + effectiveScale: effectiveScale, + outputSize: outputSize, + tiles: tiles + [(offset: offset, image: image)], + completion: completion + ) + } + } + } + + private func waitForWebContentPaint( + _ webView: WKWebView, + completion: @escaping (Result) -> Void + ) { + let script = """ + return await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(resolve)); + }); + """ + webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) } } } @@ -274,6 +425,47 @@ private final class ConversationShareHtmlRenderer: NSObject, WKNavigationDelegat } } +private extension UIImage { + var hasVisibleVariation: Bool { + guard let cgImage else { return false } + let sampleWidth = 64 + let sampleHeight = 64 + let bytesPerPixel = 4 + let bytesPerRow = sampleWidth * bytesPerPixel + var pixels = [UInt8](repeating: 0, count: sampleHeight * bytesPerRow) + guard let context = CGContext( + data: &pixels, + width: sampleWidth, + height: sampleHeight, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { + return false + } + context.interpolationQuality = .low + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: sampleWidth, height: sampleHeight)) + + var minimum = [Int](repeating: 255, count: 3) + var maximum = [Int](repeating: 0, count: 3) + var hasOpaquePixel = false + for offset in stride(from: 0, to: pixels.count, by: bytesPerPixel) { + guard pixels[offset + 3] > 8 else { continue } + hasOpaquePixel = true + for channel in 0..<3 { + let value = Int(pixels[offset + channel]) + minimum[channel] = min(minimum[channel], value) + maximum[channel] = max(maximum[channel], value) + } + } + guard hasOpaquePixel else { return false } + return zip(minimum, maximum).contains { lower, upper in + upper - lower >= 4 + } + } +} + private struct ConversationShareRenderError: LocalizedError { let message: String diff --git a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts index 9617c8b016..e5a1f973dd 100644 --- a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts +++ b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts @@ -149,7 +149,7 @@ describe('buildConversationShareHtml 富内容导出', () => { expect(html).toContain(`alt="${i18n.t('message.renderer.imageFallbackTitle')}"`); }); - it('限制原生与降级 renderer 的完整源尺寸,并清理一次性 PNG', () => { + it('限制原生与降级 renderer 的完整源尺寸,并安全保留已分享 PNG', () => { const nativeSource = readFileSync( resolve( process.cwd(), @@ -171,6 +171,13 @@ describe('buildConversationShareHtml 富内容导出', () => { expect(nativeSource).toContain('hostingWindow.rootViewController = viewController'); expect(nativeSource).toContain('hostingWindow?.isHidden = true'); expect(nativeSource).toContain('no active window scene'); + expect(nativeSource).toContain('UIWindow.Level.normal.rawValue + 1'); + expect(nativeSource).toContain('hostingWindow.alpha = 0.01'); + expect(nativeSource).toContain('waitForWebContentPaint(webView)'); + expect(nativeSource).toContain('requestAnimationFrame(resolve)'); + expect(nativeSource).toContain('merged.hasVisibleVariation'); + expect(nativeSource).toContain('Conversation share PNG is blank.'); + expect(nativeSource).toContain('format.scale = 1'); expect(nativeSource).toContain( 'captureWidth * captureHeight <= conversationShareMaxSourcePixels', ); @@ -178,10 +185,12 @@ describe('buildConversationShareHtml 富内容导出', () => { expect(webViewSource).toContain( 'await deleteConversationSharePngTemp(file.uri);', ); - expect(sessionSource).toContain("localUri && Platform.OS !== 'android'"); + expect(sessionSource).not.toContain('deleteConversationSharePngTemp'); + expect(sessionSource).toContain('cache 目录交给系统回收'); expect(sessionSource).toContain(' Date: Wed, 19 Aug 2026 19:19:44 +0800 Subject: [PATCH 5/7] fix(mobile): bound share image cache cleanup Signed-off-by: David --- apps/mobile/app/sessions/[sessionId].tsx | 10 ++++- .../conversationShareWebViewHtml.test.ts | 11 +++++- .../src/session/ConversationShareWebView.tsx | 37 +++++++++++++++++++ 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index ea7444fd7f..4178fd10c6 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -102,6 +102,8 @@ import { } from '@/session/MessageRenderer'; import { bundledAssetToDataUri, + cleanupConversationSharePngTemps, + deleteConversationSharePngTemp, writeConversationSharePngTemp, } from '@/session/ConversationShareWebView'; import { @@ -6104,8 +6106,12 @@ export default function SessionScreen() { && shareSelectionActiveRef.current && shareSelectionRevisionRef.current === operationSelectionRevision; let localUri: string | null = null; + let shareCompleted = false; setConversationShareBusy(true); try { + // 成功分享的 PNG 要保留给系统扩展读取;回收更早的产物,限制 cache + // 目录增长,并不触碰本次尚未生成的文件。 + await cleanupConversationSharePngTemps(); if (!isShareOperationActive()) return; const base64 = await exportConversationSharePng(); if (!isShareOperationActive()) return; @@ -6116,6 +6122,7 @@ export default function SessionScreen() { if (!isShareOperationActive()) return; await sharing.shareAsync(localUri, { mimeType: 'image/png' }); if (!isShareOperationActive()) return; + shareCompleted = true; setShareSelectionTriggeredByScreenshot(false); shareSelectionStore.exit(); } catch (error) { @@ -6124,7 +6131,8 @@ export default function SessionScreen() { Alert.alert(t('session.screen.shareFailedTitle'), t('session.screen.shareImageFailed')); } finally { // shareAsync 返回后,iOS 分享扩展仍可能继续读取该 URL。成功写入的文件留在 - // cache 目录交给系统回收;写入失败的半成品由 writeConversationSharePngTemp 清理。 + // cache 目录交给下一次有界清理;失败、取消或中途失活则立即删除当前产物。 + if (!shareCompleted && localUri) await deleteConversationSharePngTemp(localUri); if (shareOperationSeqRef.current === operationSeq) setConversationShareBusy(false); } }, [conversationShareBusy, exportConversationSharePng, selectedShareMessages.length, shareSelectionActive, shareSelectionRevision, t]); diff --git a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts index e5a1f973dd..96d92103ba 100644 --- a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts +++ b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts @@ -185,8 +185,15 @@ describe('buildConversationShareHtml 富内容导出', () => { expect(webViewSource).toContain( 'await deleteConversationSharePngTemp(file.uri);', ); - expect(sessionSource).not.toContain('deleteConversationSharePngTemp'); - expect(sessionSource).toContain('cache 目录交给系统回收'); + expect(webViewSource).toContain('SHARE_PNG_RETAIN_COUNT = 3'); + expect(webViewSource).toContain('SHARE_PNG_CLEANUP_BATCH = 8'); + expect(webViewSource).toContain( + 'files.slice(SHARE_PNG_RETAIN_COUNT, SHARE_PNG_RETAIN_COUNT + SHARE_PNG_CLEANUP_BATCH)', + ); + expect(sessionSource).toContain('deleteConversationSharePngTemp'); + expect(sessionSource).toContain('cleanupConversationSharePngTemps'); + expect(sessionSource).toContain('cache 目录交给下一次有界清理'); + expect(sessionSource).toContain('if (!shareCompleted && localUri)'); expect(sessionSource).toContain('; @@ -266,6 +271,38 @@ export async function writeConversationSharePngTemp( } } +/** + * 回收消息分享产生的旧 PNG。 + * + * 只在下一次分享开始前调用,当前这次刚写入的文件不会被删除;保留最近 + * 几份成功产物给系统分享扩展读取,旧文件按批次回收。清理是 best-effort, + * 不能影响当前分享流程。 + */ +export async function cleanupConversationSharePngTemps(): Promise { + try { + const directory = new Directory(Paths.cache, EXPORT_DIR_NAME); + if (!directory.exists) return; + const files = directory + .list() + .filter( + (entry): entry is File => + entry instanceof File + && entry.name.startsWith("conversation-") + && entry.extension.toLowerCase() === ".png" + && entry.exists, + ) + .sort( + (left, right) => + (right.modificationTime ?? right.creationTime ?? 0) + - (left.modificationTime ?? left.creationTime ?? 0), + ); + const stale = files.slice(SHARE_PNG_RETAIN_COUNT, SHARE_PNG_RETAIN_COUNT + SHARE_PNG_CLEANUP_BATCH); + await Promise.all(stale.map((file) => deleteConversationSharePngTemp(file.uri))); + } catch { + // 一次性缓存清理是 best-effort,不覆盖当前分享操作。 + } +} + export async function deleteConversationSharePngTemp(uri: string): Promise { if (!uri) return; try { From 39735263c51d5baedf9a7aa25805b1d828ebc4c0 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 19 Aug 2026 20:07:57 +0800 Subject: [PATCH 6/7] perf(mobile): skip native share HTML on Android Signed-off-by: David --- apps/mobile/app/sessions/[sessionId].tsx | 12 +++++++++--- .../__tests__/conversationShareWebViewHtml.test.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/mobile/app/sessions/[sessionId].tsx b/apps/mobile/app/sessions/[sessionId].tsx index 4178fd10c6..64583e05c7 100644 --- a/apps/mobile/app/sessions/[sessionId].tsx +++ b/apps/mobile/app/sessions/[sessionId].tsx @@ -611,6 +611,7 @@ const REOPEN_MESSAGE_WINDOW_LIMITS = [20, 10, 5, 1] as const; // 覆盖 settling 窗口上限(10s)之后仍无任何在途证据的场景。 const TAIL_RETRY_HIDE_TIMEOUT_MS = 15_000; const SCREENSHOT_SHARE_ACTIVATION_DEBOUNCE_MS = 1_200; +const nativeConversationShareAvailable = Platform.OS === 'ios'; // 原生 WKWebView 只能稳定读取 data URI;SVG 兜底直接使用同一组 bundle asset。 // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -1096,7 +1097,7 @@ export default function SessionScreen() { }, [sessionId]), ); useEffect(() => { - if (!shareSelectionActive) return undefined; + if (!nativeConversationShareAvailable || !shareSelectionActive) return undefined; let cancelled = false; const logoNeedsLoad = shareLogoModeRef.current !== mode || !shareLogoSrc; void Promise.all([ @@ -6038,7 +6039,11 @@ export default function SessionScreen() { dark: mode === 'dark', }), [colors, mode]); const conversationShareHtml = useMemo(() => { - if (!shareSelectionActive || selectedShareMessages.length === 0) return ''; + if ( + !nativeConversationShareAvailable + || !shareSelectionActive + || selectedShareMessages.length === 0 + ) return ''; return buildConversationShareHtml({ allShareableIds, characterSrc: shareCharacterSrc ?? undefined, @@ -6070,7 +6075,8 @@ export default function SessionScreen() { }, []); const exportConversationSharePng = useCallback(async () => { const nativeShareAssetsReady = Boolean( - shareCharacterSrc + nativeConversationShareAvailable + && shareCharacterSrc && shareLogoSrc && shareLogoModeRef.current === mode, ); diff --git a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts index 96d92103ba..a38834f3c1 100644 --- a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts +++ b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts @@ -195,6 +195,18 @@ describe('buildConversationShareHtml 富内容导出', () => { expect(sessionSource).toContain('cache 目录交给下一次有界清理'); expect(sessionSource).toContain('if (!shareCompleted && localUri)'); expect(sessionSource).toContain(' Date: Wed, 19 Aug 2026 20:46:43 +0800 Subject: [PATCH 7/7] test(mobile): normalize source line endings Signed-off-by: David --- apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts index a38834f3c1..4b54186f52 100644 --- a/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts +++ b/apps/mobile/src/__tests__/conversationShareWebViewHtml.test.ts @@ -164,7 +164,7 @@ describe('buildConversationShareHtml 富内容导出', () => { const sessionSource = readFileSync( resolve(process.cwd(), 'app/sessions/[sessionId].tsx'), 'utf8', - ); + ).replace(/\r\n/g, '\n'); expect(nativeSource).toContain('conversationShareMaxSourcePixels'); expect(nativeSource).toContain('UIWindow(windowScene: windowScene)');