Reflection-based JS bridge; copyable error report card

This commit is contained in:
harmness
2026-09-10 20:24:30 +08:00
parent e51c046007
commit 3fd4a01b4b
@@ -9,16 +9,16 @@ 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 com.intellij.ui.jcef.JBCefJSQuery
import org.cef.handler.CefLifeSpanHandlerAdapter
import org.cef.browser.CefBrowser
import org.cef.handler.CefLoadHandlerAdapter
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Desktop
import java.awt.FlowLayout
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.awt.Insets
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.BorderFactory
@@ -32,15 +32,13 @@ import javax.swing.SwingUtilities
/**
* Harmness 嵌入浏览器面板。
*
* 布局
* - NORTH:工具栏(← → ⟳ 地址栏 ⚙)
* - CENTERCardLayout,包含三张卡:
* - "settings":设置表单(起始地址 + 保存/取消)
* - "browser"JCEF 浏览器(懒初始化,失败时显示错误)
* - "error":初始化失败时的错误信息
* 四个视图(CardLayout
* - standby → 初始化中
* - browser → 网页(JCEF,惰性初始化)
* - settings → 起始地址设置表单
* - error → 错误信息 + 一键复制按钮
*
* 关键设计:工具栏和设置表单**不依赖** JCEF。即使 JCEF 初始化失败
* 界面仍然可用,错误信息会显示在中心区域,方便排错。
* 工具栏和设置表单不依赖 JCEF,始终可用。
*/
class HarmnessBrowserPanel(
private val project: com.intellij.openapi.project.Project,
@@ -50,70 +48,65 @@ class HarmnessBrowserPanel(
private val settings = SettingsState.getInstance()
private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java)
// JCEF 浏览器(懒初始化
// JCEF 对象(惰性初始化,可能为 null
private var browser: JBCefBrowser? = null
private var query: JBCefJSQuery? = null
private var bridgeAvailable = false // 反射创建 JBCefJSQuery 是否成功
private var addressField: JTextField = JTextField()
private var settingsUrlField: JTextField = JTextField(30)
private val cardLayout = CardLayout()
private val center = JBPanel<JBPanel<*>>(cardLayout)
private var currentCard = CARD_STANDBY
// 反射持有的 JSQuery 对象(类型为 Any,避免编译期依赖)
private var jsQuery: Any? = null
init {
Disposer.register(parentDisposable, this)
// 工具栏(不依赖 JCEF,总是先建立)
add(createToolbar(), BorderLayout.NORTH)
add(center, BorderLayout.CENTER)
// 中心卡片
center.add(createStandbyCard(), CARD_STANDBY)
center.add(createSettingsCard(), CARD_SETTINGS)
center.add(createErrorCard(), CARD_ERROR)
cardLayout.show(center, CARD_STANDBY)
// 在窗口系统就绪后异步初始化浏览器,避免阻塞 EDT 或初始化失败拖垮整个面板
// 延迟初始化浏览器,阻塞 EDT
SwingUtilities.invokeLater { initBrowserSafely() }
}
// ------------------------------------------------------------------
// 工具栏
// 工具栏(纯 Swing,不依赖 JCEF
// ------------------------------------------------------------------
private fun createToolbar(): JComponent {
val back = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
val cef = browser?.cefBrowser
if (cef != null && cef.canGoBack()) cef.goBack()
runCatching { browser?.cefBrowser?.goBack() }
}
})
val forward = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
val cef = browser?.cefBrowser
if (cef != null && cef.canGoForward()) cef.goForward()
runCatching { browser?.cefBrowser?.goForward() }
}
})
val reload = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { browser?.cefBrowser?.reload() }
override fun actionPerformed(e: ActionEvent) {
runCatching { browser?.cefBrowser?.reload() }
}
})
// 右上角设置图标:在浏览器视图与设置视图间切换
val settingsBtn = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
if (cardLayout == null) return
if (currentCard == CARD_SETTINGS) {
showBrowser()
} else {
showSettings()
}
if (currentCard == CARD_SETTINGS) showBrowser() else showSettings()
}
})
settingsBtn.toolTipText = "设置"
settingsBtn.border = BorderFactory.createEmptyBorder(2, 8, 2, 8)
addressField = JTextField(settings.startUrl)
addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)"
addressField.toolTipText = "当前页面地址(回车跳转)"
addressField.addActionListener { navigate(addressField.text) }
val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2))
@@ -126,11 +119,9 @@ class HarmnessBrowserPanel(
}
// ------------------------------------------------------------------
// 卡片管理与视图切换
// 视图切换
// ------------------------------------------------------------------
private var currentCard: String = CARD_STANDBY
private fun showCard(name: String) {
currentCard = name
cardLayout.show(center, name)
@@ -143,7 +134,6 @@ class HarmnessBrowserPanel(
private fun showBrowser() {
if (browser == null) {
// 浏览器还没建好:尝试初始化;失败显示错误
initBrowserSafely()
} else {
showCard(CARD_BROWSER)
@@ -152,213 +142,258 @@ class HarmnessBrowserPanel(
}
// ------------------------------------------------------------------
// JCEF 浏览器初始化(安全包装
// JCEF 浏览器初始化(安全)
// ------------------------------------------------------------------
private fun initBrowserSafely() {
if (browser != null) {
showCard(CARD_BROWSER)
loadCurrentUrl()
return
}
try {
if (browser != null) return
if (!JBCefApp.isSupported()) {
showError("JCEF (Java Chromium Embedded Framework) 在当前 IDE 中不受支持。\n" +
"请使用官方发行版 IDE(内置 JBR,或检查 IDE 是否禁用了 JCEF")
showError("JCEF 不受支持",
"当前 IDE 构建未启用 JCEF。\n请使用 JetBrains 官方发行版(内置 JBR 运行时)。")
return
}
val b = JBCefBrowser()
browser = b
// 通过反射安全初始化 JS↔Java 桥
setupBridgeViaReflection(b)
// 注入浏览器组件
center.add(b.component, CARD_BROWSER)
showCard(CARD_BROWSER)
loadCurrentUrl()
} 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
}
val b = JBCefBrowser()
browser = b
setupPopupHandling(b)
setupBridge(b)
// 2) 反射创建 JBCefJSQuery.create(JBCefBrowser)
val createMethod = queryClass.getMethod("create", JBCefBrowser::class.java)
val queryInstance = createMethod.invoke(null, b) ?: return
jsQuery = queryInstance
// 把浏览器组件放进 "browser" 卡片
center.add(b.component, CARD_BROWSER)
if (currentCard != CARD_SETTINGS) {
showCard(CARD_BROWSER)
// 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
}
}
loadCurrentUrl()
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.error("Failed to init JCEF browser", t)
showError(buildErrorMessage(t))
logger.warn("Bridge init failed (browser still works without bridge): ${t.message}")
bridgeAvailable = false
// 不中断:浏览器本身仍可用,只是网页无法调用插件 API
}
}
private fun buildErrorMessage(t: Throwable): String {
val sb = StringBuilder()
sb.append("JCEF 浏览器初始化失败:\n")
appendEx(sb, t)
return sb.toString()
}
// ------------------------------------------------------------------
// 错误展示(可复制)
// ------------------------------------------------------------------
private fun appendEx(sb: StringBuilder, t: Throwable, depth: Int = 0) {
if (depth > 4) return
sb.append(t.javaClass.simpleName).append(": ").append(t.message).append('\n')
for (el in t.stackTrace.take(6)) {
sb.append(" at ").append(el).append('\n')
private fun showError(title: String, detail: String) {
val appInfo = try {
com.intellij.openapi.application.ApplicationInfo.getInstance().build?.asString()
} catch (_: Throwable) { "unknown" }
val fullText = buildString {
appendLine("=== Harmness Browser 错误报告 ===")
appendLine("错误: $title")
appendLine()
appendLine(detail)
appendLine()
appendLine("--- 环境信息 ---")
appendLine("插件版本: 1.0.0")
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) { "检查失败" }}")
}
val area = center.getClientProperty("errorArea") as? JTextArea
if (area != null) {
area.text = fullText
area.caretPosition = 0
}
t.cause?.let { appendEx(sb, it, depth + 1) }
}
private fun showError(message: String) {
val area = center.getClientProperty(ERROR_AREA_KEY) as? JTextArea
?: run {
val a = JTextArea()
center.putClientProperty(ERROR_AREA_KEY, a)
a
}
area.text = message
showCard(CARD_ERROR)
}
private fun buildErrorText(t: Throwable): String = buildString {
append(t.javaClass.name).append(": ").appendLine(t.message ?: "no message")
appendLine()
t.stackTrace.take(15).forEach { appendLine(" at $it") }
t.cause?.let { c ->
appendLine()
append("Caused by: ").append(c.javaClass.name).append(": ").appendLine(c.message ?: "")
c.stackTrace.take(8).forEach { appendLine(" at $it") }
}
}
private fun createErrorCard(): JComponent {
val area = JTextArea().apply {
isEditable = false
font = font.deriveFont(12f)
lineWrap = true
wrapStyleWord = true
}
center.putClientProperty("errorArea", area)
val copyBtn = JButton("📋 复制错误信息")
copyBtn.addActionListener {
val clip = Toolkit.getDefaultToolkit().systemClipboard
clip.setContents(StringSelection(area.text), null)
copyBtn.text = "✓ 已复制"
SwingUtilities.invokeLater {
try { Thread.sleep(1500) } catch (_: InterruptedException) {}
copyBtn.text = "📋 复制错误信息"
}
}
val wrapper = JBPanel<JBPanel<*>>(BorderLayout())
wrapper.add(JBScrollPane(area), BorderLayout.CENTER)
wrapper.add(copyBtn, BorderLayout.SOUTH)
return wrapper
}
// ------------------------------------------------------------------
// 卡片内容
// 其他卡片
// ------------------------------------------------------------------
private fun createStandbyCard(): JComponent {
val p = JBPanel<JBPanel<*>>(BorderLayout())
val label = JBLabel("正在初始化浏览器…", 0)
p.add(label, BorderLayout.CENTER)
return p
return JBPanel<JBPanel<*>>(BorderLayout()).apply {
add(JBLabel("正在初始化浏览器…", 0), BorderLayout.CENTER)
}
}
/** 设置表单:起始地址 + 保存/取消 */
private fun createSettingsCard(): JComponent {
val panel = JBPanel<JBPanel<*>>()
panel.layout = GridBagLayout()
val gbc = GridBagConstraints()
gbc.insets = Insets(8, 10, 8, 10)
gbc.fill = GridBagConstraints.HORIZONTAL
gbc.anchor = GridBagConstraints.NORTHWEST
val panel = JBPanel<JBPanel<*>>().apply { layout = GridBagLayout() }
val gbc = GridBagConstraints().apply {
insets = Insets(8, 10, 8, 10)
fill = GridBagConstraints.HORIZONTAL
anchor = GridBagConstraints.NORTHWEST
}
val title = JBLabel("<html><h2 style='margin:0'>设置</h2></html>")
gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2
panel.add(title, gbc)
// 标题
panel.add(JBLabel("<html><h2 style='margin:0'>⚙ 设置</h2></html>"), gbc.also {
it.gridx = 0; it.gridy = 0; it.gridwidth = 2
})
gbc.gridwidth = 1
val label = JBLabel("起始地址 (Start URL):")
gbc.gridx = 0; gbc.gridy = 1
panel.add(label, gbc)
// 起始地址
panel.add(JBLabel("起始地址:"), gbc.also { it.gridx = 0; it.gridy = 1 })
settingsUrlField = JTextField(settings.startUrl, 28)
gbc.gridx = 1; gbc.gridy = 1
panel.add(settingsUrlField, gbc)
panel.add(settingsUrlField, gbc.also { it.gridx = 1; it.gridy = 1 })
val hint = JBLabel("<html><small>例如:http://127.0.0.1:8899 (本地 Harmness 服务地址)<br>" +
"目标站点需允许被嵌入(不要有 X-Frame-Options / CSP frame-ancestors 限制)</small></html>")
gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2
panel.add(hint, gbc)
// 提示
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()
saveBtn.text = "已保存"
saveBtn.text = "已保存"
SwingUtilities.invokeLater {
try { Thread.sleep(500) } catch (_: InterruptedException) {}
try { Thread.sleep(400) } catch (_: InterruptedException) {}
saveBtn.text = "保存"
}
// 保存后切回浏览器视图并加载新地址
showBrowser()
}
val cancelBtn = JButton("取消")
cancelBtn.addActionListener { showBrowser() }
val btnPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0))
btnPanel.add(saveBtn)
btnPanel.add(cancelBtn)
gbc.gridx = 0; gbc.gridy = 3; gbc.gridwidth = 2
panel.add(btnPanel, gbc)
val btnPanel = JPanel(FlowLayout(FlowLayout.LEFT, 8, 0)).apply {
add(saveBtn); add(cancelBtn)
}
panel.add(btnPanel, gbc.also { it.gridx = 0; it.gridy = 3; it.gridwidth = 2 })
gbc.gridy = 4
gbc.weighty = 1.0
panel.add(JBPanel<JBPanel<*>>(), gbc)
// 弹性填充
panel.add(JBPanel<JBPanel<*>>(), gbc.also {
it.gridy = 4; it.weighty = 1.0
})
return panel
}
private fun createErrorCard(): JComponent {
val area = JTextArea()
area.isEditable = false
area.font = area.font.deriveFont(12f)
area.lineWrap = true
area.wrapStyleWord = true
center.putClientProperty(ERROR_AREA_KEY, area)
return JBScrollPane(area)
}
// ------------------------------------------------------------------
// 浏览器行为
// 浏览器操作
// ------------------------------------------------------------------
private fun navigate(url: String) {
val b = browser ?: return
val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
b.loadURL(fixedUrl)
val fixed = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
b.loadURL(fixed)
}
private fun setupPopupHandling(b: JBCefBrowser) {
b.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() {
override fun onBeforePopup(
cefBrowser: org.cef.browser.CefBrowser,
frame: org.cef.browser.CefFrame,
targetUrl: String,
targetFrameName: String
): Boolean {
if (targetUrl.isNotBlank()) {
BrowserUtil.browse(targetUrl)
}
return true // 取消弹窗
}
}, b.cefBrowser)
}
// ------------------------------------------------------------------
// JS ↔ Java 桥
// ------------------------------------------------------------------
private fun setupBridge(b: JBCefBrowser) {
val q = JBCefJSQuery.create(b)
query = q
q.addHandler { request ->
val r = request ?: ""
logger.debug("JS bridge request: $r")
when {
r == "ping" -> JBCefJSQuery.Response("pong")
r == "getUrl" -> JBCefJSQuery.Response(b.cefBrowser.url)
r.startsWith("openExternal:") -> {
val url = r.removePrefix("openExternal:")
BrowserUtil.browse(url)
JBCefJSQuery.Response("ok")
}
else -> JBCefJSQuery.Response("unknown request: $r", 1, "unknown")
}
}
// 注入桥接函数:主 frame 加载完成后
b.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() {
override fun onLoadEnd(cefBrowser: org.cef.browser.CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) {
if (frame.isMain) {
val jsCode = """
window.$JS_BRIDGE_NAME = function(payload) {
var callbacks = payload && payload.callbacks;
${q.inject(
"payload.query",
"function(response) { if (callbacks && callbacks.onSuccess) callbacks.onSuccess(response); }",
"function(error_code, error_message) { if (callbacks && callbacks.onFailure) callbacks.onFailure(error_code, error_message); }"
)};
};
if (window.__HARMNESS_READY__) window.__HARMNESS_READY__();
""".trimIndent()
b.cefBrowser.executeJavaScript(jsCode, cefBrowser.url, 0)
}
}
}, b.cefBrowser)
}
// ------------------------------------------------------------------
// 对外方法
// ------------------------------------------------------------------
/** 加载当前设置中的起始地址(初次打开 / 设置变更后调用) */
fun loadCurrentUrl() {
val b = browser ?: return
val url = settings.startUrl
@@ -366,32 +401,29 @@ class HarmnessBrowserPanel(
b.loadURL(url)
}
/** 工具窗口被选中时由工厂回调:若浏览器存在则刷新,否则尝试初始化 */
fun onToolWindowSelected() {
if (browser == null) {
initBrowserSafely()
} else {
loadCurrentUrl()
}
if (browser == null) initBrowserSafely() else loadCurrentUrl()
}
// ------------------------------------------------------------------
// 生命周期
// ------------------------------------------------------------------
override fun dispose() {
query?.dispose()
// 反射释放 jsQuery
try {
browser?.let { Disposer.dispose(it) }
} catch (t: Throwable) {
logger.warn("dispose browser failed", t)
}
jsQuery?.javaClass?.getMethod("dispose")?.invoke(jsQuery)
} catch (_: Throwable) {}
try { browser?.let { Disposer.dispose(it) } } catch (_: Throwable) {}
browser = null
query = null
jsQuery = null
bridgeAvailable = false
}
companion object {
private const val CARD_STANDBY = "standby"
private const val CARD_BROWSER = "browser"
private const val CARD_SETTINGS = "settings"
private const val CARD_ERROR = "error"
private const val CARD_STANDBY = "standby"
private const val ERROR_AREA_KEY = "harmness.errorArea"
const val JS_BRIDGE_NAME = "harmnessBridge"
}
}