diff --git a/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt index abb1519..e5ea0bb 100644 --- a/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt +++ b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt @@ -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:工具栏(← → ⟳ 地址栏 ⚙) - * - CENTER:CardLayout,包含三张卡: - * - "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>(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>(BorderLayout()) + wrapper.add(JBScrollPane(area), BorderLayout.CENTER) + wrapper.add(copyBtn, BorderLayout.SOUTH) + return wrapper + } + // ------------------------------------------------------------------ - // 卡片内容 + // 其他卡片 // ------------------------------------------------------------------ private fun createStandbyCard(): JComponent { - val p = JBPanel>(BorderLayout()) - val label = JBLabel("正在初始化浏览器…", 0) - p.add(label, BorderLayout.CENTER) - return p + return JBPanel>(BorderLayout()).apply { + add(JBLabel("正在初始化浏览器…", 0), BorderLayout.CENTER) + } } - /** 设置表单:起始地址 + 保存/取消 */ private fun createSettingsCard(): JComponent { - val panel = 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>().apply { layout = GridBagLayout() } + val gbc = GridBagConstraints().apply { + insets = Insets(8, 10, 8, 10) + fill = GridBagConstraints.HORIZONTAL + anchor = GridBagConstraints.NORTHWEST + } - val title = JBLabel("

设置

") - gbc.gridx = 0; gbc.gridy = 0; gbc.gridwidth = 2 - panel.add(title, gbc) + // 标题 + panel.add(JBLabel("

⚙ 设置

"), 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("例如:http://127.0.0.1:8899 (本地 Harmness 服务地址)
" + - "目标站点需允许被嵌入(不要有 X-Frame-Options / CSP frame-ancestors 限制)
") - gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2 - panel.add(hint, gbc) + // 提示 + panel.add(JBLabel("例如:http://127.0.0.1:8899
" + + "目标站点须允许被 iframe 嵌入(无 X-Frame-Options / CSP 限制)
"), + 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>(), gbc) + // 弹性填充 + panel.add(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" } } \ No newline at end of file