Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 1 addition & 27 deletions .github/workflows/pr-governance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,41 +21,15 @@ jobs:
runs-on: ubuntu-latest

steps:
- name: 校验分支命名与 PR 开发记录
- name: 校验分支命名
uses: actions/github-script@v7
with:
script: |
const branch = context.payload.pull_request.head.ref;
const body = context.payload.pull_request.body || "";
const branchPattern = /^generalio\/(feature|fix|docs|chore|refactor|test)\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
const requiredSections = [
"目的",
"改动内容",
"平台影响",
"验证命令与结果",
"风险与兼容性",
"文档变更",
];

if (!branchPattern.test(branch)) {
core.setFailed(
`源分支必须匹配 generalio/<类型>/<主题>,当前为:${branch}。允许的类型:feature、fix、docs、chore、refactor、test。`,
);
return;
}

const headings = [...body.matchAll(/^##\s+(.+?)\s*$/gm)];
const sections = new Map(
headings.map((heading, index) => [
heading[1].trim(),
body
.slice(heading.index + heading[0].length, headings[index + 1]?.index)
.replace(/<!--(?:.|\n)*?-->/g, "")
.trim(),
]),
);
const missingSections = requiredSections.filter((section) => !sections.get(section));

if (missingSections.length > 0) {
core.setFailed(`PR 开发记录缺少或未填写章节:${missingSections.join("、")}。请使用 PR 模板完整说明。`);
}
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,29 @@ fun HelpPage() {
}
```

Desktop 在首次组合上述页面前,还需要由应用入口创建一次 JCEF 并注入运行时。`CefApp` 是进程级资源,
MultiWeb 不会替宿主销毁它
Desktop Compose 还需要在应用入口初始化一次进程级 JCEF。macOS 的 windowed JCEF 推荐使用受控退出入口:它会先
关闭所有 Compose WebView,确认 CEF 已终止后再退出 Compose,避免 Cmd+Q 与原生清理竞争

```kotlin
val cefApp = CefAppBuilder().build()

DesktopWebViewRuntime.initialize(
cefApp = cefApp,
onBrowserClosed = {
// 确认没有其他浏览器后,由宿主调用 cefApp.dispose()。
},
)
fun main() {
DesktopWebViewRuntime.prepareComposeInterop()

val cefApp = CefAppBuilder().apply {
setAppHandler(DesktopWebViewRuntime.createMacOsTerminationHandler())
}.build()

DesktopWebViewRuntime.initialize(cefApp)

application {
DesktopWebViewRuntime.bindApplicationExit(::exitApplication)

Window(
onCloseRequest = DesktopWebViewRuntime::requestApplicationExit,
) {
// Compose 内容
}
}
}
```

完整的依赖、初始化与各平台接入方式见[使用指南](docs/使用指南.md)。
Expand Down
53 changes: 37 additions & 16 deletions docs/使用指南.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,21 +94,43 @@ Activity、UIViewController 或桌面窗口复用。
### Desktop 运行时初始化

JCEF 的 `CefApp` 是进程级资源,必须由桌面宿主在首次调用 `rememberWebViewController` 前创建并只注入一次。
MultiWeb 会在浏览器和 Client 均关闭后回调 `onBrowserClosed`,但绝不会自动调用 `CefApp.dispose()`:
使用 Compose Desktop 时,建议将窗口关闭和 macOS Cmd+Q 都交给 `DesktopWebViewRuntime` 协调:运行时会先关闭其注册的
Compose 控制器,等待每个浏览器的原生关闭回调,再调用一次 `CefApp.dispose()`;只有 `CefApp.getState()` 变为
`TERMINATED` 后才会调用宿主退出回调。

```kotlin
val cefApp = CefAppBuilder().build()
fun main() {
// 必须早于 macOS Compose application 创建;不覆盖宿主已设置的互操作属性。
DesktopWebViewRuntime.prepareComposeInterop()

DesktopWebViewRuntime.initialize(
cefApp = cefApp,
onBrowserClosed = {
// 确认应用中没有其他浏览器后,再由宿主销毁 cefApp。
},
)
val cefApp = CefAppBuilder().apply {
setAppHandler(DesktopWebViewRuntime.createMacOsTerminationHandler())
}.build()
DesktopWebViewRuntime.initialize(cefApp)

application {
DesktopWebViewRuntime.bindApplicationExit(::exitApplication)

Window(
onCloseRequest = DesktopWebViewRuntime::requestApplicationExit,
) {
// 包含 rememberWebViewController(...) 的 Compose 内容
}
}
}
```

请在桌面应用的启动代码中调用,而不是放入可重组的 Composable。窗口关闭时应调用 Controller 的 `dispose()`;
等待 `onBrowserClosed` 后再结束 JCEF 进程。
`initialize(...)`、`bindApplicationExit(...)` 均只能调用一次,且不能放入可重组的 Composable。旧的
`initialize(cefApp, onBrowserClosed)` 仍可使用以保持兼容;采用新的受控退出方式时,**不要**在旧回调中自行调用
`cefApp.dispose()` 或退出应用。若宿主需要自定义 `MavenCefAppHandlerAdapter`,其 `onBeforeTerminate()` 应调用
`DesktopWebViewRuntime.requestApplicationExit()` 并返回 `true`:

```kotlin
override fun onBeforeTerminate(): Boolean {
DesktopWebViewRuntime.requestApplicationExit()
return true
}
```

## 3. 非 Compose 平台接入

Expand Down Expand Up @@ -193,7 +215,8 @@ view.addSubview(controller.view)
### Desktop

在 Swing EDT 创建 `DesktopWebViewController`,把 `controller.view` 放入 Swing/AWT 容器。进程级 `CefApp` 的创建、
配置和最终销毁都属于宿主职责:
配置和最终销毁都属于宿主职责;非 Compose 宿主必须等待所有控制器的 `onBrowserClosed` 通知后再调用
`cefApp.dispose()`:

```kotlin
val controller = DesktopWebViewController(
Expand All @@ -219,14 +242,12 @@ macOS 使用 windowed JCEF 与 `SwingPanel` 嵌入原生浏览器。应在创建
fun main() {
DesktopWebViewRuntime.prepareComposeInterop()

application {
// 创建 CefApp,并调用 DesktopWebViewRuntime.initialize(...)
}
// 创建 CefApp、调用 DesktopWebViewRuntime.initialize(...) 后再创建 application。
}
```

浏览器创建完成且 Swing 视图首次显示后,MultiWeb 会同步执行一次布局、即时绘制和后续重绘,触发 JCEF 的 windowed
原生子窗口绑定。调用方无需在 Compose 重组中手动调用 `repaint()`。
浏览器创建完成后,MultiWeb 会继续等待 Swing 视图同时处于 showing 且首次获得有效尺寸,再同步执行一次布局、即时绘制和
后续重绘,触发 JCEF 的 windowed 原生子窗口绑定。调用方无需在 Compose 重组中手动调用 `repaint()`。

### JS 与 Wasm

Expand Down
11 changes: 6 additions & 5 deletions docs/架构说明.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ webview-test-fixtures ──> webview-api(仅工程内测试,不发布)
| `webview-ios` | `WKWebView`、导航代理和 WebKit 消息桥。 | 精确控制 WebKit 的系统级第三方 Cookie 策略。 |
| `webview-desktop` | JCEF 浏览器、事件处理、受限桥和关闭流程。 | 管理进程级 `CefApp` 的创建与销毁。 |
| `webview-browser` | JS/Wasm 的 URL 校验和新窗口打开。 | 伪装为嵌入式 WebView 或清理浏览器全局会话。 |
| `webview-compose` | 统一的 Compose Controller 创建与原生视图嵌入入口。 | 把 Android、UIKit、JCEF 类型泄漏到 common API,或管理进程级 `CefApp`。 |
| `webview-compose` | 统一的 Compose Controller 创建、原生视图嵌入和 Desktop 受控退出入口。 | 把 Android、UIKit、JCEF 类型泄漏到 common API。 |
| `webview-test-fixtures` | 示例和工程内的非设备契约测试替身。 | 作为对外 Maven API 或发布构件。 |

## 调用流程
Expand Down Expand Up @@ -65,14 +65,15 @@ webview-test-fixtures ──> webview-api(仅工程内测试,不发布)
| --- | --- |
| Android | Compose 入口自动转发前后台并释放;直接使用平台控制器时,在主线程挂载 `controller.view`、转发 `onHostPause()`、`onHostResume()` 并调用 `dispose()`。 |
| iOS | Compose 入口自动释放;直接使用平台控制器时,在主线程将 `controller.view` 添加到 UIKit 层级并调用 `dispose()`。 |
| Desktop | Compose 入口在 Swing EDT 创建和关闭浏览器;宿主仍须通过 `DesktopWebViewRuntime.initialize` 注入进程级 `CefApp`并在 `onBrowserClosed` 后自行销毁。 |
| Desktop | Compose 入口在 Swing EDT 创建和关闭浏览器;宿主通过 `DesktopWebViewRuntime.initialize` 注入进程级 `CefApp`。使用运行时的受控退出 API 时,由其在所有 Compose 浏览器正常关闭后销毁一次 CEF,并在 `TERMINATED` 后回调宿主退出;直接使用控制器时仍由宿主等待全部 `onBrowserClosed` 后销毁 CEF。 |
| JS/Wasm | 无原生视图,按策略在新窗口或新标签页打开 URL;不持有浏览器会话。 |

Desktop 的 JCEF 采用 windowed 模式,不切换 OSR。控制器会在 JCEF 浏览器创建完成和 Swing 组件首次处于 showing 状态后,
在 Swing EDT 主动完成一次 `revalidate()`、`paintImmediately()` 与 `repaint()`,以同步进入 JCEF `JPanel.paint()`,触发
Desktop 的 JCEF 采用 windowed 模式,不切换 OSR。控制器会在 JCEF 浏览器创建完成、Swing 组件处于 showing 状态且首次获得
有效尺寸后,在 Swing EDT 主动完成一次 `revalidate()`、`paintImmediately()` 与 `repaint()`,以同步进入 JCEF `JPanel.paint()`,触发
`doUpdate()` 与原生子窗口绑定,避免 Compose Desktop 初次挂载时黑屏。`DesktopWebViewRuntime.prepareComposeInterop()` 应在
macOS 的 Compose `application {}` 前调用;它仅在宿主未设置 `compose.interop.blending` 时提供 `true` 的互操作默认值,
`initialize` 仍保留兜底调用以兼容旧接入方。
`initialize` 仍保留兜底调用以兼容旧接入方。macOS 需在创建 `CefApp` 前安装
`DesktopWebViewRuntime.createMacOsTerminationHandler()`;它会将 Cmd+Q 转入与 `Window.onCloseRequest` 相同的受控退出路径。

## 扩展方式

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import androidx.compose.ui.window.WindowPlacement
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import io.github.multiweb.compose.DesktopWebViewRuntime
import io.github.multiweb.api.WebViewController
import io.github.multiweb.extension.HostUiRequest
import io.github.multiweb.sample.SampleWebViewApp
import io.github.multiweb.sample.SampleWebViewExtension
Expand Down Expand Up @@ -84,19 +83,14 @@ private fun runDesktopSampleApplication() = application {
// JCEF 原生运行时体积较大,放入用户目录以便多次启动复用,避免污染项目工作区。
setInstallDir(jcefInstallDirectory())
configureCefUserDataDirectory()
setAppHandler(DesktopWebViewRuntime.createMacOsTerminationHandler())
}.build()
}
remember(cefApp) {
DesktopWebViewRuntime.initialize(
cefApp = cefApp,
onBrowserClosed = {
// JCEF 在原生关闭回调后才允许销毁进程级 CefApp,随后再结束 Compose 事件循环。
SwingUtilities.invokeLater {
cefApp.dispose()
exitApplication()
}
},
)
DesktopWebViewRuntime.initialize(cefApp)
}
remember {
DesktopWebViewRuntime.bindApplicationExit(::exitApplication)
}
val initialization = remember(extension, nativeBridgeExtension) {
sampleWebViewInitialization(
Expand All @@ -110,11 +104,9 @@ private fun runDesktopSampleApplication() = application {
)
}
}
var controller by remember { mutableStateOf<WebViewController?>(null) }

Window(
// 不直接退出 Compose;先等待 JCEF 确认浏览器关闭,避免 macOS AppKit 访问已释放的原生对象。
onCloseRequest = { controller?.dispose() },
// 不直接退出 Compose;先等待 JCEF 确认浏览器关闭和进程终止,避免 macOS AppKit 访问已释放的原生对象。
onCloseRequest = DesktopWebViewRuntime::requestApplicationExit,
state = windowState,
title = "MultiWeb Compose 示例",
) {
Expand All @@ -130,7 +122,7 @@ private fun runDesktopSampleApplication() = application {
}
},
onImageSaveDismissed = { pendingImageSaveUrl = null },
onWebViewControllerReady = { controller = it },
onWebViewControllerReady = {},
)
}
}
Expand Down
3 changes: 3 additions & 0 deletions webview-compose/api/jvm/webview-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ public final class io/github/multiweb/compose/ComposeWebView_desktopKt {
public final class io/github/multiweb/compose/DesktopWebViewRuntime {
public static final field $stable I
public static final field INSTANCE Lio/github/multiweb/compose/DesktopWebViewRuntime;
public final fun bindApplicationExit (Lkotlin/jvm/functions/Function0;)V
public final fun createMacOsTerminationHandler ()Lme/friwi/jcefmaven/MavenCefAppHandlerAdapter;
public final fun initialize (Lorg/cef/CefApp;Lkotlin/jvm/functions/Function0;)V
public static synthetic fun initialize$default (Lio/github/multiweb/compose/DesktopWebViewRuntime;Lorg/cef/CefApp;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V
public final fun prepareComposeInterop ()V
public final fun requestApplicationExit ()V
}

public final class io/github/multiweb/compose/WebViewHostCallbacks {
Expand Down
Loading
Loading