Compare commits

...

12 Commits

Author SHA1 Message Date
govin 3faaa62e12 v1.0.3: bump version 2026-09-15 02:49:17 +08:00
govin e031be8437 Replace icons with custom Harmness logo (Saturn planet, blue-purple gradient) 2026-09-14 18:50:34 +08:00
govin 105584f118 Add release dir to gitignore 2026-09-10 23:59:51 +08:00
harmness 90ff9b107e v1.0.1: add optional com.intellij.modules.jcef depends for JCEF classloader on PyCharm 2026.x 2026-09-10 23:05:43 +08:00
harmness 47f3129dfb v1.0.1: fully reflection-based browser (EmbeddedBrowser/EmbeddedJsBridge), zero compile-time JCEF deps; panel never blank 2026-09-10 22:26:03 +08:00
harmness 3fd4a01b4b Reflection-based JS bridge; copyable error report card 2026-09-10 20:24:30 +08:00
harmness e51c046007 Defensive panel: toolbar/settings independent of JCEF, lazy browser init, error card, drop WindowFocusService 2026-09-10 20:17:17 +08:00
harmness b3fee364e0 Release v1.0.0: in-panel settings (gear icon), default URL 127.0.0.1:8899 2026-09-10 19:58:04 +08:00
harmness dfda6042e7 Extend untilBuild to 263.* for IDE 2025.3 (JBC-263.3889.77) compatibility 2026-09-10 18:15:55 +08:00
harmness e795320845 Fix compile errors; adopt plugin ID com.prepeep.harmness-browser and official Gradle wrapper; build success 2026-09-10 17:34:56 +08:00
harmness c9c6744cc0 Finalize preset: drop jvmToolchain, remove toolWindow condition, gitignore .tools 2026-09-10 15:05:04 +08:00
harmness 4890569b95 Merge official template: adopt plugin ID com.prepeep.harmness-browser, Gradle wrapper, IDE 2024.2–251.*, run configs 2026-09-10 14:57:07 +08:00
27 changed files with 1024 additions and 408 deletions
+11 -14
View File
@@ -1,15 +1,12 @@
# Gradle .gradle
.gradle/ .idea
build/ .intellijPlatform
build
# IDE node_modules
.idea/ web/dist
*.iml *.iml
.vscode/ .DS_Store
scripts/
# Node tools/
node_modules/ .tools/
web/dist/ release/
# OS
.DS_Store
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run IDE with Plugin" type="GradleRunConfiguration" factoryName="Gradle">
<log_file alias="idea.log" path="$PROJECT_DIR$/.intellijPlatform/sandbox/*/*/log/idea.log" show_all="true" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runIde" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Tests" type="GradleRunConfiguration" factoryName="Gradle">
<log_file alias="idea.log" path="$PROJECT_DIR$/.intellijPlatform/sandbox/*/*/log-test/idea.log" show_all="true" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="check" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>true</RunAsTest>
<method v="2" />
</configuration>
</component>
+25
View File
@@ -0,0 +1,25 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Verifications" type="GradleRunConfiguration" factoryName="Gradle">
<log_file alias="idea.log" path="$PROJECT_DIR$/.intellijPlatform/sandbox/*/*/log/idea.log" show_all="true" />
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="verifyPlugin" />
</list>
</option>
<option name="vmOptions" value="" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
+5
View File
@@ -0,0 +1,5 @@
<!-- Keep a Changelog guide -> https://keepachangelog.com -->
# Harmness-browser Changelog
## [Unreleased]
+7 -6
View File
@@ -1,11 +1,12 @@
import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform
plugins { plugins {
id("java") id("org.jetbrains.kotlin.jvm")
id("org.jetbrains.kotlin.jvm") version "2.0.21" id("org.jetbrains.intellij.platform")
id("org.jetbrains.intellij.platform") version "2.5.1"
} }
group = "com.harmness" group = "com.prepeep"
version = "0.1.0" version = "1.0.3"
repositories { repositories {
mavenCentral() mavenCentral()
@@ -26,7 +27,7 @@ intellijPlatform {
pluginConfiguration { pluginConfiguration {
ideaVersion { ideaVersion {
sinceBuild = "242" sinceBuild = "242"
untilBuild = "243.*" untilBuild = "263.*"
} }
} }
} }
+8 -1
View File
@@ -1,2 +1,9 @@
group = com.prepeep
version = 1.0.0-SNAPSHOT
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=false org.gradle.parallel=false
org.gradle.configuration-cache=true
org.gradle.caching=true
kotlin.stdlib.default.dependency = false
+5
View File
@@ -0,0 +1,5 @@
[versions]
junit = "4.13.2"
[libraries]
junit = { module = "junit:junit", version.ref = "junit" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
# 准备本地构建工具链(无 root 安装,全部放到工作区 .tools 下)
set -euo pipefail
TOOLS_DIR="/data/project/Plugin/harmness/.tools"
mkdir -p "$TOOLS_DIR"
cd "$TOOLS_DIR"
JDK_VERSION="jdk-17.0.12+7"
GRADLE_VERSION="gradle-8.11.1"
echo "==> Downloading Temurin JDK17 ..."
curl -fL --retry 3 -o jdk17.tar.gz \
"https://github.com/adoptium/temurin17-binaries/releases/download/${JDK_VERSION}/OpenJDK17U-jdk_x64_linux_hotspot_17.0.12_7.tar.gz"
tar xzf jdk17.tar.gz
chmod +x jdk-17.0.12+7/bin/java
"$TOOLS_DIR/jdk-17.0.12+7/bin/java" -version
echo "==> Downloading Gradle $GRADLE_VERSION ..."
curl -fL --retry 3 -o gradle.zip \
"https://services.gradle.org/distributions/${GRADLE_VERSION}-bin.zip"
unzip -q gradle.zip
chmod +x "$GRADLE_VERSION/bin/gradle"
JAVA_HOME="$TOOLS_DIR/jdk-17.0.12+7" "$GRADLE_VERSION/bin/gradle" --version
echo "==> DONE"
+12 -1
View File
@@ -1 +1,12 @@
rootProject.name = "harmness-toolwindow" pluginManagement {
plugins {
id("org.jetbrains.kotlin.jvm") version "2.0.21"
id("org.jetbrains.intellij.platform") version "2.1.0"
}
repositories {
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "harmness-browser"
@@ -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,45 +0,0 @@
package com.harmness.toolwindow
import com.intellij.openapi.diagnostic.Logger
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBTextField
import com.intellij.util.ui.FormBuilder
import com.intellij.util.ui.UIUtil
import javax.swing.JComponent
import javax.swing.JPanel
/**
* 设置面板 UI:编辑「起始地址」并即时生效。
*/
class HarmnessBrowserConfigurablePanel {
private val urlField = JBTextField()
val panel: JPanel = FormBuilder.createFormBuilder()
.addLabeledComponent("Start URL", urlField, 1, false)
.addComponentFillVertically(JBLabel(
"<html>Embedded browser will open this URL when the tool window is shown.<br>" +
"The target site must allow embedding (no strict <code>X-Frame-Options</code> / <code>CSP frame-ancestors</code>).<br>" +
"e.g. <b>http://127.0.0.1:8888</b></html>", UIUtil.ComponentStyle.SMALL, UIUtil.FontColor.NORMAL), 1)
.panel
init {
urlField.columns = 40
urlField.emptyText.text = "http://127.0.0.1:8888"
}
fun isModified(settings: SettingsState): Boolean =
urlField.text != settings.startUrl
fun apply(settings: SettingsState) {
settings.startUrl = urlField.text.trim()
}
fun reset(settings: SettingsState) {
urlField.text = settings.startUrl
}
companion object {
private val LOG = Logger.getInstance(HarmnessBrowserConfigurablePanel::class.java)
}
}
@@ -1,40 +1,40 @@
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.jcef.JBCefApp import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.jcef.JBCefBrowser
import com.intellij.ui.jcef.JBCefJSQuery
import org.cef.browser.CefBrowser
import org.cef.handler.CefDisplayHandlerAdapter
import org.cef.handler.CefLifeSpanHandlerAdapter
import org.cef.handler.CefLoadHandlerAdapter
import java.awt.BorderLayout import java.awt.BorderLayout
import java.awt.Desktop import java.awt.CardLayout
import java.awt.FlowLayout 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.AbstractAction
import javax.swing.BorderFactory
import javax.swing.JButton import javax.swing.JButton
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.JPanel import javax.swing.JPanel
import javax.swing.JTextArea
import javax.swing.JTextField import javax.swing.JTextField
import javax.swing.SwingUtilities
/** /**
* 嵌入浏览器面板。 * Harmness 嵌入浏览器面板。
* *
* 职责 * 四个视图(CardLayout
* - 使用 JBCefBrowser 渲染用户配置的起始地址(例如本地 Harness 地址) * - standby → 初始化中
* - 工具栏:地址栏、前进/后退、刷新、设置入口(快捷跳转到 IDE Settings 的插件配置页 * - browser → 网页(通过 [EmbeddedBrowser] 反射包装,惰性初始化
* - 通过 [JBCefJSQuery] 提供 JS ↔ 插件代码双向桥 * - settings → 起始地址设置表单
* - 拦截并处理 _blank 弹窗(默认吞掉,可选交给系统浏览器) * - error → 错误信息 + 一键复制
* *
* 由于 IntelliJ Platform 官方插件模型目前不允许在 JCEF 浏览器内部直接调用 * 本类**不引用任何 JCEF 类**。浏览器能力全部走反射封装,
* "打开当前项目文件" 之类的 API(无 DOM 访问权限),插件通过注入到页面中的 * 即使 IDE 没有 JCEF,工具栏 / 设置表单 / 错误卡片也始终可用。
* 桥接函数 [JS_BRIDGE_NAME] 让网页(如 Harness 前端)发起请求,
* 再由本类把结果返回给页面。
*/ */
class HarmnessBrowserPanel( class HarmnessBrowserPanel(
private val project: com.intellij.openapi.project.Project, private val project: com.intellij.openapi.project.Project,
@@ -42,57 +42,58 @@ class HarmnessBrowserPanel(
) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) { ) : Disposable, JBPanel<HarmnessBrowserPanel>(BorderLayout()) {
private val settings = SettingsState.getInstance() private val settings = SettingsState.getInstance()
private val logger = Logger.getInstance(HarmnessBrowserPanel::class.java)
private lateinit var browser: JBCefBrowser // 反射浏览器(可能为 null
private lateinit var addressField: JTextField private var browser: EmbeddedBrowser? = null
private lateinit var query: JBCefJSQuery private var bridge: EmbeddedJsBridge? = null
private val loadEnded = java.util.concurrent.atomic.AtomicBoolean(false)
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
init { init {
Disposer.register(parentDisposable, this) Disposer.register(parentDisposable, this)
if (!JBCefApp.isSupported()) {
add(JBLabel(
"JCEF (Java Chromium Embedded Framework) is not supported in this IDE instance.\n" +
"Please use an IDE build that bundles JCEF, or a compatible JDK.", 0),
BorderLayout.CENTER)
return
}
browser = JBCefBrowser()
// 布局:顶部工具栏 + 浏览器
add(createToolbar(), BorderLayout.NORTH) add(createToolbar(), BorderLayout.NORTH)
add(browser.component, BorderLayout.CENTER) add(center, BorderLayout.CENTER)
// 设置弹窗处理 center.add(createStandbyCard(), CARD_STANDBY)
setupPopupHandling() center.add(createSettingsCard(), CARD_SETTINGS)
// 设置 JS 桥 center.add(createErrorCard(), CARD_ERROR)
setupBridge() cardLayout.show(center, CARD_STANDBY)
// 初始加载 // 窗口就绪后惰性初始化浏览器,避免阻塞 EDT
loadCurrentUrl() SwingUtilities.invokeLater { initBrowserSafely() }
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 工具栏 // 工具栏(纯 Swing,不依赖 JCEF
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private fun createToolbar(): JComponent { private fun createToolbar(): JComponent {
val cefBrowser = browser.cefBrowser val back = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) { runCatching { browser?.goBack() } }
val back = JButton(AbstractAction("") {
if (cefBrowser.canGoBack()) cefBrowser.goBack()
}) })
val forward = JButton(AbstractAction("") { val forward = JButton(object : AbstractAction("") {
if (cefBrowser.canGoForward()) cefBrowser.goForward() override fun actionPerformed(e: ActionEvent) { runCatching { browser?.goForward() } }
}) })
val reload = JButton(AbstractAction("") { cefBrowser.reload() }) val reload = JButton(object : AbstractAction("") {
val settingsBtn = JButton(AbstractAction("") { openSettings() }) override fun actionPerformed(e: ActionEvent) { runCatching { browser?.reload() } }
val externalBtn = JButton(AbstractAction("") { openInExternalBrowser() }) })
val settingsBtn = JButton(object : AbstractAction("") {
override fun actionPerformed(e: ActionEvent) {
if (currentCard == CARD_SETTINGS) showBrowser() else showSettings()
}
})
settingsBtn.toolTipText = "设置"
settingsBtn.border = BorderFactory.createEmptyBorder(2, 8, 2, 8)
addressField = JTextField(settings.startUrl) addressField = JTextField(settings.startUrl)
addressField.toolTipText = "当前页面地址(可手动输入后回车跳转)" addressField.toolTipText = "当前页面地址(回车跳转)"
addressField.addActionListener { navigate(addressField.text) } addressField.addActionListener { navigate(addressField.text) }
val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2)) val toolbar = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2))
@@ -101,135 +102,262 @@ class HarmnessBrowserPanel(
toolbar.add(reload) toolbar.add(reload)
toolbar.add(addressField) toolbar.add(addressField)
toolbar.add(settingsBtn) toolbar.add(settingsBtn)
toolbar.add(externalBtn)
return toolbar return toolbar
} }
private fun navigate(url: String) { // ------------------------------------------------------------------
val fixedUrl = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url" // 视图切换
browser.loadURL(fixedUrl) // ------------------------------------------------------------------
private fun showCard(name: String) {
currentCard = name
cardLayout.show(center, name)
} }
private fun openSettings() { private fun showSettings() {
val app = com.intellij.openapi.application.ApplicationManager.getApplication() settingsUrlField.text = settings.startUrl
val show = Runnable { showCard(CARD_SETTINGS)
com.intellij.openapi.options.ShowSettingsUtil.getInstance()
.showSettingsDialog(project, "Harmness Browser")
}
if (app.isDispatchThread) show.run() else app.invokeLater(show)
} }
private fun openInExternalBrowser() { private fun showBrowser() {
val url = addressField.text.ifBlank { settings.startUrl } if (browser == null) {
if (Desktop.isDesktopSupported()) { initBrowserSafely()
try {
Desktop.getDesktop().browse(java.net.URI(url))
} catch (e: Exception) {
BrowserUtil.browse(url)
}
} else { } else {
BrowserUtil.browse(url) showCard(CARD_BROWSER)
loadCurrentUrl()
} }
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 弹窗与外部链接策略 // 浏览器初始化(反射,安全)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private fun setupPopupHandling() { private fun initBrowserSafely() {
browser.jbCefClient.addLifeSpanHandler(object : CefLifeSpanHandlerAdapter() { if (browser != null) {
override fun onBeforePopup( showCard(CARD_BROWSER)
browser: CefBrowser, loadCurrentUrl()
frame: org.cef.browser.CefFrame, return
targetUrl: String, }
targetFrameName: String try {
): Boolean { if (!EmbeddedBrowser.isJcefSupported()) {
// 拒绝在 IDE 内打开新窗口;改由外部系统浏览器打开 showError("JCEF 不受支持",
if (targetUrl.isNotBlank()) { "当前 IDE 构建未启用 JCEF(找不到 com.intellij.ui.jcef.JBCefBrowser)。\n" +
BrowserUtil.browse(targetUrl) "请使用 JetBrains 官方发行版 IDE,或更换为内置 JCEF 的构建。")
} return
return true // 取消弹窗
} }
}, browser.cefBrowser)
browser.jbCefClient.addDisplayHandler(object : CefDisplayHandlerAdapter() { val b = EmbeddedBrowser.create()
override fun onTitleChange(browser: CefBrowser, title: String?) { ?: run {
// 可用来同步工具窗口标题,暂时留空 showError("无法创建浏览器",
"反射创建 JBCefBrowser 失败。请检查 IDE 是否启用 JCEF。")
return
}
browser = 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)
}
} }
}, browser.cefBrowser)
// 浏览器组件加入 "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))
}
}
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"
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// JS ↔ Java 桥 // 错误展示(可复制)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private fun setupBridge() { private fun showError(title: String, detail: String) {
query = JBCefJSQuery.create(browser) val appInfo = try {
query.addHandler { request -> com.intellij.openapi.application.ApplicationInfo.getInstance().build?.asString()
val r = request ?: "" } catch (_: Throwable) { "unknown" }
LOG.debug("JS bridge request: $r") val fullText = buildString {
when { appendLine("=== Harmness Browser 错误报告 ===")
r == "ping" -> JBCefJSQuery.Response("pong") appendLine("错误: $title")
r == "getUrl" -> JBCefJSQuery.Response(browser.cefBrowser.url) appendLine()
r.startsWith("openExternal:") -> { appendLine(detail)
val url = r.removePrefix("openExternal:") appendLine()
BrowserUtil.browse(url) appendLine("--- 环境信息 ---")
JBCefJSQuery.Response("ok") appendLine("插件版本: 1.0.1")
} appendLine("IDE Build: $appInfo")
else -> JBCefJSQuery.Response("unknown request: $r", 1, "unknown") appendLine("OS: ${System.getProperty("os.name")} ${System.getProperty("os.version")}")
appendLine("Java: ${System.getProperty("java.version")}")
appendLine("JCEF 支持: ${EmbeddedBrowser.isJcefSupported()}")
}
val area = center.getClientProperty("errorArea") as? JTextArea
if (area != null) {
area.text = fullText
area.caretPosition = 0
}
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 = "📋 复制错误信息"
} }
} }
// 注入桥接函数:页面加载完成后注入到 window.harmnessBridge val wrapper = JBPanel<JBPanel<*>>(BorderLayout())
browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { wrapper.add(JBScrollPane(area), BorderLayout.CENTER)
override fun onLoadEnd(browser: CefBrowser, frame: org.cef.browser.CefFrame, httpStatusCode: Int) { wrapper.add(copyBtn, BorderLayout.SOUTH)
if (frame.isMain && loadEnded.compareAndSet(false, true)) { return wrapper
val jsCode = """
window.$JS_BRIDGE_NAME = function(payload) {
var callbacks = payload && payload.callbacks;
${query.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()
browser.executeJavaScript(jsCode, browser.url, 0)
}
}
}, browser.cefBrowser)
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// 对外方法(供 ToolWindow 调用) // 其他卡片
// ------------------------------------------------------------------ // ------------------------------------------------------------------
private fun createStandbyCard(): JComponent {
return JBPanel<JBPanel<*>>(BorderLayout()).apply {
add(JBLabel("正在初始化浏览器…", 0), BorderLayout.CENTER)
}
}
private fun createSettingsCard(): JComponent {
val panel = JBPanel<JBPanel<*>>().apply { layout = GridBagLayout() }
val gbc = GridBagConstraints().apply {
insets = Insets(8, 10, 8, 10)
fill = GridBagConstraints.HORIZONTAL
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()
saveBtn.text = "✓ 已保存"
SwingUtilities.invokeLater {
try { Thread.sleep(400) } catch (_: InterruptedException) {}
saveBtn.text = "保存"
}
showBrowser()
}
val cancelBtn = JButton("取消")
cancelBtn.addActionListener { showBrowser() }
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 })
panel.add(JBPanel<JBPanel<*>>(), gbc.also {
it.gridy = 4; it.weighty = 1.0
})
return panel
}
// ------------------------------------------------------------------
// 浏览器操作
// ------------------------------------------------------------------
private fun navigate(url: String) {
val b = browser ?: return
val fixed = if (url.startsWith("http://") || url.startsWith("https://")) url else "http://$url"
b.load(fixed)
}
fun loadCurrentUrl() { fun loadCurrentUrl() {
if (!::browser.isInitialized) return val b = browser ?: return
val url = settings.startUrl val url = settings.startUrl
addressField.text = url addressField.text = url
browser.loadURL(url) b.load(url)
loadEnded.set(false)
} }
fun reloadFromSettings() { fun onToolWindowSelected() {
loadCurrentUrl() if (browser == null) initBrowserSafely() else loadCurrentUrl()
} }
// ------------------------------------------------------------------
// 生命周期
// ------------------------------------------------------------------
override fun dispose() { override fun dispose() {
loadEnded.set(false) try { bridge?.dispose() } catch (_: Throwable) {}
if (::query.isInitialized) { try { browser?.dispose() } catch (_: Throwable) {}
query.dispose() bridge = null
} browser = null
if (::browser.isInitialized) {
Disposer.dispose(browser)
}
} }
companion object { companion object {
private val LOG = Logger.getInstance(HarmnessBrowserPanel::class.java) private const val CARD_STANDBY = "standby"
const val JS_BRIDGE_NAME = "harmnessBridge" private const val CARD_BROWSER = "browser"
private const val CARD_SETTINGS = "settings"
private const val CARD_ERROR = "error"
} }
} }
@@ -1,42 +0,0 @@
package com.harmness.toolwindow
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.options.Configurable.WithEpName
import javax.swing.JComponent
/**
* Settings configurable registered in plugin.xml.
* Shows the HarmnessBrowserConfigurablePanel under Tools → Harmness Browser.
*/
class HarmnessSettingsConfigurable : Configurable, WithEpName {
private var panel: HarmnessBrowserConfigurablePanel? = null
override fun getDisplayName(): String = "Harmness Browser"
override fun getHelpTopic(): String? = null
override fun createComponent(): JComponent {
panel = HarmnessBrowserConfigurablePanel()
return panel!!.panel
}
override fun isModified(): Boolean = panel?.isModified(SettingsState.getInstance()) ?: false
override fun apply() {
panel?.apply(SettingsState.getInstance())
}
override fun reset() {
panel?.reset(SettingsState.getInstance())
}
override fun disposeUIResources() {
panel = null
}
companion object {
private val LOG = Logger.getInstance(HarmnessSettingsConfigurable::class.java)
}
}
@@ -5,63 +5,48 @@ import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBPanel
import com.intellij.ui.content.ContentFactory import com.intellij.ui.content.ContentFactory
import javax.swing.JComponent import com.intellij.ui.content.ContentManagerEvent
import com.intellij.ui.content.ContentManagerListener
import java.awt.BorderLayout
/** /**
* 本插件的工具窗口工厂。 * 工具窗口工厂:创建侧边栏 "Harmness Browser"
* *
* 工具窗口是 IntelliJ 侧边栏的区域(如 Project、Terminal、Version Control * 采用防御式创建:面板构造或 JCEF 初始化失败时不会让整个工具窗口空白
* 由 IntelliJ 自动管理生命周期。我们在此创建一个自定义工具窗口 "Harmness Browser" * 而是显示具体错误信息,便于排查。
* 其内容就是 [HarmnessBrowserPanel]。
*
* plugin.xml 中通过扩展点注册本类:
* ```
* <toolWindow id="Harmness Browser" ... factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"/>
* ```
*/ */
class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware { class HarmnessToolWindowFactory : ToolWindowFactory, DumbAware {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
LOG.info("Creating Harmness Browser tool window for project: ${project.name}") LOG.info("Creating Harmness Browser tool window for project: ${project.name}")
// 检查 JCEF 支持 // 面板构造抛异常时,用一个错误提示面板代替,避免 IDE 显示空白内容
if (!isJcefSupported()) { val panel = try {
LOG.warn("JCEF is not supported in this IDE instance. The Harmness Browser tool window will show an error message.") HarmnessBrowserPanel(project, toolWindow.disposable)
} catch (t: Throwable) {
LOG.error("Failed to create HarmnessBrowserPanel", t)
val errPanel = JBPanel<JBPanel<*>>(BorderLayout())
val msg = buildString {
append("创建 Harmness 面板失败:\n")
append(t.javaClass.simpleName).append(": ").append(t.message ?: "no message")
}
errPanel.add(JBLabel(msg), BorderLayout.CENTER)
errPanel
} }
val panel = HarmnessBrowserPanel(project, toolWindow.disposable)
val content = ContentFactory.getInstance().createContent(panel, "Browser", false) val content = ContentFactory.getInstance().createContent(panel, "Browser", false)
toolWindow.contentManager.addContent(content) toolWindow.contentManager.addContent(content)
// 注册工具窗口可见性监听器,用于通知 WindowFocusService toolWindow.contentManager.addContentManagerListener(object : ContentManagerListener {
toolWindow.addContentManagerListener(object : com.intellij.ui.content.ContentManagerListener { override fun selectionChanged(event: ContentManagerEvent) {
override fun selectionChanged(selectionChange: com.intellij.ui.content.ContentManagerEvent) { if (event.content != toolWindow.contentManager.selectedContent) return
val isActive = selectionChange.manager.selectedContent == selectionChange.content runCatching { (panel as? HarmnessBrowserPanel)?.onToolWindowSelected() }
WindowFocusService.getInstance().setActive(isActive) .onFailure { LOG.warn("selectionChanged handler failed", it) }
if (isActive) {
// 当工具窗口被选中时,触发面板刷新
panel.loadCurrentUrl()
}
} }
}) })
// 注册工具窗口可见性监听器(窗口获得/失去焦点)
toolWindow.addVisibleListener { visible ->
WindowFocusService.getInstance().setActive(visible)
}
}
private fun isJcefSupported(): Boolean {
return try {
val clz = Class.forName("com.intellij.ui.jcef.JBCefApp")
val method = clz.getMethod("isSupported")
method.invoke(null) as Boolean
} catch (e: Exception) {
false
}
} }
companion object { companion object {
@@ -1,22 +1,23 @@
package com.harmness.toolwindow package com.harmness.toolwindow
import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.Logger
/** /**
* 插件设置:目前仅保存一个「浏览器起始地址」。 * 插件设置:仅保存「浏览器起始地址」。
* *
* 该地址必须是一个允许被嵌入的 Web 应用地址(例如本地 Harness 服务地址), * 通过 @Service 注解注册(无需在 plugin.xml 中重复声明)。
* 并且需要在页面上开启允许被 iframe/嵌入浏览(X-Frame-Options / CSP frame-ancestors
* 或者由 Harness 提供专门的无 frame 限制入口。
*/ */
@State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")]) @State(name = "HarmnessToolwindowSettings", storages = [Storage("harmness-toolwindow.xml")])
@Service(Service.Level.APP)
class SettingsState : PersistentStateComponent<SettingsState.State> { class SettingsState : PersistentStateComponent<SettingsState.State> {
data class State( data class State(
var startUrl: String = "http://127.0.0.1:8888", var startUrl: String = DEFAULT_URL,
) )
private var myState = State() private var myState = State()
@@ -36,9 +37,9 @@ class SettingsState : PersistentStateComponent<SettingsState.State> {
} }
companion object { companion object {
const val DEFAULT_URL = "http://127.0.0.1:8888" // 与 DSH Web GUI 默认端口一致
const val DEFAULT_URL = "http://127.0.0.1:8899"
fun getInstance(): SettingsState = fun getInstance(): SettingsState = service()
com.intellij.openapi.components.ServiceManager.getService(SettingsState::class.java)
} }
} }
@@ -1,48 +0,0 @@
package com.harmness.toolwindow
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
/**
* 全局单例服务,负责把「工具窗口是否可见/激活」的实时状态广播给浏览器面板。
*
* 之所以需要它:JCEF 浏览器的 JavaScript 侧想知道窗口何时被聚焦/失焦,
* 以便暂停或恢复渲染(例如 Harness 界面在后台时降低 CPU 占用)。
* 由于 ToolWindow 在内容区与焦点事件里提供的上下文有限,我们用这个轻量级
* 事件总线在 ToolWindowManager 和 JBCefBrowser 之间传递状态。
*/
@Service(Service.Level.APP)
class WindowFocusService {
private val logger = Logger.getInstance(WindowFocusService::class.java)
@Volatile
var active: Boolean = false
private set
private val listeners = mutableListOf<(Boolean) -> Unit>()
@Synchronized
fun addListener(listener: (Boolean) -> Unit) {
listeners.add(listener)
// 注册时立即补发一次当前状态,避免面板错过初始状态
listener(active)
}
@Synchronized
fun removeListener(listener: (Boolean) -> Unit) {
listeners.remove(listener)
}
@Synchronized
fun setActive(value: Boolean) {
if (active == value) return
active = value
logger.debug("Window focus changed: $value")
listeners.toList().forEach { it(value) }
}
companion object {
fun getInstance(): WindowFocusService = service()
}
}
@@ -0,0 +1,3 @@
<idea-plugin>
<!-- This file is loaded only when com.intellij.modules.jcef plugin is present -->
</idea-plugin>
+8 -28
View File
@@ -1,47 +1,27 @@
<idea-plugin> <idea-plugin>
<id>com.harmness.toolwindow</id> <id>com.prepeep.harmness-browser</id>
<name>Harmness Browser</name> <name>Harmness Browser</name>
<vendor>harmness</vendor> <vendor>prepeep</vendor>
<description> <description><![CDATA[
Embed a configurable browser panel (e.g. a local Harmness instance) directly inside Embed a configurable browser panel (e.g. a local Harmness instance) directly inside
JetBrains IDE sidebars. Enables seamless AI-assisted coding inside PyCharm / IntelliJ JetBrains IDE sidebars. Enables seamless AI-assisted coding inside PyCharm / IntelliJ
IDEA without switching to a separate browser window. IDEA without switching to a separate browser window.
</description> ]]></description>
<!-- <idea-version since-build="242" until-build="263.*"/>
IDE version compatibility:
sinceBuild / untilBuild 控制插件兼容的 IDE 版本范围。
这里要求 2024.2 及以上、2024.3.* 及以下。
如需支持更老版本,请参考 https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html
-->
<idea-version since-build="242" until-build="243.*"/>
<!-- 依赖 platform -->
<depends>com.intellij.modules.platform</depends> <depends>com.intellij.modules.platform</depends>
<!-- 可选依赖:JCEF 存在时加载 jcef-support.xml(当前为空占位,
未来 JCEF 相关扩展会放进该文件)。缺少 JCEF 也不影响插件加载 -->
<depends optional="true" config-file="jcef-support.xml">com.intellij.modules.jcef</depends>
<!-- 工具窗口扩展点 -->
<extensions defaultExtensionNs="com.intellij"> <extensions defaultExtensionNs="com.intellij">
<toolWindow <toolWindow
id="Harmness Browser" id="Harmness Browser"
icon="/icons/harmness.svg" icon="/icons/harmness.svg"
factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory" factoryClass="com.harmness.toolwindow.HarmnessToolWindowFactory"
anchor="right" anchor="right"
condition="class#==com.intellij.ui.jcef.JBCefApp"
/> />
<!-- 插件设置页:Settings | Tools | Harmness Browser -->
<applicationConfigurable
parent="tools"
instance="com.harmness.toolwindow.HarmnessSettingsConfigurable"
id="com.harmness.toolwindow.HarmnessSettingsConfigurable"
displayName="Harmness Browser"/>
<!-- 持久化组件 -->
<applicationService
serviceImplementation="com.harmness.toolwindow.SettingsState"/>
<applicationService
serviceImplementation="com.harmness.toolwindow.WindowFocusService"/>
</extensions> </extensions>
</idea-plugin> </idea-plugin>
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 512 512">
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgb(96,158,255)"/>
<stop offset="1" stop-color="rgb(150,92,255)"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="512" height="512" rx="82" fill="url(#tile)"/>
<circle cx="256" cy="244.74" r="137.22"
fill="none" stroke="white" stroke-width="21.5"/>
<ellipse cx="256" cy="244.74"
rx="137.22" ry="57.63"
transform="rotate(-17 256 244.74)"
fill="none" stroke="white" stroke-width="28.16"/>
<circle cx="349.03" cy="310.28" r="19.46" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 706 B

@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="rgb(96,158,255)"/>
<stop offset="1" stop-color="rgb(150,92,255)"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="512" height="512" rx="81.92" fill="url(#tile)"/>
<circle cx="256.00" cy="244.74" r="137.22"
fill="none" stroke="white" stroke-width="21.50"/>
<ellipse cx="256.00" cy="244.74"
rx="137.22" ry="57.63"
transform="rotate(-17.0 256.00 244.74)"
fill="none" stroke="white" stroke-width="28.16"/>
<circle cx="349.03" cy="310.28" r="19.46" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

+12 -8
View File
@@ -1,12 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40"> <svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 512 512">
<defs> <defs>
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" /> <stop offset="0" stop-color="rgb(96,158,255)"/>
<stop offset="100%" style="stop-color:#8B5CF6;stop-opacity:1" /> <stop offset="1" stop-color="rgb(150,92,255)"/>
</linearGradient> </linearGradient>
</defs> </defs>
<rect width="40" height="40" rx="8" fill="url(#g)"/> <rect x="0" y="0" width="512" height="512" rx="82" fill="url(#tile)"/>
<path d="M10 14h20v2H10zm0 5h20v2H10zm0 5h14v2H10z" fill="white"/> <circle cx="256" cy="244.74" r="137.22"
<circle cx="30" cy="27" r="6" fill="white" opacity=".25"/> fill="none" stroke="white" stroke-width="21.5"/>
<path d="M28 27l4-4" stroke="white" stroke-width="1.6" stroke-linecap="round"/> <ellipse cx="256" cy="244.74"
rx="137.22" ry="57.63"
transform="rotate(-17 256 244.74)"
fill="none" stroke="white" stroke-width="28.16"/>
<circle cx="349.03" cy="310.28" r="19.46" fill="white"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 602 B

After

Width:  |  Height:  |  Size: 706 B