Write a Chrome Extension with Kotlin

Gary Laurenceau
8 min readApr 11, 2023

--

Hello everyone! This is my first post on Medium, and I’m excited to share my knowledge with you. In this post, I will explain how to build a Chrome extension using Kotlin with Compose. As Kotlin is a cross-platform language, it can be used to add a new target for your product, alongside Android / iOS, or desktop app. I hope you find this article helpful, and I welcome any feedback you may have. Let’s get started!

While there are many code examples on creating extensions with Kotlin, they often lack a clean and simple implementation that integrates a background script, a content script, and a popup window. This tutorial will show you how to create a multi-module build with Gradle that implements all of these components seamlessly.

Kotlin 1.8.0 and Compose 1.3.0 will be used, and we’ll start everything from scratch. If you’re not familiar with Compose, have a look at the Compose-Multiplatform depot. It contains a lot of great tutorials and example to begin. You can also have a look at the documentation provided by JetBrains to learn more about Kotlin with JS. If you just want to see the code, check out the depot on GitHub.

Module and Build Setup

To set up our project, we’ll create several sub-modules in our project. These modules are as follows:

  • app: This module contains shared HTML, CSS, icons, resources, and the manifest file. Its purpose is to package the extension.
  • background: This module contains the background script code.
  • buildSrc: This module defines the dependencies with Kotlin DSL so that the definitions can be accessed and auto-completed from other gradle.build.kts files.
  • chrome: This module contains external definitions of the Chrome API. It’s based on the depot chrome-ts2kt.
  • content: This module defines the content script.
  • data: This is a shared module that defines data used by other sub-modules.
  • popup: This module contains the code for the popup window.

By splitting our project into these sub-modules, we can easily manage our codebase and make it more modular. This allows us to reuse code and reduce duplication, which can save time and effort in the long run.

List of submodules for our simple Chrome Extension

Once all modules created, don’t forget to include them in the settings.gradle.kts:

include("app")
include("chrome")
include("background")
include("content")
include("popup")
include("data")

Let’s start with "buildSrc” module:

In build.gradle.kts we enable kotlin-dsl and list the modules used in the other modules. It also gathers the list of external dependencies:

buildSrc module

build.gradle.kts:

plugins {
`kotlin-dsl`
}

repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}

dependencies {
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${rootProject.properties["kotlin.version"]}")
implementation("org.jetbrains.kotlin:kotlin-serialization:${rootProject.properties["kotlin.version"]}")
implementation("org.jetbrains.compose:compose-gradle-plugin:${rootProject.properties["compose.version"]}")
}

gradle.properties to set the required versions:

kotlin.version=1.8.0
compose.version=1.3.0

Here’s a basic example of how to declare the list of libraries in dependencies.kt. But I encourage you to split it several files if you have a lot of dependencies.

object Deps {

object Gradle {
private const val KOTLIN_VERSION = "1.8.0"
const val kotlinPlugin = "org.jetbrains.kotlin:kotlin-gradle-plugin:$KOTLIN_VERSION"
private const val COMPOSE_VERSION = "1.3.0"
const val composePlugin = "org.jetbrains.compose:compose-gradle-plugin:$COMPOSE_VERSION"
}

object Kotlin {
private const val SERIALIZATION_VERSION = "1.5.0"
const val serialization = "org.jetbrains.kotlinx:kotlinx-serialization-json:$SERIALIZATION_VERSION"

object Coroutines {
private const val VERSION = "1.6.4"
const val core = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${VERSION}"
const val core_js = "org.jetbrains.kotlinx:kotlinx-coroutines-core-js:${VERSION}"
}
}
}

“app” module:

Now that we’ve setup the build module, we can start using in other submodules. “app” will contain only static code, html, icons, css, manifest.json.

app module

In the build.gradle.kts, you have to tell gradle it’s a javascript module and enable the new compiler IR:

plugins {
kotlin("js")
}

group = "com.template"
version = "0.1.0"

dependencies {
implementation(project(":background"))
implementation(project(":content"))
implementation(project(":popup"))
}

kotlin {
js(IR)
}

By default Kotlin/JS doesn’t provide any task to build an extension, only tasks to create JS libraries or classic web app. So let’s create a new task to package the generated scripts from background, content and popup with the static resources:

tasks {
// Copy js scripts
val background = ":background:browserDistribution"
val content = ":content:browserDistribution"
val popup = ":popup:browserDistribution"
val extensionFolder = "$projectDir/build/extension"
val copyBundleFile = register<Copy>("copyBundleFile") {
dependsOn(background, content, popup)
from(
"$projectDir/../build/distributions/background.js",
"$projectDir/../build/distributions/content.js",
"$projectDir/../build/distributions/popup.js",
)
into(extensionFolder)
}

// Copy resources
val copyResources = register<Copy>("copyResources") {
val resourceFolder = "src/main/resources"
from(
"$resourceFolder/manifest.json",
"$resourceFolder/icons",
"$resourceFolder/html",
"$resourceFolder/css"
)
into(extensionFolder)
}

// Build modules
val buildExtension = register("buildExtension") {
dependsOn(copyBundleFile, copyResources)
}
}

For this tutorial, the manifest is really simple, and uses the version 3.

{
"manifest_version": 3,

"name": "Kotlin Extension",
"description": "",
"version": "0.1.0",

"action": {
"default_icon": "icon.png",
"default_popup": "popup.html"
},

"icons": {
"16": "icon16.png",
"32": "icon32.png",
"48": "icon48.png",
"64": "icon64.png",
"128": "icon.png"
},

"content_scripts": [
{
"matches": ["*://*/*"],
"js": [
"content.js"
]
}
],

"background": {
"service_worker": "background.js"
},

"permissions": [
"background"
]
}

