diff --git a/build.gradle.kts b/build.gradle.kts index 340de4b..1058f36 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } group = "com.prepeep" -version = "1.0.0" +version = "1.0.1" repositories { mavenCentral() diff --git a/src/main/kotlin/com/harmness/toolwindow/EmbeddedBrowser.kt b/src/main/kotlin/com/harmness/toolwindow/EmbeddedBrowser.kt new file mode 100644 index 0000000..30a9f94 --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/EmbeddedBrowser.kt @@ -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 是否提供可用 JCEF(JBCefApp.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 + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/EmbeddedJsBridge.kt b/src/main/kotlin/com/harmness/toolwindow/EmbeddedJsBridge.kt new file mode 100644 index 0000000..70a23bb --- /dev/null +++ b/src/main/kotlin/com/harmness/toolwindow/EmbeddedJsBridge.kt @@ -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) + val handlerFn = Function { 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 + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt index e5ea0bb..48fbb2e 100644 --- a/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt +++ b/src/main/kotlin/com/harmness/toolwindow/HarmnessBrowserPanel.kt @@ -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>(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("

⚙ 设置

"), 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("例如: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() @@ -376,7 +315,6 @@ class HarmnessBrowserPanel( } panel.add(btnPanel, gbc.also { it.gridx = 0; it.gridy = 3; it.gridwidth = 2 }) - // 弹性填充 panel.add(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 {