Skip to content

Instantly share code, notes, and snippets.

@mucharafal
Created November 20, 2020 16:08
Show Gist options
  • Save mucharafal/1ca0284c3eb17084a75dae8e445bc401 to your computer and use it in GitHub Desktop.
Save mucharafal/1ca0284c3eb17084a75dae8e445bc401 to your computer and use it in GitHub Desktop.
Blogpost- JCEF

Hello!

In this short post, I would like to show you, how to create a simple plugin for IntelliJ, which uses JCEF, the new WebView framework. But first, short story - why and how do we use JCEF? As you can read in our previous blog post, for a few months in our R&D team we are developing a CodeTale. It is a plugin to your IDE, which enables you to browse comments from PR's. To make the UI easier to reuse with many IDEs, we have decided to use WebView. If you are not familiar with it - it's an embedded web browser, which allows you to create a UI once in the form of a normal webpage. From the beginning of the project, we have decided to support two IDEs: IntelliJ and VSCode, as one of most popular IDEs. VSCode is built on WebView technology - Electron, so it uses an efficient Chromium web engine. On the opposite, IntelliJ up to the middle of this year supports only the obsolete JavaFX version of WebView. It is based on the old Safari engine, which makes some problems with the UI (mainly very poor performance). However, in version 2020.2 there was introduced a new officially supported version of WebView - JCEF. Such improvement was requested for a long time, and finally, there is it! It is based on Chromium, so it is much faster and easier to debug. Whatsmore, the old one is no longer officially supported. To be able to run JavaFX's WebView there must be an additional plugin installed. Maybe in the next blog post, I would present an example of migration - please, let me know if you are interested in :) But now, I would like to show you how to build a toy plugin for IntelliJ, which would use JCEF - including a nice trick, how to use files from the resources folder in jar archive (jar protocol is unfortunately not supported by JCEF).

What do you need?

  • installed Java
  • installed or downloaded Gradle
  • your favorite IDE
  • 10 minutes
  • cup of coffee or mug of tea ;)

So, let's start!

At first, we need to create plugin.xml file:

<idea-plugin>
<id>catviewer</id>
<name>CatViewer</name>
<version>1.0</version>
 
<description><![CDATA[
Your employee track every minute spent in the browser? Use CatViewer in your IDE, and browse funny cats along your code!
]]></description>
 
<idea-version since-build="192.5118"/>
 
<depends>com.intellij.modules.platform</depends>
 
<extensions defaultExtensionNs="com.intellij">
  <toolWindow id="CatViewer" anchor="right" factoryClass="com.catviewer.WindowFactory"/>
</extensions>
 
</idea-plugin>

It should be located in src/main/resources/META-INF/plugin.xml. What has happened above? It is a file with some general information about the plugin, like version, name, or id. An important part of it is extensions - there are declared services or tool windows, like it is in our case. I.e. Projects is one of the default tool windows in IntelliJ.

Let’s look closer to it:

<toolWindow id="CatViewer" anchor="right" factoryClass="com.catviewer.WindowFactory"/>

Tool window has a few properties. Some of them- like id or factoryClass are obligatory, but others, like icon is optional. Keep in mind, that id has in this case two functions- it is the name displayed on the button, but also this tool window is identified by it. So if later in code, you want to access this tool window, you can do it only by that name. And factory class- it must contain a classpath to class in your project, which implements ToolWindowFactory with its method createToolWindowContent. This method is invoked by IDE, when the tool window button is pressed. The next step would be creating a factory class mentioned above.

package com.catviewer
 
import com.intellij.openapi.components.ServiceManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.{ ToolWindow, ToolWindowFactory }
 
class WindowFactory extends ToolWindowFactory {
override def createToolWindowContent(project: Project, toolWindow: ToolWindow): Unit = {
  val catViewerWindow = ServiceManager.getService(project, classOf[CatViewerWindowService]).catViewerWindow
  val component = toolWindow.getComponent
  component.getParent.add(catViewerWindow.content)
}
}

