From 78e6c55537bc024d5031a2fe8ef847626616922e Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Sat, 8 Aug 2026 21:54:42 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(desktop):=20=E7=AD=89=E5=BE=85=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E8=A7=86=E5=9B=BE=E5=8F=AF=E8=A7=81=E5=90=8E=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=20JCEF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- ...sktopInitialNativeViewLayoutCoordinator.kt | 134 ++++++++--- .../desktop/DesktopWebViewController.kt | 17 +- ...pInitialNativeViewLayoutCoordinatorTest.kt | 212 ++++++++---------- ...ktopNativeViewAttachmentCoordinatorTest.kt | 81 ++++++- 5 files changed, 288 insertions(+), 158 deletions(-) diff --git a/gradle.properties b/gradle.properties index bafcf12..32593a3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ GROUP=io.github.generalio.multiweb -VERSION_NAME=0.2.8-SNAPSHOT +VERSION_NAME=0.2.9-SNAPSHOT kotlin.code.style=official kotlin.mpp.androidSourceSetLayoutVersion=2 diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt index d31a5f2..20eb2a5 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt @@ -3,10 +3,12 @@ package io.github.multiweb.desktop import java.awt.Component import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent +import java.awt.event.HierarchyBoundsAdapter import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener import javax.swing.JComponent import javax.swing.SwingUtilities +import javax.swing.Timer /** * 可参与 JCEF 初始布局同步的原生视图。 @@ -22,6 +24,10 @@ internal interface DesktopNativeViewLayoutTarget { val width: Int /** 当前布局高度。 */ val height: Int + /** 当前实际可见区域宽度。 */ + val visibleWidth: Int + /** 当前实际可见区域高度。 */ + val visibleHeight: Int /** 注册视图进入 showing 状态时的回调。 */ fun addShowingListener(listener: () -> Unit) @@ -58,7 +64,7 @@ internal class ComponentDesktopNativeViewLayoutTarget( private val component: Component, ) : DesktopNativeViewLayoutTarget { private val hierarchyListeners = mutableMapOf<() -> Unit, HierarchyListener>() - private val componentListeners = mutableMapOf<() -> Unit, ComponentAdapter>() + private val layoutChangedListeners = mutableMapOf<() -> Unit, LayoutChangedListenerRegistration>() override val isDisplayable: Boolean get() = component.isDisplayable @@ -72,6 +78,12 @@ internal class ComponentDesktopNativeViewLayoutTarget( override val height: Int get() = component.height + override val visibleWidth: Int + get() = component.visibleSize().width + + override val visibleHeight: Int + get() = component.visibleSize().height + override fun addShowingListener(listener: () -> Unit) { if (hierarchyListeners.containsKey(listener)) { return @@ -93,7 +105,7 @@ internal class ComponentDesktopNativeViewLayoutTarget( } override fun addLayoutChangedListener(listener: () -> Unit) { - if (componentListeners.containsKey(listener)) { + if (layoutChangedListeners.containsKey(listener)) { return } val componentListener = object : ComponentAdapter() { @@ -105,12 +117,28 @@ internal class ComponentDesktopNativeViewLayoutTarget( listener() } } - componentListeners[listener] = componentListener + val hierarchyBoundsListener = object : HierarchyBoundsAdapter() { + override fun ancestorResized(event: HierarchyEvent) { + listener() + } + + override fun ancestorMoved(event: HierarchyEvent) { + listener() + } + } + layoutChangedListeners[listener] = LayoutChangedListenerRegistration( + componentListener = componentListener, + hierarchyBoundsListener = hierarchyBoundsListener, + ) component.addComponentListener(componentListener) + component.addHierarchyBoundsListener(hierarchyBoundsListener) } override fun removeLayoutChangedListener(listener: () -> Unit) { - componentListeners.remove(listener)?.let(component::removeComponentListener) + layoutChangedListeners.remove(listener)?.let { registration -> + component.removeComponentListener(registration.componentListener) + component.removeHierarchyBoundsListener(registration.hierarchyBoundsListener) + } } override fun revalidate() { @@ -142,6 +170,13 @@ internal class ComponentDesktopNativeViewLayoutTarget( override fun repaint() { component.repaint() } + + private fun Component.visibleSize() = (this as? JComponent)?.visibleRect?.size ?: size + + private data class LayoutChangedListenerRegistration( + val componentListener: ComponentAdapter, + val hierarchyBoundsListener: HierarchyBoundsAdapter, + ) } /** @@ -158,6 +193,7 @@ internal class DesktopNativeViewAttachmentCoordinator( ) { private var isDisposed = false private var isShowingListenerRegistered = false + private var isLayoutChangedListenerRegistered = false private var isBrowserCreationRequested = false private var isBrowserCreationStarted = false private var isBrowserCreated = false @@ -170,9 +206,11 @@ internal class DesktopNativeViewAttachmentCoordinator( } target.addShowingListener(showingListener) isShowingListenerRegistered = true + target.addLayoutChangedListener(showingListener) + isLayoutChangedListenerRegistered = true } - /** 请求创建浏览器;仅在组件已显示且拥有原生父视图时执行。 */ + /** 请求创建浏览器;仅在组件已显示、已完成可见裁剪且拥有原生父视图时执行。 */ fun requestBrowserCreation() { if (isUnavailable()) { return @@ -187,7 +225,7 @@ internal class DesktopNativeViewAttachmentCoordinator( return } isDisposed = true - removeShowingListener() + removeViewReadyListeners() } private fun createBrowserIfReady() { @@ -197,7 +235,11 @@ internal class DesktopNativeViewAttachmentCoordinator( isBrowserCreated || isBrowserCreationStarted || !target.isDisplayable || - !target.isShowing + !target.isShowing || + target.width <= 0 || + target.height <= 0 || + target.visibleWidth <= 0 || + target.visibleHeight <= 0 ) { return } @@ -205,18 +247,21 @@ internal class DesktopNativeViewAttachmentCoordinator( try { createBrowser() isBrowserCreated = true - removeShowingListener() + removeViewReadyListeners() } finally { isBrowserCreationStarted = false } } - private fun removeShowingListener() { - if (!isShowingListenerRegistered) { - return + private fun removeViewReadyListeners() { + if (isShowingListenerRegistered) { + target.removeShowingListener(showingListener) + isShowingListenerRegistered = false + } + if (isLayoutChangedListenerRegistered) { + target.removeLayoutChangedListener(showingListener) + isLayoutChangedListenerRegistered = false } - target.removeShowingListener(showingListener) - isShowingListenerRegistered = false } private fun isUnavailable(): Boolean = isDisposed || isControllerDisposed() @@ -225,25 +270,30 @@ internal class DesktopNativeViewAttachmentCoordinator( /** * 协调 JCEF 就绪与 Swing 视图首次 showing 的布局同步。 * - * Compose `SwingPanel` 的首次有效尺寸可能晚于 JCEF 创建或 showing 事件;因此在控制器明确请求后同时监听 showing 与 - * 尺寸变化,并在 Swing EDT 延迟重试一次。仅当视图可显示且尺寸有效时,才执行一次 + * Compose `SwingPanel` 的首次有效可见区域可能晚于 JCEF 创建或 showing 事件;因此在控制器明确请求后同时监听 showing 与 + * 祖先布局变化,并在 JCEF 的原生子窗口完成绑定后再延迟同步一次。仅当视图可显示、尺寸与裁剪区域均有效时,才执行 * [DesktopNativeViewLayoutTarget.revalidate]、[DesktopNativeViewLayoutTarget.paintImmediately] 与 * [DesktopNativeViewLayoutTarget.repaint]。调用方必须在 Swing EDT 调用本类。 */ internal class DesktopInitialNativeViewLayoutCoordinator( private val target: DesktopNativeViewLayoutTarget, private val isControllerDisposed: () -> Boolean, - private val scheduleDelayedRetry: ((() -> Unit) -> Unit) = { action -> - SwingUtilities.invokeLater { action() } + private val scheduleRetry: ((() -> Unit) -> Unit) = { action -> + Timer(INITIAL_LAYOUT_RETRY_DELAY_MILLIS) { action() }.apply { + isRepeats = false + start() + } }, ) { private var isDisposed = false private var isShowingListenerRegistered = false private var isLayoutChangedListenerRegistered = false private var isInitialLayoutRequested = false - private var isInitialLayoutSynchronized = false + private var isInitialLayoutCompleted = false private var isSynchronizing = false private var isDelayedRetryScheduled = false + private var invalidLayoutRetryCount = 0 + private var synchronizationPassCount = 0 private val initialLayoutListener: () -> Unit = ::synchronizeNativeViewLayoutIfReady /** 注册一次 showing 监听;视图先于 JCEF 就绪时也可安全调用。 */ @@ -279,7 +329,7 @@ internal class DesktopInitialNativeViewLayoutCoordinator( removeInitialLayoutListeners() } - /** 初始同步成功后立即移除全部监听,窗口后续缩放不得再次触发同步绘制。 */ + /** 初始同步完成后移除全部监听,窗口后续缩放不得再次触发同步绘制。 */ private fun removeInitialLayoutListeners() { if (isShowingListenerRegistered) { target.removeShowingListener(initialLayoutListener) @@ -291,12 +341,12 @@ internal class DesktopInitialNativeViewLayoutCoordinator( } } - /** 仅在 JCEF 已请求、Swing 已显示且布局尺寸有效时执行一次同步。 */ + /** 仅在 JCEF 已请求、Swing 已显示且布局尺寸与可见裁剪区域有效时执行同步。 */ private fun synchronizeNativeViewLayoutIfReady() { if ( isUnavailable() || !isInitialLayoutRequested || - isInitialLayoutSynchronized || + isInitialLayoutCompleted || isSynchronizing ) { return @@ -305,9 +355,11 @@ internal class DesktopInitialNativeViewLayoutCoordinator( !target.isDisplayable || !target.isShowing || target.width <= 0 || - target.height <= 0 + target.height <= 0 || + target.visibleWidth <= 0 || + target.visibleHeight <= 0 ) { - scheduleOneDelayedRetry() + scheduleRetryForInvalidLayout() return } isSynchronizing = true @@ -316,24 +368,48 @@ internal class DesktopInitialNativeViewLayoutCoordinator( target.synchronizeNativeViewSize() target.paintImmediately() target.repaint() - isInitialLayoutSynchronized = true - removeInitialLayoutListeners() + synchronizationPassCount++ + if (synchronizationPassCount == INITIAL_LAYOUT_SYNCHRONIZATION_PASSES) { + isInitialLayoutCompleted = true + removeInitialLayoutListeners() + } else { + scheduleStabilizationRetry() + } } finally { isSynchronizing = false } } - /** 覆盖同一 EDT 事件循环内才完成的首次布局;未就绪时仍等待下一次真实视图事件。 */ - private fun scheduleOneDelayedRetry() { - if (isDelayedRetryScheduled || isUnavailable() || isInitialLayoutSynchronized) { + /** 布局尚未稳定时的有限重试;次数耗尽后仍保留布局监听等待真实事件。 */ + private fun scheduleRetryForInvalidLayout() { + if (invalidLayoutRetryCount == MAX_INVALID_LAYOUT_RETRIES) { + return + } + invalidLayoutRetryCount++ + scheduleDelayedRetry() + } + + /** JCEF 创建原生子窗口后,再执行一次布局同步以覆盖异步 parent 绑定。 */ + private fun scheduleStabilizationRetry() { + scheduleDelayedRetry() + } + + private fun scheduleDelayedRetry() { + if (isDelayedRetryScheduled || isUnavailable() || isInitialLayoutCompleted) { return } isDelayedRetryScheduled = true - scheduleDelayedRetry { + scheduleRetry { isDelayedRetryScheduled = false synchronizeNativeViewLayoutIfReady() } } private fun isUnavailable(): Boolean = isDisposed || isControllerDisposed() + + private companion object { + const val INITIAL_LAYOUT_RETRY_DELAY_MILLIS = 100 + const val INITIAL_LAYOUT_SYNCHRONIZATION_PASSES = 2 + const val MAX_INVALID_LAYOUT_RETRIES = 3 + } } diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt index 8843c90..bcb89dc 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.asStateFlow import java.awt.Component import java.util.concurrent.ConcurrentHashMap import javax.swing.SwingUtilities +import javax.swing.Timer import org.cef.CefApp import org.cef.CefClient import org.cef.browser.CefBrowser @@ -126,7 +127,7 @@ class DesktopWebViewController( /** 等待 JCEF 与 Swing 均就绪后同步 windowed 原生浏览器的首次布局。 */ private lateinit var initialNativeViewLayoutCoordinator: DesktopInitialNativeViewLayoutCoordinator - /** `createImmediately()` 已调用后才可以请求 JCEF 的正常浏览器关闭路径。 */ + /** 已请求 JCEF 创建后才可以请求浏览器的正常关闭路径。 */ private var isBrowserCreationStarted = false /** 控制器是否已释放。释放后除 [dispose] 外的操作都会抛出 [IllegalStateException]。 */ @@ -178,7 +179,17 @@ class DesktopWebViewController( isControllerDisposed = { isDisposed }, createBrowser = { isBrowserCreationStarted = true - browser.createImmediately() + // `createImmediately()` 会以无原生父窗口的参数创建浏览器。先触发 JCEF 视图绘制,JCEF 的内部延迟 + // 更新会携带当前 macOS 原生窗口句柄创建浏览器;仅在该路径没有产生回调时再使用旧路径兜底。 + nativeViewTarget.paintImmediately() + Timer(BROWSER_CREATION_FALLBACK_DELAY_MILLIS) { + if (!isDisposed && !isBrowserReady) { + browser.createImmediately() + } + }.apply { + isRepeats = false + start() + } }, ) initialNativeViewLayoutCoordinator = DesktopInitialNativeViewLayoutCoordinator( @@ -794,6 +805,8 @@ class DesktopWebViewController( } } +private const val BROWSER_CREATION_FALLBACK_DELAY_MILLIS = 300 + /** 统一执行 JCEF 的非强制浏览器关闭顺序,供桌面控制器及回归测试复用。 */ internal fun closeDesktopBrowser(browser: CefBrowser) { browser.stopLoad() diff --git a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt index e74fcc3..68776e1 100644 --- a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt @@ -8,209 +8,177 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { @Test fun `视图尚未 showing 时不会立即同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target = target, - isControllerDisposed = { false }, - scheduleDelayedRetry = {}, - ) + val coordinator = createCoordinator(target) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) + assertSynchronizationPerformed(target, expectedCount = 0) } @Test - fun `收到 showing 事件后按同步顺序绘制一次`() { - val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target = target, - isControllerDisposed = { false }, - scheduleDelayedRetry = {}, - ) - - coordinator.registerShowingListener() - coordinator.requestInitialNativeViewLayout() - target.isShowing = true - target.dispatchShowingChanged() - target.dispatchShowingChanged() - - assertEquals(1, target.revalidateCount) - assertEquals(1, target.nativeViewSizeSynchronizationCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) - assertEquals( - listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint"), - target.synchronizationOperations, - ) - } - - @Test - fun `尺寸无效时不会同步绘制`() { + fun `可见裁剪区域无效时不会提前同步`() { val target = FakeDesktopNativeViewLayoutTarget( isShowing = true, - width = 0, + visibleWidth = 0, + visibleHeight = 0, ) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val coordinator = createCoordinator(target) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) + assertSynchronizationPerformed(target, expectedCount = 0) } @Test - fun `首次尺寸变为有效后会同步绘制一次`() { - val target = FakeDesktopNativeViewLayoutTarget( - isShowing = true, - width = 0, - height = 0, - ) - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target = target, - isControllerDisposed = { false }, - scheduleDelayedRetry = {}, - ) + fun `收到 showing 事件后会开始稳定同步`() { + val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - target.width = 100 - target.height = 100 - target.dispatchLayoutChanged() + target.isShowing = true + target.dispatchShowingChanged() + delayedRetries.removeFirst().invoke() - assertEquals(1, target.revalidateCount) - assertEquals(1, target.nativeViewSizeSynchronizationCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) - assertEquals( - listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint"), - target.synchronizationOperations, - ) + assertSynchronizationPerformed(target, expectedCount = 2) } @Test - fun `同一事件循环内尺寸变为有效时延迟重试会同步绘制`() { + fun `可见裁剪区域稳定后会执行两次同步`() { val target = FakeDesktopNativeViewLayoutTarget( isShowing = true, - width = 0, - height = 0, + visibleWidth = 0, + visibleHeight = 0, ) val delayedRetries = mutableListOf<() -> Unit>() - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target, - { false }, - { action -> delayedRetries += action }, - ) + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - target.width = 100 - target.height = 100 - delayedRetries.single().invoke() + target.visibleWidth = 100 + target.visibleHeight = 100 + delayedRetries.removeFirst().invoke() + + assertSynchronizationPerformed(target, expectedCount = 1) + assertEquals(1, delayedRetries.size) + assertEquals(0, target.removeShowingListenerCount) + assertEquals(0, target.removeLayoutChangedListenerCount) - assertSynchronizationPerformedOnce(target) + delayedRetries.removeFirst().invoke() + + assertSynchronizationPerformed(target, expectedCount = 2) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test - fun `Swing 先 showing 后 JCEF 就绪时会同步绘制一次`() { + fun `JCEF 就绪后会安排第二次稳定布局同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - assertSynchronizationPerformedOnce(target) + assertSynchronizationPerformed(target, expectedCount = 1) + assertEquals(1, delayedRetries.size) + + delayedRetries.removeFirst().invoke() + + assertSynchronizationPerformed(target, expectedCount = 2) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test - fun `JCEF 就绪后会重新同步当前尺寸`() { + fun `真实布局事件可在延迟重试前完成稳定同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() + target.dispatchLayoutChanged() + delayedRetries.single().invoke() - assertEquals(1, target.nativeViewSizeSynchronizationCount) + assertSynchronizationPerformed(target, expectedCount = 2) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test - fun `初始同步完成后多次 resize 不会重复绘制`() { + fun `同步完成后多次 resize 不会重复绘制`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() + delayedRetries.removeFirst().invoke() target.dispatchLayoutChanged() target.dispatchLayoutChanged() - assertSynchronizationPerformedOnce(target) - assertEquals(1, target.removeShowingListenerCount) - assertEquals(1, target.removeLayoutChangedListenerCount) + assertSynchronizationPerformed(target, expectedCount = 2) } @Test - fun `销毁后不再同步且移除监听`() { + fun `销毁后不再执行已排队的稳定同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() - coordinator.dispose() coordinator.requestInitialNativeViewLayout() - target.dispatchShowingChanged() + coordinator.dispose() + delayedRetries.single().invoke() + assertSynchronizationPerformed(target, expectedCount = 1) assertEquals(1, target.removeShowingListenerCount) assertEquals(1, target.removeLayoutChangedListenerCount) - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) } @Test fun `控制器已销毁时不执行同步`() { - val isControllerDisposed = true val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { isControllerDisposed }) + val coordinator = createCoordinator(target, isControllerDisposed = { true }) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() target.dispatchShowingChanged() - assertEquals(0, target.addListenerCount) + assertEquals(0, target.addShowingListenerCount) assertEquals(0, target.addLayoutChangedListenerCount) - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) + assertSynchronizationPerformed(target, expectedCount = 0) } +} - @Test - fun `多次注册不会重复添加 showing 监听`() { - val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) - - coordinator.registerShowingListener() - coordinator.registerShowingListener() - coordinator.requestInitialNativeViewLayout() - target.dispatchShowingChanged() - - assertEquals(1, target.addListenerCount) - assertEquals(1, target.addLayoutChangedListenerCount) - assertEquals(1, target.revalidateCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) - } +private fun createCoordinator( + target: FakeDesktopNativeViewLayoutTarget, + isControllerDisposed: () -> Boolean = { false }, + scheduleDelayedRetry: ((() -> Unit) -> Unit) = {}, +): DesktopInitialNativeViewLayoutCoordinator { + return DesktopInitialNativeViewLayoutCoordinator( + target = target, + isControllerDisposed = isControllerDisposed, + scheduleRetry = scheduleDelayedRetry, + ) } -private fun assertSynchronizationPerformedOnce(target: FakeDesktopNativeViewLayoutTarget) { - assertEquals(1, target.revalidateCount) - assertEquals(1, target.nativeViewSizeSynchronizationCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) +private fun assertSynchronizationPerformed( + target: FakeDesktopNativeViewLayoutTarget, + expectedCount: Int, +) { + assertEquals(expectedCount, target.revalidateCount) + assertEquals(expectedCount, target.nativeViewSizeSynchronizationCount) + assertEquals(expectedCount, target.immediatePaintCount) + assertEquals(expectedCount, target.repaintCount) assertEquals( - listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint"), + List(expectedCount) { + listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint") + }.flatten(), target.synchronizationOperations, ) } @@ -220,11 +188,13 @@ private class FakeDesktopNativeViewLayoutTarget( override var isShowing: Boolean, override var width: Int = 100, override var height: Int = 100, + override var visibleWidth: Int = 100, + override var visibleHeight: Int = 100, ) : DesktopNativeViewLayoutTarget { private val showingListeners = mutableSetOf<() -> Unit>() private val layoutChangedListeners = mutableSetOf<() -> Unit>() - var addListenerCount = 0 + var addShowingListenerCount = 0 private set var removeShowingListenerCount = 0 private set @@ -243,7 +213,7 @@ private class FakeDesktopNativeViewLayoutTarget( val synchronizationOperations = mutableListOf() override fun addShowingListener(listener: () -> Unit) { - addListenerCount++ + addShowingListenerCount++ showingListeners += listener } diff --git a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt index 2a65cdd..d585897 100644 --- a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt @@ -25,6 +25,7 @@ class DesktopNativeViewAttachmentCoordinatorTest { assertEquals(listOf("browser"), created) assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test @@ -63,20 +64,81 @@ class DesktopNativeViewAttachmentCoordinatorTest { assertEquals(0, createCount) assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) + } + + @Test + fun `原生视图尺寸有效后才创建浏览器`() { + val target = FakeAttachmentTarget( + isShowing = true, + width = 0, + height = 0, + ) + var createCount = 0 + val coordinator = DesktopNativeViewAttachmentCoordinator( + target = target, + isControllerDisposed = { false }, + createBrowser = { createCount++ }, + ) + + coordinator.registerShowingListener() + coordinator.requestBrowserCreation() + + assertEquals(0, createCount) + + target.width = 100 + target.height = 100 + target.dispatchLayoutChanged() + + assertEquals(1, createCount) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) + } + + @Test + fun `实际可见区域有效后才创建浏览器`() { + val target = FakeAttachmentTarget( + isShowing = true, + visibleWidth = 0, + visibleHeight = 0, + ) + var createCount = 0 + val coordinator = DesktopNativeViewAttachmentCoordinator( + target = target, + isControllerDisposed = { false }, + createBrowser = { createCount++ }, + ) + + coordinator.registerShowingListener() + coordinator.requestBrowserCreation() + + assertEquals(0, createCount) + + target.visibleWidth = 100 + target.visibleHeight = 100 + target.dispatchLayoutChanged() + + assertEquals(1, createCount) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } } private class FakeAttachmentTarget( override var isDisplayable: Boolean = true, override var isShowing: Boolean, + override var width: Int = 100, + override var height: Int = 100, + override var visibleWidth: Int = 100, + override var visibleHeight: Int = 100, ) : DesktopNativeViewLayoutTarget { private val showingListeners = mutableSetOf<() -> Unit>() + private val layoutChangedListeners = mutableSetOf<() -> Unit>() var removeShowingListenerCount = 0 private set - - override val width: Int = 100 - override val height: Int = 100 + var removeLayoutChangedListenerCount = 0 + private set override fun addShowingListener(listener: () -> Unit) { showingListeners += listener @@ -87,9 +149,14 @@ private class FakeAttachmentTarget( showingListeners -= listener } - override fun addLayoutChangedListener(listener: () -> Unit) = Unit + override fun addLayoutChangedListener(listener: () -> Unit) { + layoutChangedListeners += listener + } - override fun removeLayoutChangedListener(listener: () -> Unit) = Unit + override fun removeLayoutChangedListener(listener: () -> Unit) { + removeLayoutChangedListenerCount++ + layoutChangedListeners -= listener + } override fun revalidate() = Unit @@ -102,4 +169,8 @@ private class FakeAttachmentTarget( fun dispatchShowingChanged() { showingListeners.toList().forEach { listener -> listener() } } + + fun dispatchLayoutChanged() { + layoutChangedListeners.toList().forEach { listener -> listener() } + } } From 5ea3b5e18a9d3a31a9125b41b7519e1fa5321047 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Sun, 9 Aug 2026 01:58:18 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(desktop):=20=E5=BC=BA=E5=88=B6=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=20JCEF=20=E9=A6=96=E6=AC=A1=E7=BB=98=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...sktopInitialNativeViewLayoutCoordinator.kt | 37 +++++++++++++++++-- .../desktop/DesktopWebViewController.kt | 15 +------- .../desktop/JcefInitialPaintDispatcherTest.kt | 28 ++++++++++++++ 3 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 webview-desktop/src/test/kotlin/io/github/multiweb/desktop/JcefInitialPaintDispatcherTest.kt diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt index 20eb2a5..03e57d1 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt @@ -6,6 +6,8 @@ import java.awt.event.ComponentEvent import java.awt.event.HierarchyBoundsAdapter import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener +import java.awt.geom.AffineTransform +import java.awt.image.BufferedImage import javax.swing.JComponent import javax.swing.SwingUtilities import javax.swing.Timer @@ -152,8 +154,9 @@ internal class ComponentDesktopNativeViewLayoutTarget( /** * 同步绘制 JCEF 暴露的 JPanel,触发其内部的 `doUpdate()` 与原生浏览器子窗口绑定。 * - * JCEF windowed 模式下普通 [repaint] 只会异步请求 Swing 绘制,Compose `SwingPanel` 的首次挂载可能不会立刻 - * 进入 `paint()`;这里仅在 Swing EDT 和可见有效边界内强制执行一次。 + * Compose `SwingPanel` 的首次 [JComponent.paintImmediately] 不保证会实际调用嵌入组件重写的 `paint()`; + * 而 JCEF 恰好在该方法中安排带原生父窗口的浏览器创建。因此在正常的立即绘制请求之后,再使用极小的离屏 + * Graphics 明确执行一次真实绘制。JCEF 使用组件自身的可见区域计算布局,离屏图像尺寸不会影响浏览器尺寸。 */ override fun paintImmediately() { check(SwingUtilities.isEventDispatchThread()) { @@ -164,7 +167,13 @@ internal class ComponentDesktopNativeViewLayoutTarget( if (!component.isDisplayable || !component.isShowing || width <= 0 || height <= 0) { return } - (component as? JComponent)?.paintImmediately(0, 0, width, height) ?: component.repaint() + val swingComponent = component as? JComponent + if (swingComponent == null) { + component.repaint() + return + } + swingComponent.paintImmediately(0, 0, width, height) + JcefInitialPaintDispatcher.paint(swingComponent) } override fun repaint() { @@ -179,6 +188,28 @@ internal class ComponentDesktopNativeViewLayoutTarget( ) } +/** + * 显式调用 JCEF Windowed 组件的 [JComponent.paint]。 + * + * JCEF 的内部 `delayedUpdate` 会在本次绘制后以当前 macOS 原生窗口句柄创建浏览器。不能使用 + * `createImmediately()` 兜底,否则浏览器可能在未绑定父窗口时创建,并再次退化为只能在窗口 resize 后显示。 + */ +internal object JcefInitialPaintDispatcher { + fun paint(component: JComponent) { + val image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + try { + graphics.transform = component.graphicsConfiguration + ?.defaultTransform + ?.let(::AffineTransform) + ?: AffineTransform() + component.paint(graphics) + } finally { + graphics.dispose() + } + } +} + /** * 协调 JCEF 浏览器创建与 Compose Swing 组件挂载。 * diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt index bcb89dc..5f08238 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt @@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.asStateFlow import java.awt.Component import java.util.concurrent.ConcurrentHashMap import javax.swing.SwingUtilities -import javax.swing.Timer import org.cef.CefApp import org.cef.CefClient import org.cef.browser.CefBrowser @@ -179,17 +178,9 @@ class DesktopWebViewController( isControllerDisposed = { isDisposed }, createBrowser = { isBrowserCreationStarted = true - // `createImmediately()` 会以无原生父窗口的参数创建浏览器。先触发 JCEF 视图绘制,JCEF 的内部延迟 - // 更新会携带当前 macOS 原生窗口句柄创建浏览器;仅在该路径没有产生回调时再使用旧路径兜底。 + // JCEF Windowed 浏览器必须由视图绘制后的内部延迟更新创建;该路径会携带当前 macOS 原生窗口句柄。 + // 禁止调用 createImmediately(),其无父窗口创建路径会导致首帧直到窗口 resize 才重新绑定并显示。 nativeViewTarget.paintImmediately() - Timer(BROWSER_CREATION_FALLBACK_DELAY_MILLIS) { - if (!isDisposed && !isBrowserReady) { - browser.createImmediately() - } - }.apply { - isRepeats = false - start() - } }, ) initialNativeViewLayoutCoordinator = DesktopInitialNativeViewLayoutCoordinator( @@ -805,8 +796,6 @@ class DesktopWebViewController( } } -private const val BROWSER_CREATION_FALLBACK_DELAY_MILLIS = 300 - /** 统一执行 JCEF 的非强制浏览器关闭顺序,供桌面控制器及回归测试复用。 */ internal fun closeDesktopBrowser(browser: CefBrowser) { browser.stopLoad() diff --git a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/JcefInitialPaintDispatcherTest.kt b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/JcefInitialPaintDispatcherTest.kt new file mode 100644 index 0000000..b3c4088 --- /dev/null +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/JcefInitialPaintDispatcherTest.kt @@ -0,0 +1,28 @@ +package io.github.multiweb.desktop + +import java.awt.Graphics +import javax.swing.JPanel +import kotlin.test.Test +import kotlin.test.assertEquals + +class JcefInitialPaintDispatcherTest { + + @Test + fun `离屏绘制会调用 JCEF 组件的真实 paint`() { + val component = PaintTrackingPanel() + + JcefInitialPaintDispatcher.paint(component) + + assertEquals(1, component.paintCount) + } + + private class PaintTrackingPanel : JPanel() { + var paintCount = 0 + private set + + override fun paint(graphics: Graphics) { + paintCount++ + super.paint(graphics) + } + } +} From b777d0fba4f598028469ecc00780530555aa7003 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Sun, 9 Aug 2026 01:58:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(ios):=20=E5=85=BC=E5=AE=B9=E6=96=B0?= =?UTF-8?q?=E7=89=88=20WKUIDelegate=20=E6=96=87=E4=BB=B6=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=9B=9E=E8=B0=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build-logic/build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 +- .../api/android/webview-extension-api.api | 18 ++++++------ .../api/jvm/webview-extension-api.api | 18 ++++++------ webview-ios/build.gradle.kts | 9 ------ .../multiweb/ios/IosWebViewController.kt | 22 +++++++-------- .../cinterop/MultiWebFileChooser.def | 4 --- .../cinterop/MultiWebFileChooser.h | 28 ------------------- 8 files changed, 30 insertions(+), 73 deletions(-) delete mode 100644 webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.def delete mode 100644 webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.h diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index e710c2e..f29ce76 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -12,6 +12,6 @@ dependencies { implementation("com.android.tools.build:gradle:8.7.3") // 使用基础插件接入 Central Portal 和 GPG 签名,发布物仍由本工程约定插件手动注册。 implementation("com.vanniktech:gradle-maven-publish-plugin:0.34.0") - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20") + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10") implementation("org.jetbrains.kotlinx:binary-compatibility-validator:0.17.0") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 619a6b9..57c9dc1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] agp = "8.7.3" -kotlin = "2.1.21" +kotlin = "2.4.10" compose = "1.9.3" androidxActivity = "1.8.2" androidxComposeUi = "1.9.4" diff --git a/webview-extension-api/api/android/webview-extension-api.api b/webview-extension-api/api/android/webview-extension-api.api index 6683b2f..9c1095a 100644 --- a/webview-extension-api/api/android/webview-extension-api.api +++ b/webview-extension-api/api/android/webview-extension-api.api @@ -395,8 +395,8 @@ public final class io/github/multiweb/extension/WebFileChooserResult$Selected : } public abstract interface class io/github/multiweb/extension/WebViewControllerLifecycleExtension : io/github/multiweb/extension/WebViewExtension { - public abstract fun onControllerAttached (Lio/github/multiweb/api/WebViewController;)V - public abstract fun onControllerDisposed ()V + public fun onControllerAttached (Lio/github/multiweb/api/WebViewController;)V + public fun onControllerDisposed ()V } public final class io/github/multiweb/extension/WebViewControllerLifecycleExtension$DefaultImpls { @@ -412,13 +412,13 @@ public final class io/github/multiweb/extension/WebViewControllerLifecycleExtens } public abstract interface class io/github/multiweb/extension/WebViewExtension { - public abstract fun getScriptBridges ()Ljava/util/List; - public abstract fun onContextAction (Lio/github/multiweb/extension/WebContextAction;)V - public abstract fun onDownloadRequested (Lio/github/multiweb/extension/DownloadRequest;)V - public abstract fun onHostUiRequest (Lio/github/multiweb/extension/HostUiRequest;)V - public abstract fun onPageError (Lio/github/multiweb/extension/PageErrorEvent;)V - public abstract fun onPageFinished (Lio/github/multiweb/extension/PageFinishedEvent;)V - public abstract fun onPageStarted (Lio/github/multiweb/extension/PageStartedEvent;)V + public fun getScriptBridges ()Ljava/util/List; + public fun onContextAction (Lio/github/multiweb/extension/WebContextAction;)V + public fun onDownloadRequested (Lio/github/multiweb/extension/DownloadRequest;)V + public fun onHostUiRequest (Lio/github/multiweb/extension/HostUiRequest;)V + public fun onPageError (Lio/github/multiweb/extension/PageErrorEvent;)V + public fun onPageFinished (Lio/github/multiweb/extension/PageFinishedEvent;)V + public fun onPageStarted (Lio/github/multiweb/extension/PageStartedEvent;)V } public final class io/github/multiweb/extension/WebViewExtension$DefaultImpls { diff --git a/webview-extension-api/api/jvm/webview-extension-api.api b/webview-extension-api/api/jvm/webview-extension-api.api index 6683b2f..9c1095a 100644 --- a/webview-extension-api/api/jvm/webview-extension-api.api +++ b/webview-extension-api/api/jvm/webview-extension-api.api @@ -395,8 +395,8 @@ public final class io/github/multiweb/extension/WebFileChooserResult$Selected : } public abstract interface class io/github/multiweb/extension/WebViewControllerLifecycleExtension : io/github/multiweb/extension/WebViewExtension { - public abstract fun onControllerAttached (Lio/github/multiweb/api/WebViewController;)V - public abstract fun onControllerDisposed ()V + public fun onControllerAttached (Lio/github/multiweb/api/WebViewController;)V + public fun onControllerDisposed ()V } public final class io/github/multiweb/extension/WebViewControllerLifecycleExtension$DefaultImpls { @@ -412,13 +412,13 @@ public final class io/github/multiweb/extension/WebViewControllerLifecycleExtens } public abstract interface class io/github/multiweb/extension/WebViewExtension { - public abstract fun getScriptBridges ()Ljava/util/List; - public abstract fun onContextAction (Lio/github/multiweb/extension/WebContextAction;)V - public abstract fun onDownloadRequested (Lio/github/multiweb/extension/DownloadRequest;)V - public abstract fun onHostUiRequest (Lio/github/multiweb/extension/HostUiRequest;)V - public abstract fun onPageError (Lio/github/multiweb/extension/PageErrorEvent;)V - public abstract fun onPageFinished (Lio/github/multiweb/extension/PageFinishedEvent;)V - public abstract fun onPageStarted (Lio/github/multiweb/extension/PageStartedEvent;)V + public fun getScriptBridges ()Ljava/util/List; + public fun onContextAction (Lio/github/multiweb/extension/WebContextAction;)V + public fun onDownloadRequested (Lio/github/multiweb/extension/DownloadRequest;)V + public fun onHostUiRequest (Lio/github/multiweb/extension/HostUiRequest;)V + public fun onPageError (Lio/github/multiweb/extension/PageErrorEvent;)V + public fun onPageFinished (Lio/github/multiweb/extension/PageFinishedEvent;)V + public fun onPageStarted (Lio/github/multiweb/extension/PageStartedEvent;)V } public final class io/github/multiweb/extension/WebViewExtension$DefaultImpls { diff --git a/webview-ios/build.gradle.kts b/webview-ios/build.gradle.kts index 4204c4a..073e622 100644 --- a/webview-ios/build.gradle.kts +++ b/webview-ios/build.gradle.kts @@ -1,17 +1,8 @@ -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget - plugins { id("multiweb.ios-library") } kotlin { - targets.withType().configureEach { - compilations.getByName("main").cinterops.create("multiWebFileChooser") { - definitionFile.set(project.file("src/nativeInterop/cinterop/MultiWebFileChooser.def")) - compilerOpts("-I${project.projectDir}/src/nativeInterop/cinterop") - } - } - sourceSets { iosMain.dependencies { api(project(":webview-api")) diff --git a/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt index 0b03d1a..874661a 100644 --- a/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt +++ b/webview-ios/src/iosMain/kotlin/io/github/multiweb/ios/IosWebViewController.kt @@ -1,8 +1,5 @@ package io.github.multiweb.ios -import io.github.multiweb.ios.filechooser.MultiWebFileChooserAllowsDirectories -import io.github.multiweb.ios.filechooser.MultiWebFileChooserAllowsMultipleSelection -import io.github.multiweb.ios.filechooser.MultiWebFileChooserDelegateProtocol import io.github.multiweb.api.NavigationDecision import io.github.multiweb.api.NavigationPolicy import io.github.multiweb.api.NavigationRequest @@ -45,6 +42,8 @@ import platform.WebKit.WKNavigationDelegateProtocol import platform.WebKit.WKNavigationResponse import platform.WebKit.WKNavigationResponsePolicy import platform.WebKit.WKNavigationTypeOther +import platform.WebKit.WKFrameInfo +import platform.WebKit.WKOpenPanelParameters import platform.WebKit.WKPreferences import platform.WebKit.WKContextMenuElementInfo import platform.WebKit.WKUIDelegateProtocol @@ -346,14 +345,14 @@ class IosWebViewController( * 上传保持系统不支持状态。无论宿主处理器是否存在,都不会触发 UIKit 的默认选取器或隐式申请媒体权限。 */ private fun handleFileChooser( - parameters: Any?, + parameters: WKOpenPanelParameters, completionHandler: (List<*>?) -> Unit, ) { activeFileChooserCompletion?.invoke(null) activeFileChooserCompletion = completionHandler val request = WebFileChooserRequest( - allowMultipleSelection = MultiWebFileChooserAllowsMultipleSelection(parameters), - allowDirectories = MultiWebFileChooserAllowsDirectories(parameters), + allowMultipleSelection = parameters.allowsMultipleSelection, + allowDirectories = parameters.allowsDirectories, ) val handler = fileChooserHandler if (handler == null) { @@ -515,7 +514,7 @@ class IosWebViewController( /** 仅观察 WebKit 系统上下文菜单;不提供自定义配置,以保留系统默认菜单和预览行为。 */ private class IosUiDelegate( private val controller: IosWebViewController, - ) : NSObject(), WKUIDelegateProtocol, MultiWebFileChooserDelegateProtocol { + ) : NSObject(), WKUIDelegateProtocol { @ObjCSignatureOverride override fun webView( webView: WKWebView, @@ -526,12 +525,11 @@ class IosWebViewController( /** iOS 18.4+ 的网页文件上传入口;宿主完成回调前 WebKit 会保持当前选择请求。 */ override fun webView( - webView: Any?, - runOpenPanelWithParameters: Any?, - initiatedByFrame: Any?, - completionHandler: ((List<*>?) -> Unit)?, + webView: WKWebView, + runOpenPanelWithParameters: WKOpenPanelParameters, + initiatedByFrame: WKFrameInfo, + completionHandler: (List<*>?) -> Unit, ) { - completionHandler ?: return controller.handleFileChooser(runOpenPanelWithParameters, completionHandler) } } diff --git a/webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.def b/webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.def deleted file mode 100644 index f2cd476..0000000 --- a/webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.def +++ /dev/null @@ -1,4 +0,0 @@ -language = Objective-C -package = io.github.multiweb.ios.filechooser -headers = MultiWebFileChooser.h -linkerOpts = -framework WebKit diff --git a/webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.h b/webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.h deleted file mode 100644 index 9c4c0a0..0000000 --- a/webview-ios/src/nativeInterop/cinterop/MultiWebFileChooser.h +++ /dev/null @@ -1,28 +0,0 @@ -#import - -/** - * iOS 18.4 新增的 WKUIDelegate 文件面板回调。 - * - * Kotlin 2.1.21 的系统 WebKit 绑定尚未导出该协议成员;此处仅以 Objective-C 对象 ABI 声明选择器,避免把 - * 整个工程升级到尚未验证兼容性的 Kotlin/Compose 版本。WebKit 只会在支持该 API 的系统上调用该选择器。 - */ -@protocol MultiWebFileChooserDelegate -- (void)webView:(id)webView -runOpenPanelWithParameters:(id)parameters -initiatedByFrame:(id)frame -completionHandler:(void (^)(NSArray * _Nullable URLs))completionHandler; -@end - -/** 仅在 WebKit 已调用上述 iOS 18.4+ 选择器时读取参数,旧系统不会执行这些消息。 */ -@interface NSObject (MultiWebFileChooserParameters) -- (BOOL)allowsMultipleSelection; -- (BOOL)allowsDirectories; -@end - -static inline BOOL MultiWebFileChooserAllowsMultipleSelection(id parameters) { - return [parameters allowsMultipleSelection]; -} - -static inline BOOL MultiWebFileChooserAllowsDirectories(id parameters) { - return [parameters allowsDirectories]; -}