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"
version = "1.0.0"
version = "1.0.1"
repositories {
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
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.Disposable
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.Disposer
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBPanel
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.CardLayout
import java.awt.FlowLayout
@@ -34,11 +29,12 @@ import javax.swing.SwingUtilities
*
* 四个视图(CardLayout):
* - standby → 初始化中
* - browser → 网页(JCEF,惰性初始化)
* - browser → 网页(通过 [EmbeddedBrowser] 反射包装,惰性初始化)
* - settings → 起始地址设置表单
* - error → 错误信息 + 一键复制按钮
* - error → 错误信息 + 一键复制
*
* 工具栏和设置表单不依赖 JCEF,始终可用。
* 本类**不引用任何 JCEF 类**。浏览器能力全部走反射封装,
* 即使 IDE 没有 JCEF,工具栏 / 设置表单 / 错误卡片也始终可用。
*/
class HarmnessBrowserPanel(
private val project: com.intellij.openapi.project.Project,
@@ -48,9 +44,9 @@ class HarmnessBrowserPanel(
private val settings = SettingsState.getInstance()
private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java)
// JCEF 对象(惰性初始化,可能为 null
private var browser: JBCefBrowser? = null
private var bridgeAvailable = false // 反射创建 JBCefJSQuery 是否成功
// 反射浏览器(可能为 null
private var browser: EmbeddedBrowser? = null
private var bridge: EmbeddedJsBridge? = null
private var addressField: JTextField = JTextField()
private var settingsUrlField: JTextField = JTextField(30)
@@ -59,9 +55,6 @@ class HarmnessBrowserPanel(
private val center = JBPanel<JBPanel<*>>(cardLayout)
private var currentCard = CARD_STANDBY
// 反射持有的 JSQuery 对象(类型为 Any,避免编译期依赖)
private var jsQuery: Any? = null
init {
Disposer.register(parentDisposable, this)
@@ -73,7 +66,7 @@ class HarmnessBrowserPanel(
center.add(createErrorCard(), CARD_ERROR)
cardLayout.show(center, CARD_STANDBY)
// 延迟初始化浏览器,阻塞 EDT
// 窗口就绪后惰性初始化浏览器,避免阻塞 EDT
SwingUtilities.invokeLater { initBrowserSafely() }
}
@@ -83,19 +76,13 @@ class HarmnessBrowserPanel(
private fun createToolbar(): JComponent {
val back = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
runCatching { browser?.cefBrowser?.goBack() }
}
override fun actionPerformed(e: ActionEvent) { runCatching { browser?.goBack() } }
})
val forward = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
runCatching { browser?.cefBrowser?.goForward() }
}
override fun actionPerformed(e: ActionEvent) { runCatching { browser?.goForward() } }
})
val reload = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
runCatching { browser?.cefBrowser?.reload() }
}
override fun actionPerformed(e: ActionEvent) { runCatching { browser?.reload() } }
})
val settingsBtn = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
@@ -142,7 +129,7 @@ class HarmnessBrowserPanel(
}
// ------------------------------------------------------------------
// JCEF 浏览器初始化(安全)
// 浏览器初始化(反射,安全)
// ------------------------------------------------------------------
private fun initBrowserSafely() {
@@ -152,108 +139,64 @@ class HarmnessBrowserPanel(
return
}
try {
if (!JBCefApp.isSupported()) {
if (!EmbeddedBrowser.isJcefSupported()) {
showError("JCEF 不受支持",
"当前 IDE 构建未启用 JCEF\n请使用 JetBrains 官方发行版(内置 JBR 运行时)。")
"当前 IDE 构建未启用 JCEF(找不到 com.intellij.ui.jcef.JBCefBrowser)。\n" +
"请使用 JetBrains 官方发行版 IDE,或更换为内置 JCEF 的构建。")
return
}
val b = JBCefBrowser()
val b = EmbeddedBrowser.create()
?: run {
showError("无法创建浏览器",
"反射创建 JBCefBrowser 失败。请检查 IDE 是否启用 JCEF。")
return
}
browser = b
// 通过反射安全初始化 JS↔Java 桥
setupBridgeViaReflection(b)
// JS 桥(失败不影响浏览器)
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)
}
}
// 注入浏览器组件
center.add(b.component, CARD_BROWSER)
showCard(CARD_BROWSER)
loadCurrentUrl()
// 浏览器组件加入 "browser" 卡片
val comp = b.component
if (comp != null) {
center.add(comp, CARD_BROWSER)
showCard(CARD_BROWSER)
loadCurrentUrl()
} else {
showError("浏览器组件不可用", "JBCefBrowser.getComponent() 返回空。")
}
} catch (t: Throwable) {
logger.error("JCEF init failed", t)
showError("JCEF 初始化失败", buildErrorText(t))
}
}
// ------------------------------------------------------------------
// JS ↔ Java 桥(全反射,编译期零 JBCefJSQuery 依赖)
// ------------------------------------------------------------------
private fun setupBridgeViaReflection(b: JBCefBrowser) {
try {
// 1) 检查 JBCefJSQuery 类是否可用
val queryClass = try {
Class.forName("com.intellij.ui.jcef.JBCefJSQuery")
} catch (_: Throwable) {
logger.info("JBCefJSQuery class not available; bridge disabled")
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 {
val js = """
window.harmnessBridge = function(payload) {
$injectedJs
};
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
private fun handleBridgeRequest(request: String): String = when {
request == "ping" -> "pong"
request == "getUrl" -> browser?.let {
try {
val cef = it.instance.javaClass.getMethod("getCefBrowser").invoke(it.instance)
cef.javaClass.getMethod("getUrl").invoke(cef) as? String ?: ""
} catch (_: Throwable) { "" }
} ?: ""
request.startsWith("openExternal:") -> {
try {
com.intellij.ide.BrowserUtil.browse(request.removePrefix("openExternal:"))
} catch (_: Throwable) {}
"ok"
}
else -> "unknown request: $request"
}
// ------------------------------------------------------------------
@@ -271,11 +214,11 @@ class HarmnessBrowserPanel(
appendLine(detail)
appendLine()
appendLine("--- 环境信息 ---")
appendLine("插件版本: 1.0.0")
appendLine("插件版本: 1.0.1")
appendLine("IDE Build: $appInfo")
appendLine("OS: ${System.getProperty("os.name")} ${System.getProperty("os.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
if (area != null) {
@@ -340,24 +283,20 @@ class HarmnessBrowserPanel(
anchor = GridBagConstraints.NORTHWEST
}
// 标题
panel.add(JBLabel("<html><h2 style='margin:0'>⚙ 设置</h2></html>"), gbc.also {
it.gridx = 0; it.gridy = 0; it.gridwidth = 2
})
gbc.gridwidth = 1
// 起始地址
panel.add(JBLabel("起始地址:"), gbc.also { it.gridx = 0; it.gridy = 1 })
settingsUrlField = JTextField(settings.startUrl, 28)
panel.add(settingsUrlField, gbc.also { it.gridx = 1; it.gridy = 1 })
// 提示
panel.add(JBLabel("<html><small>例如:http://127.0.0.1:8899<br>" +
"目标站点须允许被 iframe 嵌入(无 X-Frame-Options / CSP 限制)</small></html>"),
gbc.also { it.gridx = 0; it.gridy = 2; it.gridwidth = 2 })
gbc.gridwidth = 1
// 按钮
val saveBtn = JButton("保存")
saveBtn.addActionListener {
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(JBPanel<JBPanel<*>>(), gbc.also {
it.gridy = 4; it.weighty = 1.0
})
@@ -391,14 +329,14 @@ class HarmnessBrowserPanel(
private fun navigate(url: String) {
val b = browser ?: return
val fixed = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
b.loadURL(fixed)
b.load(fixed)
}
fun loadCurrentUrl() {
val b = browser ?: return
val url = settings.startUrl
addressField.text = url
b.loadURL(url)
b.load(url)
}
fun onToolWindowSelected() {
@@ -410,14 +348,10 @@ class HarmnessBrowserPanel(
// ------------------------------------------------------------------
override fun dispose() {
// 反射释放 jsQuery
try {
jsQuery?.javaClass?.getMethod("dispose")?.invoke(jsQuery)
} catch (_: Throwable) {}
try { browser?.let { Disposer.dispose(it) } } catch (_: Throwable) {}
try { bridge?.dispose() } catch (_: Throwable) {}
try { browser?.dispose() } catch (_: Throwable) {}
bridge = null
browser = null
jsQuery = null
bridgeAvailable = false
}
companion object {