v1.0.1: fully reflection-based browser (EmbeddedBrowser/EmbeddedJsBridge), zero compile-time JCEF deps; panel never blank

This commit is contained in:
harmness
2026-09-10 22:26:03 +08:00
parent 3fd4a01b4b
commit 47f3129dfb
4 changed files with 269 additions and 131 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ plugins {
} }
group = "com.prepeep" group = "com.prepeep"
version = "1.0.0" version = "1.0.1"
repositories { repositories {
mavenCentral() mavenCentral()
@@ -0,0 +1,118 @@
package com.harmness.toolwindow
import com.intellij.openapi.Disposable
import com.intellij.openapi.diagnostic.Logger
import java.lang.reflect.InvocationTargetException
import javax.swing.JComponent
/**
* JCEF 浏览器的纯反射封装。
*
* 目标:让插件代码在**编译期完全不引用** com.intellij.ui.jcef.*
* (以及 org.cef.*),避免 IDE 缺少 JCEF 类时出现
* NoClassDefFoundError 把整个面板拖垮。
*
* 所有 JCEF 调用都走反射;类不存在或调用失败时返回 null / 静默降级。
*/
class EmbeddedBrowser internal constructor(val instance: Any) {
/** 浏览器 Swing 组件(可加入面板);失败返回 null */
val component: JComponent?
get() = try {
instance.invoke("getComponent") as? JComponent
} catch (t: Throwable) {
log.warn("getComponent failed", t)
null
}
fun load(url: String): Boolean = try {
instance.invoke("loadURL", url)
true
} catch (t: Throwable) {
log.warn("loadURL failed for $url", t)
false
}
fun canGoBack(): Boolean = cefCall("canGoBack") as? Boolean ?: false
fun goBack(): Boolean = cefCall("goBack") != null
fun canGoForward(): Boolean = cefCall("canGoForward") as? Boolean ?: false
fun goForward(): Boolean = cefCall("goForward") != null
fun reload(): Boolean = cefCall("reload") != null
fun dispose() {
try {
instance.invoke("dispose")
} catch (t: Throwable) {
log.warn("browser dispose failed", t)
}
}
// org.cef.browser.CefBrowser 上的方法(getCefBrowser() 返回它)
private fun cefCall(name: String, vararg args: Any?): Any? = try {
val cef = instance.invoke("getCefBrowser") ?: return null
cef.invoke(name, *args)
} catch (t: Throwable) {
log.warn("cefCall $name failed", t)
null
}
private fun Any.invoke(method: String, vararg args: Any?): Any? {
val clazz = this::class.java
return try {
for (m in clazz.methods) {
if (m.name == method && m.parameterCount == args.size) {
m.isAccessible = true
return m.invoke(this, *args)
}
}
// 按名字+精确参数类型再找一次
val types = args.map { it?.javaClass ?: String::class.java }.toTypedArray()
val m = clazz.getMethod(method, *types)
m.isAccessible = true
m.invoke(this, *args)
} catch (t: Throwable) {
log.warn("reflect invoke $method failed", t)
null
}
}
companion object {
private val log = Logger.getInstance(EmbeddedBrowser::class.java)
/** IDE 是否提供可用 JCEFJBCefApp.isSupported 的反射版本) */
fun isJcefSupported(): Boolean = try {
val app = Class.forName("com.intellij.ui.jcef.JBCefApp")
val m = app.getMethod("isSupported")
m.invoke(null) as? Boolean ?: false
} catch (t: Throwable) {
log.info("JCEF not supported in this IDE: ${t.javaClass.simpleName}")
false
}
/**
* 尝试创建嵌入式浏览器。
* @return 成功返回封装对象;失败(类缺失 / 不支持 / 构造异常)返回 null
*/
fun create(): EmbeddedBrowser? {
try {
val cls = Class.forName("com.intellij.ui.jcef.JBCefBrowser")
val ctor = cls.getConstructor()
val inst = ctor.newInstance()
// 验证 getComponent() 可用,避免构造成功但组件拿不到
inst.javaClass.getMethod("getComponent").invoke(inst)
return EmbeddedBrowser(inst)
} catch (t: Throwable) {
val real = when (t) {
is InvocationTargetException -> t.targetException ?: t
else -> t
}
log.warn("EmbeddedBrowser.create failed: ${real.javaClass.simpleName}: ${real.message}", real)
return null
}
}
}
}
@@ -0,0 +1,86 @@
package com.harmness.toolwindow
import com.intellij.openapi.Disposable
import com.intellij.openapi.diagnostic.Logger
import java.lang.reflect.InvocationTargetException
import java.util.function.Function
import javax.swing.JComponent
/**
* JCEF JS↔Java 桥的反射封装。
*
* 负责两件事:
* 1. 通过反射创建 JBCefJSQuery 并注册 handler(把网页发来的字符串请求转发给 [onRequest]
* 2. 通过反射调用 JBCefJSQuery.inject() 生成注入到页面的 JS 代码
*
* 与 [EmbeddedBrowser] 一样,编译期零 JCEF 依赖。类缺失/调用失败时
* 仅返回 null / 空串,不影响浏览器主体功能。
*/
class EmbeddedJsBridge private constructor(
private val queryInstance: Any,
private val onRequest: (String) -> Any?
) {
/**
* 返回可注入到页面 JS 的代码片段(调用插件 handler),
* 失败返回空串。
*/
fun injectJs(): String = try {
val m = queryInstance.javaClass.getMethod(
"inject", String::class.java, String::class.java, String::class.java
)
(m.invoke(queryInstance, "payload.query",
"function(response){ if (payload.callbacks && payload.callbacks.onSuccess) payload.callbacks.onSuccess(response); }",
"function(code,msg){ if (payload.callbacks && payload.callbacks.onFailure) payload.callbacks.onFailure(code,msg); }"
) as? String) ?: ""
} catch (t: Throwable) {
log.warn("injectJs failed", t)
""
}
fun dispose() {
try {
queryInstance.javaClass.getMethod("dispose").invoke(queryInstance)
} catch (t: Throwable) {
log.warn("bridge dispose failed", t)
}
}
companion object {
private val log = Logger.getInstance(EmbeddedJsBridge::class.java)
/**
* 为已创建的 [browser] 建立 JS 桥。
* @return 成功返回桥对象;失败返回 null(浏览器仍可用,仅网页无法调用插件 API)
*/
fun create(browser: EmbeddedBrowser, onRequest: (String) -> Any?): EmbeddedJsBridge? {
try {
val queryClass = Class.forName("com.intellij.ui.jcef.JBCefJSQuery")
// create(JBCefBrowserBase) — 用可赋值参数类型查找
val browserInst = browser.instance
val createMethod = queryClass.methods.firstOrNull { m ->
m.name == "create" && m.parameterCount == 1 &&
m.parameterTypes[0].isAssignableFrom(browserInst.javaClass)
} ?: return null
val queryInstance = createMethod.invoke(null, browserInst)
// addHandler(java.util.function.Function<String, Response>)
val handlerFn = Function<String, Any?> { req -> onRequest(req ?: "") }
val addMethod = queryClass.methods.firstOrNull { m ->
m.name == "addHandler" && m.parameterCount == 1 &&
m.parameterTypes[0] == Function::class.java
} ?: return null
addMethod.invoke(queryInstance, handlerFn)
return EmbeddedJsBridge(queryInstance, onRequest)
} catch (t: Throwable) {
val real = when (t) {
is InvocationTargetException -> t.targetException ?: t
else -> t
}
log.warn("EmbeddedJsBridge.create failed: ${real.javaClass.simpleName}: ${real.message}", real)
return null
}
}
}
}
@@ -1,16 +1,11 @@
package com.harmness.toolwindow package com.harmness.toolwindow
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable import com.intellij.openapi.Disposable
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBPanel import com.intellij.ui.components.JBPanel
import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.jcef.JBCefApp
import com.intellij.ui.jcef.JBCefBrowser
import org.cef.browser.CefBrowser
import org.cef.handler.CefLoadHandlerAdapter
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.CardLayout import java.awt.CardLayout
import java.awt.FlowLayout import java.awt.FlowLayout
@@ -34,11 +29,12 @@ import javax.swing.SwingUtilities
* *
* 四个视图(CardLayout): * 四个视图(CardLayout):
* - standby → 初始化中 * - standby → 初始化中
* - browser → 网页(JCEF,惰性初始化) * - browser → 网页(通过 [EmbeddedBrowser] 反射包装,惰性初始化)
* - settings → 起始地址设置表单 * - settings → 起始地址设置表单
* - error → 错误信息 + 一键复制按钮 * - error → 错误信息 + 一键复制
* *
* 工具栏和设置表单不依赖 JCEF,始终可用。 * 本类**不引用任何 JCEF 类**。浏览器能力全部走反射封装,
* 即使 IDE 没有 JCEF,工具栏 / 设置表单 / 错误卡片也始终可用。
*/ */
class HarmnessBrowserPanel( class HarmnessBrowserPanel(
private val project: com.intellij.openapi.project.Project, private val project: com.intellij.openapi.project.Project,
@@ -48,9 +44,9 @@ class HarmnessBrowserPanel(
private val settings = SettingsState.getInstance() private val settings = SettingsState.getInstance()
private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java) private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java)
// JCEF 对象(惰性初始化,可能为 null // 反射浏览器(可能为 null
private var browser: JBCefBrowser? = null private var browser: EmbeddedBrowser? = null
private var bridgeAvailable = false // 反射创建 JBCefJSQuery 是否成功 private var bridge: EmbeddedJsBridge? = null
private var addressField: JTextField = JTextField() private var addressField: JTextField = JTextField()
private var settingsUrlField: JTextField = JTextField(30) private var settingsUrlField: JTextField = JTextField(30)
@@ -59,9 +55,6 @@ class HarmnessBrowserPanel(
private val center = JBPanel<JBPanel<*>>(cardLayout) private val center = JBPanel<JBPanel<*>>(cardLayout)
private var currentCard = CARD_STANDBY private var currentCard = CARD_STANDBY
// 反射持有的 JSQuery 对象(类型为 Any,避免编译期依赖)
private var jsQuery: Any? = null
init { init {
Disposer.register(parentDisposable, this) Disposer.register(parentDisposable, this)
@@ -73,7 +66,7 @@ class HarmnessBrowserPanel(
center.add(createErrorCard(), CARD_ERROR) center.add(createErrorCard(), CARD_ERROR)
cardLayout.show(center, CARD_STANDBY) cardLayout.show(center, CARD_STANDBY)
// 延迟初始化浏览器,阻塞 EDT // 窗口就绪后惰性初始化浏览器,避免阻塞 EDT
SwingUtilities.invokeLater { initBrowserSafely() } SwingUtilities.invokeLater { initBrowserSafely() }
} }
@@ -83,19 +76,13 @@ class HarmnessBrowserPanel(
private fun createToolbar(): JComponent { private fun createToolbar(): JComponent {
val back = JButton(object : AbstractAction("") { val back = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) { runCatching { browser?.goBack() } }
runCatching { browser?.cefBrowser?.goBack() }
}
}) })
val forward = JButton(object : AbstractAction("") { val forward = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) { runCatching { browser?.goForward() } }
runCatching { browser?.cefBrowser?.goForward() }
}
}) })
val reload = JButton(object : AbstractAction("") { val reload = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) { runCatching { browser?.reload() } }
runCatching { browser?.cefBrowser?.reload() }
}
}) })
val settingsBtn = JButton(object : AbstractAction("") { val settingsBtn = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { override fun actionPerformed(e: ActionEvent) {
@@ -142,7 +129,7 @@ class HarmnessBrowserPanel(
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// JCEF 浏览器初始化(安全) // 浏览器初始化(反射,安全)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private fun initBrowserSafely() { private fun initBrowserSafely() {
@@ -152,108 +139,64 @@ class HarmnessBrowserPanel(
return return
} }
try { try {
if (!JBCefApp.isSupported()) { if (!EmbeddedBrowser.isJcefSupported()) {
showError("JCEF 不受支持", showError("JCEF 不受支持",
"当前 IDE 构建未启用 JCEF\n请使用 JetBrains 官方发行版(内置 JBR 运行时)。") "当前 IDE 构建未启用 JCEF(找不到 com.intellij.ui.jcef.JBCefBrowser)。\n" +
"请使用 JetBrains 官方发行版 IDE,或更换为内置 JCEF 的构建。")
return
}
val b = EmbeddedBrowser.create()
?: run {
showError("无法创建浏览器",
"反射创建 JBCefBrowser 失败。请检查 IDE 是否启用 JCEF。")
return return
} }
val b = JBCefBrowser()
browser = b browser = b
// 通过反射安全初始化 JS↔Java 桥 // JS 桥(失败不影响浏览器)
setupBridgeViaReflection(b) bridge = EmbeddedJsBridge.create(b) { request ->
logger.debug("bridge request: $request")
try {
val responseClass = Class.forName("com.intellij.ui.jcef.JBCefJSQuery\$Response")
val ctor = responseClass.getConstructor(String::class.java)
ctor.newInstance(handleBridgeRequest(request))
} catch (_: Throwable) {
// 无 Response 类:直接返回字符串
handleBridgeRequest(request)
}
}
// 注入浏览器组件 // 浏览器组件加入 "browser" 卡片
center.add(b.component, CARD_BROWSER) val comp = b.component
if (comp != null) {
center.add(comp, CARD_BROWSER)
showCard(CARD_BROWSER) showCard(CARD_BROWSER)
loadCurrentUrl() loadCurrentUrl()
} else {
showError("浏览器组件不可用", "JBCefBrowser.getComponent() 返回空。")
}
} catch (t: Throwable) { } catch (t: Throwable) {
logger.error("JCEF init failed", t) logger.error("JCEF init failed", t)
showError("JCEF 初始化失败", buildErrorText(t)) showError("JCEF 初始化失败", buildErrorText(t))
} }
} }
// ------------------------------------------------------------------ private fun handleBridgeRequest(request: String): String = when {
// JS ↔ Java 桥(全反射,编译期零 JBCefJSQuery 依赖) request == "ping" -> "pong"
// ------------------------------------------------------------------ request == "getUrl" -> browser?.let {
private fun setupBridgeViaReflection(b: JBCefBrowser) {
try { try {
// 1) 检查 JBCefJSQuery 类是否可用 val cef = it.instance.javaClass.getMethod("getCefBrowser").invoke(it.instance)
val queryClass = try { cef.javaClass.getMethod("getUrl").invoke(cef) as? String ?: ""
Class.forName("com.intellij.ui.jcef.JBCefJSQuery") } catch (_: Throwable) { "" }
} catch (_: Throwable) { } ?: ""
logger.info("JBCefJSQuery class not available; bridge disabled") request.startsWith("openExternal:") -> {
return
}
// 2) 反射创建 JBCefJSQuery.create(JBCefBrowser)
val createMethod = queryClass.getMethod("create", JBCefBrowser::class.java)
val queryInstance = createMethod.invoke(null, b) ?: return
jsQuery = queryInstance
// 3) 用反射构建 Handler(避免编译期引用 Response 内部类)
val handlerInterface = Class.forName("com.intellij.ui.jcef.JBCefJSQuery\$Handler")
val handlerProxy = java.lang.reflect.Proxy.newProxyInstance(
handlerInterface.classLoader,
arrayOf(handlerInterface)
) { _, method, args ->
when (method.name) {
"success" -> {
val response = args?.getOrNull(0)?.toString() ?: ""
logger.debug("Bridge success: $response")
}
"error" -> {
logger.warn("Bridge error: code=${args?.getOrNull(0)}, msg=${args?.getOrNull(1)}")
}
else -> null
}
}
val addHandlerMethod = queryClass.getMethod("addHandler", handlerInterface)
addHandlerMethod.invoke(queryInstance, handlerProxy)
// 4) 反射调用 inject() 获取注入到页面的 JS 代码
val injectMethod = queryClass.getMethod(
"inject", String::class.java, String::class.java, String::class.java
)
val injectedJs = injectMethod.invoke(
queryInstance,
"payload.query",
"function(r){}",
"function(c,m){}"
) as? String ?: ""
// 5) 在页面加载完成后注入桥接函数
b.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() {
override fun onLoadEnd(
cBrowser: CefBrowser,
frame: org.cef.browser.CefFrame,
httpStatusCode: Int
) {
if (!frame.isMain) return
try { try {
val js = """ com.intellij.ide.BrowserUtil.browse(request.removePrefix("openExternal:"))
window.harmnessBridge = function(payload) { } catch (_: Throwable) {}
$injectedJs "ok"
};
if (window.__HARMNESS_READY__) window.__HARMNESS_READY__();
""".trimIndent()
cBrowser.executeJavaScript(js, cBrowser.url ?: "", 0)
} catch (t: Throwable) {
logger.warn("Bridge inject failed", t)
}
}
}, b.cefBrowser)
bridgeAvailable = true
logger.info("JS↔Java bridge initialized successfully via reflection")
} catch (t: Throwable) {
logger.warn("Bridge init failed (browser still works without bridge): ${t.message}")
bridgeAvailable = false
// 不中断:浏览器本身仍可用,只是网页无法调用插件 API
} }
else -> "unknown request: $request"
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -271,11 +214,11 @@ class HarmnessBrowserPanel(
appendLine(detail) appendLine(detail)
appendLine() appendLine()
appendLine("--- 环境信息 ---") appendLine("--- 环境信息 ---")
appendLine("插件版本: 1.0.0") appendLine("插件版本: 1.0.1")
appendLine("IDE Build: $appInfo") appendLine("IDE Build: $appInfo")
appendLine("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")}") appendLine("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")}")
appendLine("Java: ${System.getProperty("java.version")}") appendLine("Java: ${System.getProperty("java.version")}")
appendLine("JCEF 支持: ${try { JBCefApp.isSupported() } catch (_: Throwable) { "检查失败" }}") appendLine("JCEF 支持: ${EmbeddedBrowser.isJcefSupported()}")
} }
val area = center.getClientProperty("errorArea") as? JTextArea val area = center.getClientProperty("errorArea") as? JTextArea
if (area != null) { if (area != null) {
@@ -340,24 +283,20 @@ class HarmnessBrowserPanel(
anchor = GridBagConstraints.NORTHWEST anchor = GridBagConstraints.NORTHWEST
} }
// 标题
panel.add(JBLabel("<html><h2 style='margin:0'>⚙ 设置</h2></html>"), gbc.also { panel.add(JBLabel("<html><h2 style='margin:0'>⚙ 设置</h2></html>"), gbc.also {
it.gridx = 0; it.gridy = 0; it.gridwidth = 2 it.gridx = 0; it.gridy = 0; it.gridwidth = 2
}) })
gbc.gridwidth = 1 gbc.gridwidth = 1
// 起始地址
panel.add(JBLabel("起始地址:"), gbc.also { it.gridx = 0; it.gridy = 1 }) panel.add(JBLabel("起始地址:"), gbc.also { it.gridx = 0; it.gridy = 1 })
settingsUrlField = JTextField(settings.startUrl, 28) settingsUrlField = JTextField(settings.startUrl, 28)
panel.add(settingsUrlField, gbc.also { it.gridx = 1; it.gridy = 1 }) panel.add(settingsUrlField, gbc.also { it.gridx = 1; it.gridy = 1 })
// 提示
panel.add(JBLabel("<html><small>例如:http://127.0.0.1:8899<br>" + panel.add(JBLabel("<html><small>例如:http://127.0.0.1:8899<br>" +
"目标站点须允许被 iframe 嵌入(无 X-Frame-Options / CSP 限制)</small></html>"), "目标站点须允许被 iframe 嵌入(无 X-Frame-Options / CSP 限制)</small></html>"),
gbc.also { it.gridx = 0; it.gridy = 2; it.gridwidth = 2 }) gbc.also { it.gridx = 0; it.gridy = 2; it.gridwidth = 2 })
gbc.gridwidth = 1 gbc.gridwidth = 1
// 按钮
val saveBtn = JButton("保存") val saveBtn = JButton("保存")
saveBtn.addActionListener { saveBtn.addActionListener {
settings.startUrl = settingsUrlField.text.trim() settings.startUrl = settingsUrlField.text.trim()
@@ -376,7 +315,6 @@ class HarmnessBrowserPanel(
} }
panel.add(btnPanel, gbc.also { it.gridx = 0; it.gridy = 3; it.gridwidth = 2 }) panel.add(btnPanel, gbc.also { it.gridx = 0; it.gridy = 3; it.gridwidth = 2 })
// 弹性填充
panel.add(JBPanel<JBPanel<*>>(), gbc.also { panel.add(JBPanel<JBPanel<*>>(), gbc.also {
it.gridy = 4; it.weighty = 1.0 it.gridy = 4; it.weighty = 1.0
}) })
@@ -391,14 +329,14 @@ class HarmnessBrowserPanel(
private fun navigate(url: String) { private fun navigate(url: String) {
val b = browser ?: return val b = browser ?: return
val fixed = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url" val fixed = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
b.loadURL(fixed) b.load(fixed)
} }
fun loadCurrentUrl() { fun loadCurrentUrl() {
val b = browser ?: return val b = browser ?: return
val url = settings.startUrl val url = settings.startUrl
addressField.text = url addressField.text = url
b.loadURL(url) b.load(url)
} }
fun onToolWindowSelected() { fun onToolWindowSelected() {
@@ -410,14 +348,10 @@ class HarmnessBrowserPanel(
// ------------------------------------------------------------------ // ------------------------------------------------------------------
override fun dispose() { override fun dispose() {
// 反射释放 jsQuery try { bridge?.dispose() } catch (_: Throwable) {}
try { try { browser?.dispose() } catch (_: Throwable) {}
jsQuery?.javaClass?.getMethod("dispose")?.invoke(jsQuery) bridge = null
} catch (_: Throwable) {}
try { browser?.let { Disposer.dispose(it) } } catch (_: Throwable) {}
browser = null browser = null
jsQuery = null
bridgeAvailable = false
} }
companion object { companion object {