Save it to src/main/scala/com/catviewer/WindowFactory.scala. Surprised about language? Yes, it is possible to develop plugins for IntelliJ also using Scala! Unfortunately, it also hides some types, so I need to describe it a bit. In the above method, the WebView window is injected into the ToolWindow component. As IntelliJ UI is written using Swing, it must be passed as a compatible component. But what about the ServiceManager? It is a typical way of implementing singleton in plugins- create service, which would create and manage access to object. In this case it is a WebView window. In the next step let's create service and window class itself, so everything should be clear:

package com.catviewer
 
import com.intellij.openapi.project.Project
import com.catviewer.CatViewerWindow
 
class CatViewerWindowService(val project: Project) {
val catViewerWindow = CatViewerWindow(project)
}

Save it to src/main/scala/com/catviewer/CatViewerWindowService.scala. Also add it as project service by adding line to extensions field in plugin.xml:

<extensions defaultExtensionNs="com.intellij">
  <toolWindow id="CatViewer" anchor="right" factoryClass="com.catviewer.WindowFactory"/>
  <projectService id="CatViewerWindowService" serviceImplementation="com.catviewer.CatViewerWindowService"/>
</extensions>

Generally services in IntelliJ are initialized lazily, so if you want to make sure that your service is running, remember to access it (as it is done in WindowFactory). And CatViewerWindow (src/main/scala/com/catviewer/CatViewerWindow.scala):

package com.catviewer
 
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeoutException
 
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.ui.jcef.JBCefBrowser
import com.catviewer.CustomSchemeHandlerFactory
import javax.swing.JComponent
import org.cef.CefApp
 
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration.Duration
import scala.concurrent.{ Await, Future, Promise }
 
case class CatViewerWindow(val project: Project) {
private lazy val webView: JBCefBrowser = {
  createAndSetUpWebView()
}
 
def content: JComponent = webView.getComponent
 
private def createAndSetUpWebView(): JBCefBrowser = {
  val webView = new JBCefBrowser()
 
  registerAppSchemeHandler()
 
  webView.loadURL("http://myapp/index.html")
 
  Disposer.register(project, webView)
 
  webView
}
 
private def registerAppSchemeHandler(): Unit = {
  CefApp
    .getInstance()
    .registerSchemeHandlerFactory(
      "http",
      "myapp",
      new CustomSchemeHandlerFactory
    )
}
}

As you can see, it is almost the same, as in the Jetbrains documentation. There are a few places worth looking. Notice the "myapp" domain registered in WebView - it allows us to use files from the resources folder in our jar archive. To do this, we need to implement CustomResourceHandler and CustomSchemeHandlerFactory (this is the next part). Another not obvious point is registering webview in disposer- it helps IDE with memory management (and is a must to avoid “Memory leak detected” in IntelliJ logs). src/main/scala/com/catviewer/CustomResourceHandler:

package com.catviewer
 
import java.io.{IOException, InputStream}
import java.net.JarURLConnection
 
import org.cef.callback.CefCallback
import org.cef.handler.{CefLoadHandler, CefResourceHandler}
import org.cef.misc.{IntRef, StringRef}
import org.cef.network.{CefRequest, CefResponse}
 