Lear more on the official Chrome documentation.

Leave this module for the moment, now we will create the background module to handle the messages!

“background” module:

In the background script, we can add listeners to handle messages from the popup or content script. We can start by adding a listener to handle incoming messages:

fun main() {
chrome.runtime.onMessage.addListener { request, sender, sendResponse ->
println("Background receives a message: ${request}")
}
}

external object chrome {
object runtime {
var onMessage: ExtensionMessageEvent = definedExternally
}
}

Because chrome API is not available in a library but provided by chrome at runtime, we need to define this function as external in our code. For more convenience, put external Chrome declarations in the “chrome” module. You can find a bunch of API definitions ready to copy/paste in chrome-ts2kt depot.

Using Kotlin serialization, we can decode or encode the JSON and create a Kotlin object:

val message = Json.decodeFromString<Message>(request.toString())

val json = Json.encodeToString(message)

The Message object is used to represent a message that will be passed between background and front script, it must be serializable into JSON.

@Serializable
data class Message(
@SerialName("action") val action: String,
@SerialName("message") val message: String
)

Putting everything together, we can handle messages and send responses:

fun main() {
println("Background script initialized")
chrome.runtime.onMessage.addListener { request, sender, sendResponse ->
println("Background receives a message: ${request}")
val message = Json.decodeFromString<Message>(request.toString())
val response = when (message.action) {
"POPUP_CLICK" -> Message(action = "BG_RESPONSE", message = "Message from background to popup")
"CONTENT_SCRIPT_CLICK" -> Message(action = "BG_RESPONSE", message = "Message from background to content script")
else -> Message(action = "ERROR", message = "Action not handled")
}
sendResponse(Json.encodeToString(response))
}
}

“popup” module:

We’ll start by displaying a button using Compose, and when the user clicks on it, we’ll send a message to the backend and display its response in the popup.

The popup.html is defined in the app module's resources folder. Here's an example of what it might look like:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Popup</title>
<link type="text/css" rel="stylesheet" href="style.css">
</head>
<body style="width:200px; height:100px">
<div id="root"></div>
<script type="text/javascript" src="popup.js"></script>
</body>
</html>

Note that our popup.js script is inserted after the div element with the id composeRoot. This is to ensure that the div element exists when the script starts.

popup.kt uses renderComposable to render HTML. Make sure to use the id defined in the popup.html.

fun main() {
renderComposable("root") {
var result by remember { mutableStateOf("") }
Button({
onClick {
sendMessage {
result = "Message result: ${it.message}"
}
}
}) {
Text("Send message")
}

Text(result)
}
}

@NoLiveLiterals
private fun sendMessage(onResponse: (Message) -> Unit) {
val message = Message(action = "POPUP_CLICK", message = "Message from popup")
chrome.runtime.sendMessage(
message = Json.encodeToString(message),
responseCallback = { response ->
val message = Json.decodeFromString<Message>(response.toString())
onResponse(message)
}
)
}

external fun sendMessage(message: Any, responseCallback: ((response: Any) -> Unit)? = definedExternally /* null */): Unit = definedExternally

When the user clicks on the button, chrome.runtime.sendMessage sends a message to the backend. Once a response is received, it updates the result state, which will cause the text element to display the message in the popup.

First build:

The build.gradle.kts scripts are pretty simple, and similar in for background, popup and content. Here’s the popup build script. It configures the Kotlin-JS module to build an executable and specifies the output directory for the distribution. For background, only the dependencies would change.

plugins {
kotlin("js")
}

dependencies {
implementation(kotlin("stdlib-js"))
implementation(project(":chrome"))
implementation(project(":data"))
implementation(Deps.Kotlin.serialization)
}

kotlin {
js(IR) {
binaries.executable()
browser {
distribution {
directory = File("$rootDir/build/distributions/")
}
}
}
}

To build, load, and test the extension follow these steps:

  1. Run the Gradle task app:buildExtension.
app tasks: buildExtension

2. Once the build is successful, open Chrome and go to the extensions page (chrome://extensions).

3. Turn on “Developer mode” in the top right corner.

4. Click on “Load unpacked” and select the app/build/extension folder.

5. The extension should now be loaded and visible in the extensions page. From here you can click on service worker to have access to the background logs.

Extension developer mode

6. Open the popup by clicking on the extension icon in the top right corner of the browser.

7. Click on “Send message” , the response from the background script should be displayed in the popup.

Popup view

That’s it! You have successfully built your first Chrome extension with Kotlin.

Add content script:

Most of the extension will need to add a content script to inject views or logic in webpages. It’s really similar to the popup script, except for the setup of Compose. We need to inject a view into the webpage. For that, create a div element and append it to the document body, then renderComposable can use it:

fun main() {
val element = document.createElement("div")
element.id = "rootDialog"
document.body?.appendChild(element)
renderComposable(element) {
...
}
}

For example, if you run this extension on Github page, you will see this button in the bottom of the page.

Content script button

That’s all !

In conclusion, it’s pretty simple and fast to build extensions for Chrome. After setting up the build configuration and establishing communication between the background and front scripts, you can easily render views in the popup, inject them into webpages, and communicate with the background to perform various operations, such as syncing and database management.

Things to know:

The app module packages CSS and icons. When accessing icons from the content script (it’s not an issue from popup), make sure to wrap the path with chrome.runtime.getURL("icons/image.png") to prevent the browser from fetching the resource from the website.

All the code is available on GitHub.

Next:

This tutorial is the first of a serie to learn how Kotlin can be used as a unique language to build everything, not just mobile apps. It will cover backend code, cloud function, plugin development and more. Everything I came across when building https://pixel-perfect.dev/ and https://sumupnow.com/

--

--