class CustomResourceHandler extends CefResourceHandler {
var connectionOption: Option[JarURLConnection] = None
var inputStreamOption: Option[InputStream] = _
override def processRequest(cefRequest: CefRequest, cefCallback: CefCallback): Boolean = {
  val urlOption = Option(cefRequest.getURL)
  urlOption match {
    case Some(processedUrl) =>
      val pathToResource = processedUrl.replace("http://myapp", "webview/")
      val newUrl = getClass.getClassLoader.getResource(pathToResource)
      connectionOption = Some(newUrl.openConnection().asInstanceOf[JarURLConnection])
      cefCallback.Continue()
      true
    case None => false
  }
}
 
override def getResponseHeaders(cefResponse: CefResponse, responseLength: IntRef, redirectUrl: StringRef): Unit = {
  if (connectionOption.isDefined) {
    try {
      val connection = connectionOption.get
      cefResponse.setMimeType(connection.getContentType)
      if (connectionOption.get.getURL.toString.contains("css")) {
        cefResponse.setMimeType("text/css")
      } else {
        if (connectionOption.get.getURL.toString.contains("js")) {
          cefResponse.setMimeType("text/javascript")
        }
      }
      inputStreamOption = Some(connection.getInputStream)
      responseLength.set(inputStreamOption.get.available())
      cefResponse.setStatus(200)
    } catch {
    case e: IOException =>
      cefResponse.setError(CefLoadHandler.ErrorCode.ERR_FILE_NOT_FOUND)
      cefResponse.setStatusText(e.getLocalizedMessage)
      cefResponse.setStatus(404)
    }
  } else {
    cefResponse.setStatus(404)
  }
}
 
override def readResponse(
  dataOut: Array[Byte],
  designedBytesToRead: Int,
  bytesRead: IntRef,
  callback: CefCallback
): Boolean = {
  if (inputStreamOption.isDefined) {
    val inputStream = inputStreamOption.get
    val availableSize = inputStream.available()
    if (availableSize > 0) {
      val bytesToRead = Math.min(availableSize, designedBytesToRead)
      val realNumberOfReadBytes = inputStream.read(dataOut, 0, bytesToRead)
      bytesRead.set(realNumberOfReadBytes)
      true
    } else {
      inputStreamOption.get.close()
      connectionOption = None
      inputStreamOption = None
      false
    }
  } else {
    false
  }
}
 
override def cancel(): Unit = {
  inputStreamOption = None
  connectionOption = None
}
}

In terms of logic - it is quite straightforward. We need to implement the logic for opening files, returning headers, sending data, and handling cases of cancelation. Page sources are loaded from the webview folder in sources. And CustomSchemeHandlerFactory:

package com.catviewer
 
import org.cef.browser.{ CefBrowser, CefFrame }
import org.cef.callback.CefSchemeHandlerFactory
import org.cef.handler.CefResourceHandler
import org.cef.network.CefRequest
 
class CustomSchemeHandlerFactory extends CefSchemeHandlerFactory {
override def create(
  cefBrowser: CefBrowser,
  cefFrame: CefFrame,
  s: String,
  cefRequest: CefRequest
): CefResourceHandler = {
  new CustomResourceHandler()
}
}

And finally, we can create WebView folder in out resources and insert there our website files. index.html (src/main/resources/webview/index.html)

<html>
<body>
  <div>
    <img src="cat.png"/>
  </div>
</body>
</html>

Insert there also your favorite image with the name cat.png (src/main/resources/webview/cat.png). To build everything we would use gradle. build.gradle:

buildscript {
repositories {
jcenter()
mavenCentral()
maven { url 'https://plugins.gradle.org/m2/' }
maven { url 'https://dl.bintray.com/jetbrains/intellij-plugin-service' }
maven { url 'https://dl.bintray.com/jetbrains/intellij-third-party-dependencies/' }
}
dependencies {
  classpath "org.jetbrains.intellij.plugins:gradle-intellij-plugin:0.5.0-SNAPSHOT"
  classpath "cz.alenkacz:gradle-scalafmt:1.13.0"
}
}
 
apply plugin: 'idea'
apply plugin: 'scala'
apply plugin: 'org.jetbrains.intellij'
apply plugin: 'scalafmt'
 
ext {
scalaVersion = "2.13.1"
}
 
sourceSets {
main.scala.srcDirs = ['src/main/scala']
main.java.srcDirs = []
}
 
dependencies {
implementation group: "org.scala-lang", name: "scala-library", version: scalaVersion
implementation group: 'org.scala-lang', name: 'scala-reflect', version: scalaVersion
}
 
sourceCompatibility = 11
targetCompatibility = 11

Place it in the root of the project. And finally - to run IntelliJ with our plugin: $ gradle runIde On the right toolbar, you should see the CatViewer button. After clicking on it, a tool window with your photo should appear.

Of course, WebView can show much more. My colleagues develop quite a heavy app for browsing code like a graph - and for showing it in IntelliJ, they use JCEF. Project has the name GraphBuddy- please check it, even to see how complex things can be presented using WebView.

Ok, that's all. Thank you for reading! Happy coding & keep safe!

More materials:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment