diff --git a/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/DIModule.kt b/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/DIModule.kt
index d1fa1e30a..c94ae0ab8 100644
--- a/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/DIModule.kt
+++ b/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/DIModule.kt
@@ -1,6 +1,7 @@
package com.kitakkun.jetwhale.demo.shared
import com.kitakkun.jetwhale.plugins.example.agent.ExampleAgentPlugin
+import com.kitakkun.jetwhale.plugins.example.agent.ExampleWebAgentPlugin
import com.kitakkun.jetwhale.plugins.network.agent.JetWhaleNetworkAgentPlugin
import com.kitakkun.jetwhale.plugins.network.agent.ktor.ktorClientPlugin
import io.ktor.client.HttpClient
@@ -11,6 +12,9 @@ import io.ktor.client.request.header
object DIModule {
val exampleAgentPlugin: ExampleAgentPlugin by lazy { ExampleAgentPlugin() }
+ /** Agent counterpart of the experimental web-based host plugin. */
+ val exampleWebAgentPlugin: ExampleWebAgentPlugin by lazy { ExampleWebAgentPlugin() }
+
val networkAgentPlugin: JetWhaleNetworkAgentPlugin by lazy { JetWhaleNetworkAgentPlugin() }
/** A demo Ktor client wired to the Network Inspector so its traffic shows up in the debugger. */
diff --git a/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt b/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt
index 6fa31ab2c..3f70d62fa 100644
--- a/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt
+++ b/demo/shared/src/commonMain/kotlin/com/kitakkun/jetwhale/demo/shared/InitializeJetWhale.kt
@@ -24,6 +24,7 @@ fun initializeJetWhale() {
plugins {
register(DIModule.exampleAgentPlugin)
+ register(DIModule.exampleWebAgentPlugin)
register(DIModule.networkAgentPlugin)
}
}
diff --git a/docs/guide/developing-plugins.md b/docs/guide/developing-plugins.md
index 1f23ea9db..622ef60fb 100644
--- a/docs/guide/developing-plugins.md
+++ b/docs/guide/developing-plugins.md
@@ -368,6 +368,106 @@ Read it (`LocalJetWhaleDarkTheme.current`) to pick theme-appropriate colors inst
`isSystemInDarkTheme()`, which reflects the OS setting and can disagree with the host's own Theme
option.
+## Web-based UI
+
+A host plugin's UI can be a web page (React, Vue, plain HTML — anything that builds to static assets
+or runs on a dev server) instead of Compose. The page is shown by an embedded Chromium browser and
+talks to its agent counterpart through an injected `window.jetwhale` bridge.
+
+The recommended form is a **pure web plugin**: no plugin code to write or compile. You ship only a
+manifest plus your built web assets — the host provides the plugin implementation and wires the
+bridge to the agent for you.
+
+### Pure web plugin (no Kotlin)
+
+Declare a `web` block in the manifest instead of a `factoryClass`, and put your built output under
+`src/main/resources//`:
+
+```json
+{
+ "plugins": [
+ {
+ "pluginId": "com.example.myplugin",
+ "pluginName": "My Web Plugin",
+ "version": "1.0.0",
+ "web": {
+ "entry": "index.html",
+ "resourceRoot": "web/myplugin",
+ "devServerUrlProperty": "myplugin.devServer"
+ },
+ "agentVersionRange": { "min": "1.0.0", "max": "1.0.0" }
+ }
+ ]
+}
+```
+
+- **Bundled assets** are served over a loopback `http://127.0.0.1` origin (so `fetch()` and relative
+ URLs behave as on a real web server), then the browser opens `entry`. Copy a Vite `dist/` (etc.)
+ into `src/main/resources/web/myplugin/`.
+- **Dev server / HMR:** set `devServerUrlProperty` and launch with e.g.
+ `-Dmyplugin.devServer=http://localhost:5173` to load a running dev server instead of the bundle.
+
+That is the whole plugin — only the manifest and the assets. It still needs an agent counterpart
+advertising the same `pluginId` to appear (like any messaging plugin).
+
+Because there is no code to compile, you do not need Gradle or any JVM tooling: zip the two entries
+and install the archive directly. Lay the files out as they sit inside the archive —
+
+```
+myplugin/
+├── META-INF/jetwhale/plugin-manifest.json
+└── web/myplugin/ # matches "resourceRoot"
+ ├── index.html # matches "entry"
+ └── assets/…
+```
+
+— then `cd myplugin && zip -r ../myplugin.zip .` and install `myplugin.zip` from **Settings →
+Plugins** (the picker accepts `.jar` and `.zip`). A Kotlin-authored plugin still ships as the usual
+`.jar`; a `.zip` is just a plugin archive with no compiled classes.
+
+### The `window.jetwhale` bridge
+
+The page reaches the agent through a bridge injected once the document loads (a `jetwhale:ready`
+event also fires on `window`):
+
+```js
+// fire-and-forget event to the agent
+window.jetwhale.send("example/note", JSON.stringify({ text: "hi" }));
+
+// request-reply; resolves with the agent's reply payload (a JSON string)
+window.jetwhale.request("example/ping", "{}")
+ .then(reply => console.log(reply))
+ .catch(err => console.error(err.message));
+
+// every agent event arrives here as (wireType, payloadJson)
+window.jetwhale.onMessage((type, payloadJson) => { /* ... */ });
+```
+
+`type` is the message's wire name — its `@SerialName` (or fully-qualified name). For TypeScript, copy
+`jetwhale.d.ts` (next to the example page) into your project for a typed `window.jetwhale`.
+
+### Advanced: Kotlin-authored web plugin
+
+Write plugin code only when you need custom Kotlin logic (MCP tools, storage migrations, transforming
+messages). Implement `JetWhaleWebHostPluginUi` and call `JetWhaleWebView` from `Content`, passing the
+plugin's `messenger`, a `JetWhaleWebBridge`, and a `JetWhaleWebSource`
+(`BundledAsset(classLoader = javaClass.classLoader, …)` or `DevServer(url)`). Forward agent messages
+to the page with `bridge.emit(type, payloadJson)` from a `configure { … }` handler.
+
+### Things to know
+
+- The APIs are `@ExperimentalJetWhaleApi` and may change between releases.
+- The Chromium runtime is downloaded and initialized **the first time any web plugin is shown** (a
+ one-time download); plugins that never open one are unaffected.
+- A web plugin **cannot be captured by `jetwhale.screenshot`**, and Compose overlays the host draws
+ may be occluded by the browser surface.
+- Agent → web-UI **events** are delivered generically. Agent-initiated **requests** to the UI need a
+ Kotlin `onRawRequest` handler (advanced) — the pure form forwards events only.
+
+A complete in-repo example — a pure web manifest, its bundled page + `jetwhale.d.ts`, and its agent
+counterpart — lives in `jetwhale-plugins/example` (`plugin-manifest.json` `com.kitakkun.jetwhale.example.web`,
+`resources/web/example/`, `ExampleWebAgentPlugin`).
+
## Persistent storage
Every host plugin instance gets a persistent key-value store via the protected `storage` property,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4e577fdab..bbf6bebd9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -42,6 +42,9 @@ conveyorControl = "1.1"
bouncyCastle = "1.83"
+# Embedded browser for experimental web-based host plugins (Compose-independent JCEF wrapper).
+kcef = "2025.03.23"
+
[libraries]
# Gradle Plugins
kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
@@ -55,11 +58,16 @@ mavenPublishGradlePlugin = { module = "com.vanniktech:gradle-maven-publish-plugi
# kotlin
kotlinTest = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+# webview (experimental web-based host plugins)
+kcef = { module = "dev.datlag:kcef", version.ref = "kcef" }
+
# kotlinx
kotlinxSerializationCore = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationJson" }
kotlinxSerializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
kotlinxCollectionsImmutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" }
kotlinxCoroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
+# Swing dispatcher required by KCEF (the embedded browser drives its callbacks on the AWT/Swing thread).
+kotlinxCoroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" }
byteBuddyAgent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" }
javaKeyring = { module = "com.github.javakeyring:java-keyring", version.ref = "javaKeyring" }
kotlinxDatetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
@@ -73,6 +81,8 @@ androidxDatastoreCoreOkio = { module = "androidx.datastore:datastore-core-okio",
# compose
jetbrainsComposeRuntime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "jetbrainsCompose" }
+jetbrainsComposeFoundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "jetbrainsCompose" }
+jetbrainsComposeUi = { module = "org.jetbrains.compose.ui:ui", version.ref = "jetbrainsCompose" }
jetbrainsComposePreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "jetbrainsCompose" }
jetbrainsComposeResources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "jetbrainsCompose" }
jetbrainsComposeSplitPane = { module = "org.jetbrains.compose.components:components-splitpane", version.ref = "jetbrainsCompose" }
diff --git a/jetwhale-host-sdk/api/jetwhale-host-sdk.api b/jetwhale-host-sdk/api/jetwhale-host-sdk.api
index 20e24cc3a..2eeab8464 100644
--- a/jetwhale-host-sdk/api/jetwhale-host-sdk.api
+++ b/jetwhale-host-sdk/api/jetwhale-host-sdk.api
@@ -35,8 +35,8 @@ public abstract interface class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPlugi
public final class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest {
public static final field $stable I
public static final field Companion Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Companion;
- public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;)V
- public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;)V
+ public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()Ljava/lang/String;
@@ -44,8 +44,9 @@ public final class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest {
public final fun component5 ()Z
public final fun component6 ()Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;
public final fun component7 ()Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;
- public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest;
- public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest;
+ public final fun component8 ()Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;
+ public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest;
+ public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ZLcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Icon;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest;
public fun equals (Ljava/lang/Object;)Z
public final fun getAgentVersionRange ()Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$AgentVersionRange;
public final fun getFactoryClass ()Ljava/lang/String;
@@ -54,6 +55,7 @@ public final class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest {
public final fun getPluginName ()Ljava/lang/String;
public final fun getRequiresAgent ()Z
public final fun getVersion ()Ljava/lang/String;
+ public final fun getWeb ()Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}
@@ -140,6 +142,40 @@ public final class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$Ico
public final fun serializer ()Lkotlinx/serialization/KSerializer;
}
+public final class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi {
+ public static final field $stable I
+ public static final field Companion Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi$Companion;
+ public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
+ public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun component2 ()Ljava/lang/String;
+ public final fun component3 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;
+ public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getDevServerUrlProperty ()Ljava/lang/String;
+ public final fun getEntry ()Ljava/lang/String;
+ public final fun getResourceRoot ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final synthetic class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi$$serializer : kotlinx/serialization/internal/GeneratedSerializer {
+ public static final field $stable I
+ public static final field INSTANCE Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi$$serializer;
+ public final fun childSerializers ()[Lkotlinx/serialization/KSerializer;
+ public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;
+ public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object;
+ public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor;
+ public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi;)V
+ public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V
+ public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer;
+}
+
+public final class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest$WebUi$Companion {
+ public final fun serializer ()Lkotlinx/serialization/KSerializer;
+}
+
public final class com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifestFile {
public static final field $stable I
public static final field Companion Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifestFile$Companion;
@@ -320,3 +356,60 @@ public final class com/kitakkun/jetwhale/host/sdk/ScreenshotCaptureKt {
public static final fun getLocalIsScreenshotCapture ()Landroidx/compose/runtime/ProvidableCompositionLocal;
}
+public final class com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebBridge {
+ public static final field $stable I
+ public fun ()V
+ public final fun emit (Ljava/lang/String;Ljava/lang/String;)V
+}
+
+public abstract interface class com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebHostPluginUi : com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginUi {
+}
+
+public abstract interface class com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource {
+}
+
+public final class com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$BundledAsset : com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource {
+ public static final field $stable I
+ public fun (Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/ClassLoader;
+ public final fun component2 ()Ljava/lang/String;
+ public final fun component3 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$BundledAsset;
+ public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$BundledAsset;Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$BundledAsset;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getClassLoader ()Ljava/lang/ClassLoader;
+ public final fun getEntry ()Ljava/lang/String;
+ public final fun getResourceRoot ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$DevServer : com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource {
+ public static final field $stable I
+ public fun (Ljava/lang/String;)V
+ public final fun component1 ()Ljava/lang/String;
+ public final fun copy (Ljava/lang/String;)Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$DevServer;
+ public static synthetic fun copy$default (Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$DevServer;Ljava/lang/String;ILjava/lang/Object;)Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource$DevServer;
+ public fun equals (Ljava/lang/Object;)Z
+ public final fun getUrl ()Ljava/lang/String;
+ public fun hashCode ()I
+ public fun toString ()Ljava/lang/String;
+}
+
+public final class com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebViewKt {
+ public static final fun JetWhaleWebView (Lcom/kitakkun/jetwhale/protocol/messaging/JetWhaleMessenger;Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebBridge;Lcom/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V
+}
+
+public final class com/kitakkun/jetwhale/host/sdk/web/WebManifestHostPluginKt {
+ public static final fun webManifestPluginFactory (Lcom/kitakkun/jetwhale/host/sdk/web/WebPluginConfig;)Lcom/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginFactory;
+}
+
+public final class com/kitakkun/jetwhale/host/sdk/web/WebPluginConfig {
+ public static final field $stable I
+ public fun (Ljava/lang/ClassLoader;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
+ public final fun getClassLoader ()Ljava/lang/ClassLoader;
+ public final fun getDevServerUrl ()Ljava/lang/String;
+ public final fun getEntry ()Ljava/lang/String;
+ public final fun getResourceRoot ()Ljava/lang/String;
+}
+
diff --git a/jetwhale-host-sdk/build.gradle.kts b/jetwhale-host-sdk/build.gradle.kts
index 3dbb7f554..9d8084eb7 100644
--- a/jetwhale-host-sdk/build.gradle.kts
+++ b/jetwhale-host-sdk/build.gradle.kts
@@ -25,6 +25,15 @@ dependencies {
// Exposed in public API: JetWhalePluginStorage returns Flow and rememberPersistent uses coroutines.
api(libs.kotlinxCoroutinesCore)
api(projects.jetwhaleProtocol.core)
+
+ // Experimental web-based host plugins: JetWhaleWebView embeds a Chromium browser (KCEF) into the
+ // plugin UI via SwingPanel, so the SDK needs Compose Foundation/UI (desktop) and the KCEF runtime.
+ implementation(libs.jetbrainsComposeFoundation)
+ implementation(libs.jetbrainsComposeUi)
+ implementation(libs.kcef)
+ // KCEF drives its browser callbacks on the AWT/Swing thread via the Swing coroutine dispatcher.
+ implementation(libs.kotlinxCoroutinesSwing)
+
testImplementation(libs.kotlinTest)
}
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest.kt
index 02500a9a6..8b96c1c6e 100644
--- a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest.kt
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/JetWhaleHostPluginManifest.kt
@@ -22,8 +22,11 @@ public data class JetWhaleHostPluginManifest(
* Fully-qualified name of this plugin's [JetWhaleHostPluginFactory] implementation. The host loads
* this class from the plugin JAR and instantiates it (via its no-arg constructor) to obtain the
* plugin. Each entry pointing at its own factory is what lets one JAR provide multiple plugins.
+ *
+ * Exactly one of [factoryClass] or [web] must be set: a Kotlin-authored plugin names a factory;
+ * a pure web plugin declares [web] instead and needs no plugin code at all.
*/
- public val factoryClass: String,
+ public val factoryClass: String? = null,
/**
* Whether this plugin needs an agent counterpart. When `true` (default) the plugin is only
* available for a session whose agent advertised this `pluginId` during negotiation. When
@@ -34,6 +37,12 @@ public data class JetWhaleHostPluginManifest(
public val requiresAgent: Boolean = true,
public val agentVersionRange: AgentVersionRange? = null,
public val icon: Icon? = null,
+ /**
+ * Declares a **pure web plugin**: its UI is bundled web assets rendered by an embedded browser,
+ * with no plugin code to write or compile. Set this instead of [factoryClass]; the host provides
+ * the plugin implementation and wires the `window.jetwhale` bridge to the agent automatically.
+ */
+ public val web: WebUi? = null,
) {
/**
* Specifies the range of agent plugin versions this host plugin is compatible with.
@@ -51,4 +60,19 @@ public data class JetWhaleHostPluginManifest(
public val activePath: String? = null,
public val inactivePath: String? = null,
)
+
+ /**
+ * Declaration of a pure web plugin's UI: the bundled entry document and its resource root inside
+ * the plugin jar, plus an optional system-property name that, when set at runtime, overrides the
+ * source with a dev-server URL (for hot-module reload while iterating).
+ */
+ @Serializable
+ public data class WebUi(
+ /** Entry document to open, relative to [resourceRoot], e.g. `"index.html"`. */
+ public val entry: String,
+ /** Resource directory inside the jar holding the built web app, e.g. `"web"`. */
+ public val resourceRoot: String,
+ /** System property whose value, if present, is loaded as a dev-server URL instead of the bundle. */
+ public val devServerUrlProperty: String? = null,
+ )
}
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebBridge.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebBridge.kt
new file mode 100644
index 000000000..0b9ac9579
--- /dev/null
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebBridge.kt
@@ -0,0 +1,43 @@
+package com.kitakkun.jetwhale.host.sdk.web
+
+import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+
+/**
+ * Delivers agent-originated messages from the plugin (Kotlin) into the web UI (JavaScript).
+ *
+ * The web UI reaches the agent through the `window.jetwhale` bridge that [JetWhaleWebView] injects:
+ * - `window.jetwhale.send(type, payloadJson)` and `window.jetwhale.request(type, payloadJson)` go
+ * straight to the agent through the plugin's messenger — no Kotlin needed per message.
+ * - The other direction (agent → web UI) goes through this bridge: register a typed handler in
+ * `configure { ... }` and forward it with [emit]. [JetWhaleWebView] passes it to every
+ * `window.jetwhale.onMessage` listener as `(type, payloadJson)`.
+ *
+ * ```kotlin
+ * private val bridge = JetWhaleWebBridge()
+ *
+ * override fun JetWhaleMessageHandlers.configure() {
+ * onEvent { e: ButtonClicked -> bridge.emit("ButtonClicked", Json.encodeToString(e)) }
+ * }
+ * ```
+ */
+@ExperimentalJetWhaleApi
+public class JetWhaleWebBridge {
+ private val inboundFlow = MutableSharedFlow(extraBufferCapacity = 64)
+
+ internal val inbound: Flow = inboundFlow
+
+ /**
+ * Delivers a message to the web UI's `window.jetwhale.onMessage(type, payload)` listeners.
+ *
+ * [payload] must be a JSON string, or `""` when there is none. Callable from any thread. The
+ * message is dropped when no [JetWhaleWebView] is currently mounted to receive it — inbound
+ * messages are not buffered across mounts.
+ */
+ public fun emit(messageType: String, payload: String) {
+ inboundFlow.tryEmit(InboundMessage(messageType, payload))
+ }
+
+ internal data class InboundMessage(val messageType: String, val payload: String)
+}
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebHostPluginUi.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebHostPluginUi.kt
new file mode 100644
index 000000000..8180078e2
--- /dev/null
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebHostPluginUi.kt
@@ -0,0 +1,18 @@
+package com.kitakkun.jetwhale.host.sdk.web
+
+import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi
+import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPluginUi
+
+/**
+ * Marks a [JetWhaleHostPluginUi] whose `Content` embeds a [JetWhaleWebView].
+ *
+ * The host renders a web plugin's `Content` in a real windowed composition so the embedded browser's
+ * heavyweight component can attach, instead of the off-screen Compose scene used for pure-Compose
+ * plugins. Everything else about the plugin — lifecycle, messaging, storage — is unchanged; implement
+ * this in addition to providing `Content`.
+ *
+ * Trade-offs of the windowed path: the host cannot capture a screenshot of a web plugin, and Compose
+ * overlays the host draws may be occluded by the browser surface.
+ */
+@ExperimentalJetWhaleApi
+public interface JetWhaleWebHostPluginUi : JetWhaleHostPluginUi
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource.kt
new file mode 100644
index 000000000..c59bc1447
--- /dev/null
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebSource.kt
@@ -0,0 +1,36 @@
+package com.kitakkun.jetwhale.host.sdk.web
+
+import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi
+
+/**
+ * Where a [JetWhaleWebView] loads its web UI from.
+ *
+ * Two modes so one plugin can point at a live dev server while iterating and at bundled assets once
+ * shipped. Both resolve to a plain `http` origin, so `fetch()` and relative URLs behave the same in
+ * development and in production.
+ */
+@ExperimentalJetWhaleApi
+public sealed interface JetWhaleWebSource {
+ /**
+ * Load from a running dev server, e.g. Vite at `http://localhost:5173`. Hot-module reload keeps
+ * working because the browser talks to the dev server directly. The URL is loaded as-is.
+ */
+ public data class DevServer(public val url: String) : JetWhaleWebSource
+
+ /**
+ * Load static assets bundled inside the plugin jar. The assets are served over a loopback HTTP
+ * origin and the browser navigates to [entry].
+ *
+ * @param classLoader the plugin's own class loader — the one that can see the bundled assets,
+ * typically `javaClass.classLoader`. Plugin jars are loaded by isolated class loaders, so the
+ * loader that owns the assets must be passed explicitly.
+ * @param resourceRoot the resource directory inside the jar holding the built web app, e.g.
+ * `"web"` for files under `src/main/resources/web/`.
+ * @param entry the entry document to open, relative to [resourceRoot], e.g. `"index.html"`.
+ */
+ public data class BundledAsset(
+ public val classLoader: ClassLoader,
+ public val resourceRoot: String,
+ public val entry: String,
+ ) : JetWhaleWebSource
+}
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebView.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebView.kt
new file mode 100644
index 000000000..e10df1578
--- /dev/null
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/JetWhaleWebView.kt
@@ -0,0 +1,234 @@
+package com.kitakkun.jetwhale.host.sdk.web
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.awt.SwingPanel
+import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi
+import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessenger
+import dev.datlag.kcef.KCEF
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.json.Json
+import org.cef.browser.CefBrowser
+import org.cef.browser.CefFrame
+import org.cef.browser.CefMessageRouter
+import org.cef.browser.CefRendering
+import org.cef.callback.CefQueryCallback
+import org.cef.handler.CefLoadHandlerAdapter
+import org.cef.handler.CefMessageRouterHandlerAdapter
+
+/**
+ * Embeds a Chromium browser rendering a web UI (React, etc.) inside a host plugin's `Content`.
+ *
+ * The web UI reaches its agent counterpart through an injected `window.jetwhale` bridge:
+ * - `window.jetwhale.send(type, payloadJson)` — fire-and-forget to the agent.
+ * - `window.jetwhale.request(type, payloadJson)` — returns a `Promise` resolved with the agent's
+ * reply payload (rejected on failure/timeout).
+ * - `window.jetwhale.onMessage((type, payloadJson) => { ... })` — receives messages the plugin
+ * forwards from the agent via [JetWhaleWebBridge.emit].
+ * - A `jetwhale:ready` event fires on `window` once the bridge is installed.
+ *
+ * Declare the plugin with [JetWhaleWebHostPluginUi] so the host mounts this in a windowed composition.
+ * The Chromium runtime is downloaded and initialized lazily the first time any web plugin is shown.
+ *
+ * @param messenger the plugin's own `messenger` (from `JetWhaleMessagingHostPlugin`); wires the
+ * `send`/`request` bridge calls straight to the agent.
+ * @param bridge carries agent → web-UI messages; forward inbound handlers into it with
+ * [JetWhaleWebBridge.emit].
+ * @param source where to load the UI from — a dev server or bundled assets.
+ */
+@ExperimentalJetWhaleApi
+@Composable
+public fun JetWhaleWebView(
+ messenger: JetWhaleMessenger,
+ bridge: JetWhaleWebBridge,
+ source: JetWhaleWebSource,
+ modifier: Modifier = Modifier,
+) {
+ LaunchedEffect(Unit) { KcefController.ensureInitialized() }
+ val status by KcefController.status.collectAsState()
+
+ when (val current = status) {
+ is KcefInitStatus.Initializing -> WebViewMessage(
+ if (current.progress >= 0f) "Preparing browser… ${current.progress.toInt()}%" else "Preparing browser…",
+ modifier,
+ )
+ is KcefInitStatus.Failed -> WebViewMessage("Failed to start browser: ${current.message}", modifier)
+ KcefInitStatus.RestartRequired -> WebViewMessage("Restart the host to finish browser setup.", modifier)
+ KcefInitStatus.Ready -> ReadyWebView(messenger, bridge, source, modifier)
+ }
+}
+
+@Composable
+private fun ReadyWebView(
+ messenger: JetWhaleMessenger,
+ bridge: JetWhaleWebBridge,
+ source: JetWhaleWebSource,
+ modifier: Modifier,
+) {
+ val url = rememberSourceUrl(source)
+ if (url == null) {
+ WebViewMessage("Loading…", modifier)
+ return
+ }
+
+ val scope = rememberCoroutineScope()
+ val client = remember { KCEF.newClientOrNullBlocking() }
+ val browser = remember(client, url) {
+ client?.createBrowser(url, CefRendering.DEFAULT, false)
+ }
+
+ if (client == null || browser == null) {
+ WebViewMessage("Browser unavailable.", modifier)
+ return
+ }
+
+ DisposableEffect(browser) {
+ // window.cefQuery -> parse -> agent, via the plugin messenger.
+ val router = CefMessageRouter.create()
+ router.addHandler(BridgeQueryHandler(messenger, scope), true)
+ client.addMessageRouter(router)
+
+ // Install the window.jetwhale bridge as soon as each main-frame document finishes loading.
+ val loadHandler = object : CefLoadHandlerAdapter() {
+ override fun onLoadEnd(browser: CefBrowser?, frame: CefFrame?, httpStatusCode: Int) {
+ if (frame?.isMain == true) {
+ browser?.executeJavaScript(BRIDGE_SHIM_JS, browser.url ?: "", 0)
+ }
+ }
+ }
+ client.addLoadHandler(loadHandler)
+
+ onDispose {
+ client.removeMessageRouter(router)
+ router.dispose()
+ browser.close(true)
+ client.dispose()
+ }
+ }
+
+ // Agent -> web UI: forward every emitted message to window.jetwhale.onMessage listeners.
+ LaunchedEffect(browser) {
+ bridge.inbound.collect { message ->
+ val type = Json.encodeToString(String.serializer(), message.messageType)
+ val payload = Json.encodeToString(String.serializer(), message.payload)
+ browser.executeJavaScript(
+ "if(window.__jetwhaleReceive){window.__jetwhaleReceive($type,$payload);}",
+ browser.url ?: "",
+ 0,
+ )
+ }
+ }
+
+ SwingPanel(
+ factory = { browser.uiComponent },
+ modifier = modifier,
+ )
+}
+
+/** Resolves [source] to a loadable URL, mounting the asset server for bundled assets. */
+@Composable
+private fun rememberSourceUrl(source: JetWhaleWebSource): String? = when (source) {
+ is JetWhaleWebSource.DevServer -> source.url
+ is JetWhaleWebSource.BundledAsset -> {
+ var url by remember(source) { mutableStateOf(null) }
+ DisposableEffect(source) {
+ val handle = PluginAssetServer.mount(source.classLoader, source.resourceRoot)
+ url = handle.baseUrl + source.entry.trimStart('/')
+ onDispose { handle.unmount() }
+ }
+ url
+ }
+}
+
+@Composable
+private fun WebViewMessage(text: String, modifier: Modifier) {
+ Box(modifier.fillMaxSize()) {
+ BasicText(text = text, modifier = Modifier.wrapContentSize())
+ }
+}
+
+/** A single call from `window.jetwhale` on the JS side. */
+@Serializable
+private data class BridgeCall(val kind: String, val type: String, val payload: String)
+
+private class BridgeQueryHandler(
+ private val messenger: JetWhaleMessenger,
+ private val scope: kotlinx.coroutines.CoroutineScope,
+) : CefMessageRouterHandlerAdapter() {
+ override fun onQuery(
+ browser: CefBrowser?,
+ frame: CefFrame?,
+ queryId: Long,
+ request: String?,
+ persistent: Boolean,
+ callback: CefQueryCallback?,
+ ): Boolean {
+ val raw = request ?: return false
+ val call = runCatching { Json.decodeFromString(BridgeCall.serializer(), raw) }.getOrNull()
+ ?: return false
+ when (call.kind) {
+ "send" -> {
+ messenger.sendRaw(call.type, call.payload)
+ callback?.success("")
+ }
+ "request" -> scope.launch {
+ try {
+ val reply = messenger.requestRaw(call.type, call.payload, null)
+ callback?.success(reply)
+ } catch (throwable: Throwable) {
+ callback?.failure(-1, throwable.message ?: "request failed")
+ }
+ }
+ else -> return false
+ }
+ return true
+ }
+}
+
+/**
+ * Installs `window.jetwhale`. `send`/`request` go through CEF's `window.cefQuery`; `request` uses
+ * cefQuery's own success/failure callbacks so a reply resolves the returned promise directly.
+ */
+private val BRIDGE_SHIM_JS: String =
+ """
+ (function () {
+ if (window.jetwhale && window.jetwhale.__ready) return;
+ var listeners = [];
+ window.jetwhale = {
+ __ready: true,
+ send: function (type, payload) {
+ window.cefQuery({ request: JSON.stringify({ kind: 'send', type: type, payload: payload == null ? '' : payload }) });
+ },
+ request: function (type, payload) {
+ return new Promise(function (resolve, reject) {
+ window.cefQuery({
+ request: JSON.stringify({ kind: 'request', type: type, payload: payload == null ? '' : payload }),
+ onSuccess: function (response) { resolve(response); },
+ onFailure: function (code, message) { reject(new Error(message)); }
+ });
+ });
+ },
+ onMessage: function (cb) { listeners.push(cb); }
+ };
+ window.__jetwhaleReceive = function (type, payload) {
+ for (var i = 0; i < listeners.length; i++) {
+ try { listeners[i](type, payload); } catch (e) { console.error(e); }
+ }
+ };
+ window.dispatchEvent(new Event('jetwhale:ready'));
+ })();
+ """.trimIndent()
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/KcefController.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/KcefController.kt
new file mode 100644
index 000000000..2c05d9bd8
--- /dev/null
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/KcefController.kt
@@ -0,0 +1,70 @@
+package com.kitakkun.jetwhale.host.sdk.web
+
+import dev.datlag.kcef.KCEF
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.math.max
+
+/** Progress of the one-time KCEF (Chromium) runtime bootstrap shared by every [JetWhaleWebView]. */
+internal sealed interface KcefInitStatus {
+ /** Downloading and/or initializing the browser runtime; [progress] is 0..100, or -1 when unknown. */
+ data class Initializing(val progress: Float) : KcefInitStatus
+
+ /** The runtime is ready; browsers can be created. */
+ data object Ready : KcefInitStatus
+
+ /** Initialization failed; [message] describes why. */
+ data class Failed(val message: String) : KcefInitStatus
+
+ /** All packages fetched but the host must restart to load them (rare). */
+ data object RestartRequired : KcefInitStatus
+}
+
+/**
+ * Owns the process-wide KCEF bootstrap. KCEF is a singleton, so it is initialized at most once and
+ * lazily — the first time a web plugin is shown — keeping the heavy Chromium download off users who
+ * never open one. [KCEF.init] is itself idempotent and thread-safe; the guard here just avoids
+ * re-entering the download coroutine.
+ */
+internal object KcefController {
+ private val _status = MutableStateFlow(KcefInitStatus.Initializing(-1f))
+ val status: StateFlow = _status.asStateFlow()
+
+ private val started = AtomicBoolean(false)
+
+ /** Starts the bootstrap on first call; later calls return immediately. Observe [status] for progress. */
+ suspend fun ensureInitialized() {
+ if (!started.compareAndSet(false, true)) return
+ withContext(Dispatchers.IO) {
+ runCatching {
+ KCEF.init(
+ builder = {
+ installDir(File(jetwhaleHome(), "kcef-bundle"))
+ progress {
+ onDownloading { percent -> _status.value = KcefInitStatus.Initializing(max(percent, 0f)) }
+ onInitialized { _status.value = KcefInitStatus.Ready }
+ }
+ settings {
+ cachePath = File(jetwhaleHome(), "kcef-cache").absolutePath
+ }
+ },
+ onError = { throwable ->
+ _status.value = KcefInitStatus.Failed(throwable?.message ?: throwable?.toString() ?: "unknown error")
+ },
+ onRestartRequired = {
+ _status.value = KcefInitStatus.RestartRequired
+ },
+ )
+ }.onFailure { throwable ->
+ _status.value = KcefInitStatus.Failed(throwable.message ?: throwable.toString())
+ }
+ }
+ }
+
+ private fun jetwhaleHome(): File = File(System.getProperty("user.home"), ".jetwhale")
+}
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/PluginAssetServer.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/PluginAssetServer.kt
new file mode 100644
index 000000000..66ed299f2
--- /dev/null
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/PluginAssetServer.kt
@@ -0,0 +1,106 @@
+package com.kitakkun.jetwhale.host.sdk.web
+
+import com.sun.net.httpserver.HttpExchange
+import com.sun.net.httpserver.HttpHandler
+import com.sun.net.httpserver.HttpServer
+import java.net.InetAddress
+import java.net.InetSocketAddress
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * A single loopback HTTP server that serves plugins' bundled web assets from inside their jars.
+ *
+ * Bundled assets are served over a real `http://127.0.0.1` origin rather than `file://` so that
+ * `fetch()`, absolute paths and client-side routing behave exactly as they do against a dev server.
+ * The server binds to loopback only and is started lazily on the first [mount].
+ *
+ * Each mounted web view gets its own context path (`//`), backed by the plugin's own class
+ * loader, and [MountHandle.unmount] removes it when the view is disposed.
+ */
+internal object PluginAssetServer {
+ private val nextId = AtomicInteger(0)
+
+ private val server: HttpServer by lazy {
+ HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0).also {
+ // A daemon-ish cached executor keeps asset serving off the caller thread without holding
+ // the JVM alive on its own.
+ it.executor = null
+ it.start()
+ }
+ }
+
+ /**
+ * Publishes [resourceRoot] (as seen by [classLoader]) under a fresh context path and returns a
+ * handle whose [MountHandle.baseUrl] ends with `/`.
+ */
+ fun mount(classLoader: ClassLoader, resourceRoot: String): MountHandle {
+ val id = nextId.getAndIncrement()
+ val contextPath = "/$id/"
+ val normalizedRoot = resourceRoot.trim('/')
+ server.createContext(contextPath, AssetHandler(classLoader, normalizedRoot, contextPath))
+ val address = server.address
+ val baseUrl = "http://127.0.0.1:${address.port}$contextPath"
+ return MountHandle(baseUrl) { server.removeContext(contextPath) }
+ }
+
+ internal class MountHandle(val baseUrl: String, private val onUnmount: () -> Unit) {
+ fun unmount(): Unit = onUnmount()
+ }
+
+ private class AssetHandler(
+ private val classLoader: ClassLoader,
+ private val resourceRoot: String,
+ private val contextPath: String,
+ ) : HttpHandler {
+ override fun handle(exchange: HttpExchange) {
+ exchange.use {
+ val relative = exchange.requestURI.path.removePrefix(contextPath).trimStart('/')
+ // Reject path traversal outright rather than trying to normalize it.
+ if (relative.split('/').any { it == ".." }) {
+ exchange.sendResponseHeaders(403, -1)
+ return
+ }
+ val bytes = readAsset(relative)
+ if (bytes == null) {
+ exchange.sendResponseHeaders(404, -1)
+ return
+ }
+ exchange.responseHeaders.add("Content-Type", contentTypeFor(relative))
+ exchange.sendResponseHeaders(200, bytes.size.toLong())
+ exchange.responseBody.write(bytes)
+ }
+ }
+
+ private fun readAsset(relative: String): ByteArray? {
+ val requested = if (relative.isEmpty()) "index.html" else relative
+ resource("$resourceRoot/$requested")?.let { return it }
+ // Single-page apps serve deep-link paths (no file extension) from index.html.
+ if (!requested.substringAfterLast('/').contains('.')) {
+ return resource("$resourceRoot/index.html")
+ }
+ return null
+ }
+
+ private fun resource(path: String): ByteArray? =
+ classLoader.getResourceAsStream(path)?.use { it.readBytes() }
+ }
+}
+
+private fun contentTypeFor(path: String): String = when (path.substringAfterLast('.', "").lowercase()) {
+ "html", "htm" -> "text/html; charset=utf-8"
+ "js", "mjs" -> "text/javascript; charset=utf-8"
+ "css" -> "text/css; charset=utf-8"
+ "json" -> "application/json; charset=utf-8"
+ "svg" -> "image/svg+xml"
+ "png" -> "image/png"
+ "jpg", "jpeg" -> "image/jpeg"
+ "gif" -> "image/gif"
+ "webp" -> "image/webp"
+ "ico" -> "image/x-icon"
+ "woff" -> "font/woff"
+ "woff2" -> "font/woff2"
+ "ttf" -> "font/ttf"
+ "map" -> "application/json; charset=utf-8"
+ "wasm" -> "application/wasm"
+ else -> "application/octet-stream"
+}
diff --git a/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/WebManifestHostPlugin.kt b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/WebManifestHostPlugin.kt
new file mode 100644
index 000000000..34eda3e01
--- /dev/null
+++ b/jetwhale-host-sdk/src/main/kotlin/com/kitakkun/jetwhale/host/sdk/web/WebManifestHostPlugin.kt
@@ -0,0 +1,70 @@
+package com.kitakkun.jetwhale.host.sdk.web
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi
+import com.kitakkun.jetwhale.host.sdk.InternalJetWhaleHostApi
+import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPlugin
+import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPluginFactory
+import com.kitakkun.jetwhale.host.sdk.JetWhaleMessagingHostPlugin
+import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessageHandlers
+
+/**
+ * Configuration for a pure web plugin, derived from its manifest `web` block by the host loader.
+ *
+ * @param classLoader the plugin jar's class loader (owns the bundled assets).
+ * @param resourceRoot resource directory inside the jar holding the built web app.
+ * @param entry entry document relative to [resourceRoot].
+ * @param devServerUrl a dev-server URL to load instead of the bundle, or `null` to use the bundle.
+ */
+@InternalJetWhaleHostApi
+public class WebPluginConfig(
+ public val classLoader: ClassLoader,
+ public val resourceRoot: String,
+ public val entry: String,
+ public val devServerUrl: String?,
+)
+
+/**
+ * A [JetWhaleHostPluginFactory] the host synthesizes for a manifest that declares a `web` block, so a
+ * pure web plugin needs no author code. The returned plugin forwards everything between the bundled
+ * web UI and the agent generically:
+ * - JS `window.jetwhale.send`/`request` → the agent (handled inside [JetWhaleWebView]);
+ * - every inbound agent event → the web UI's `window.jetwhale.onMessage`, via a raw event handler.
+ */
+@InternalJetWhaleHostApi
+public fun webManifestPluginFactory(config: WebPluginConfig): JetWhaleHostPluginFactory =
+ object : JetWhaleHostPluginFactory {
+ override fun createPlugin(): JetWhaleHostPlugin = WebManifestHostPlugin(config)
+ }
+
+@OptIn(ExperimentalJetWhaleApi::class, InternalJetWhaleHostApi::class)
+private class WebManifestHostPlugin(
+ private val config: WebPluginConfig,
+) : JetWhaleMessagingHostPlugin(), JetWhaleWebHostPluginUi {
+
+ private val bridge = JetWhaleWebBridge()
+
+ override fun JetWhaleMessageHandlers.configure() {
+ // Generic forwarding: any inbound agent event is delivered to the web UI as (type, payload)
+ // without a Kotlin type per message.
+ onRawEvent { messageType, payload -> bridge.emit(messageType, payload) }
+ }
+
+ @Composable
+ override fun Content() {
+ val source = config.devServerUrl?.let { JetWhaleWebSource.DevServer(it) }
+ ?: JetWhaleWebSource.BundledAsset(
+ classLoader = config.classLoader,
+ resourceRoot = config.resourceRoot,
+ entry = config.entry,
+ )
+ JetWhaleWebView(
+ messenger = messenger,
+ bridge = bridge,
+ source = source,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+}
diff --git a/jetwhale-host/app/build.gradle.kts b/jetwhale-host/app/build.gradle.kts
index e623de779..83642a042 100644
--- a/jetwhale-host/app/build.gradle.kts
+++ b/jetwhale-host/app/build.gradle.kts
@@ -70,6 +70,14 @@ compose.desktop {
jvmArgs(
"-Dapple.awt.application.appearance=system",
)
+ // KCEF (the embedded Chromium used by experimental web plugins) reflects into internal
+ // AWT classes and needs these opened. See DatL4g/KCEF COMPOSE.md#flags.
+ jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
+ if (System.getProperty("os.name").contains("Mac")) {
+ jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
+ }
macOS {
iconFile.set(file("src/main/resources/icon.icns"))
@@ -84,6 +92,18 @@ compose.desktop {
}
}
+// Development runs (compose `run`, `runJetWhale`, `runJetWhaleHot`) launch via JavaExec, which does
+// not inherit the packaged-app jvmArgs above — open the same internal AWT packages KCEF needs so the
+// embedded browser works when running from source too.
+tasks.withType().configureEach {
+ jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED")
+ if (System.getProperty("os.name").contains("Mac")) {
+ jvmArgs("--add-opens", "java.desktop/sun.lwawt=ALL-UNNAMED")
+ jvmArgs("--add-opens", "java.desktop/sun.lwawt.macosx=ALL-UNNAMED")
+ }
+}
+
// Merging signed dependency jars (e.g. BouncyCastle) into an uber jar invalidates their
// signatures; leftover META-INF signature files then make the JVM reject the jar at launch
// with "Invalid signature file digest for Manifest main attributes".
diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/AppDataDirectoryProvider.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/AppDataDirectoryProvider.kt
index 67136bde3..dc6405c34 100644
--- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/AppDataDirectoryProvider.kt
+++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/AppDataDirectoryProvider.kt
@@ -78,14 +78,14 @@ class AppDataDirectoryProvider {
}
/**
- * True only for a `.jar` file directly inside the managed plugins directory. Paths are compared
- * canonically so `..` segments or symlinked aliases cannot smuggle in a jar from elsewhere. This
- * is the precondition for trusting a jar: the plugins directory is the security boundary, and
- * only files placed there through the explicit install flow may be approved.
+ * True only for a plugin archive (`.jar` or `.zip`) directly inside the managed plugins directory.
+ * Paths are compared canonically so `..` segments or symlinked aliases cannot smuggle in an archive
+ * from elsewhere. This is the precondition for trusting an archive: the plugins directory is the
+ * security boundary, and only files placed there through the explicit install flow may be approved.
*/
fun isManagedPluginJarPath(jarPath: String): Boolean {
val file = File(jarPath)
- if (file.extension != "jar") return false
+ if (!isPluginArchive(file)) return false
return try {
file.canonicalFile.parentFile == File(pluginDir).canonicalFile
} catch (e: java.io.IOException) {
@@ -119,7 +119,7 @@ class AppDataDirectoryProvider {
fun getAllPluginJarFilePaths(): List {
val pluginDirectory = File(pluginDir)
- return pluginDirectory.listFiles { file -> file.extension == "jar" }?.map { it.absolutePath } ?: emptyList()
+ return pluginDirectory.listFiles { file -> isPluginArchive(file) }?.map { it.absolutePath } ?: emptyList()
}
fun getPluginDirectory(): File = File(pluginDir)
@@ -146,7 +146,7 @@ class AppDataDirectoryProvider {
fun getDevPluginJarFilePaths(): List {
val devDir = getDevPluginsDir() ?: return emptyList()
val devDirectory = File(devDir)
- return devDirectory.listFiles { file -> file.extension == "jar" }?.map { it.absolutePath } ?: emptyList()
+ return devDirectory.listFiles { file -> isPluginArchive(file) }?.map { it.absolutePath } ?: emptyList()
}
companion object {
@@ -157,5 +157,15 @@ class AppDataDirectoryProvider {
* plugin-developer Gradle tasks to an isolated per-project sandbox directory.
*/
const val APP_DATA_DIR_PROPERTY = "jetwhale.appDataDir"
+
+ /**
+ * Extensions accepted as plugin archives. Both are ZIP containers read via `URLClassLoader`: a
+ * `.jar` for a Kotlin-authored (compiled) plugin, a `.zip` for a pure web plugin (manifest +
+ * bundled assets, no code to compile).
+ */
+ val PLUGIN_ARCHIVE_EXTENSIONS = setOf("jar", "zip")
+
+ /** True for a file whose extension marks it as a plugin archive (see [PLUGIN_ARCHIVE_EXTENSIONS]). */
+ fun isPluginArchive(file: File): Boolean = file.extension.lowercase() in PLUGIN_ARCHIVE_EXTENSIONS
}
}
diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginFactoryRepository.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginFactoryRepository.kt
index 745da84eb..8a99d0612 100644
--- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginFactoryRepository.kt
+++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginFactoryRepository.kt
@@ -4,8 +4,11 @@ import com.kitakkun.jetwhale.host.data.AppDataDirectoryProvider
import com.kitakkun.jetwhale.host.model.FailedPluginJar
import com.kitakkun.jetwhale.host.model.LoadedHostPlugin
import com.kitakkun.jetwhale.host.model.PluginFactoryRepository
+import com.kitakkun.jetwhale.host.sdk.InternalJetWhaleHostApi
import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPluginFactory
import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPluginManifestFile
+import com.kitakkun.jetwhale.host.sdk.web.WebPluginConfig
+import com.kitakkun.jetwhale.host.sdk.web.webManifestPluginFactory
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
@@ -194,25 +197,55 @@ class DefaultPluginFactoryRepository @Inject constructor(
}
return manifests.map { manifest ->
- val factory = try {
- // getConstructor (not getDeclaredConstructor): the contract is a *public* no-arg
- // constructor, so a non-public/missing one fails clearly with NoSuchMethodException.
- classLoader.loadClass(manifest.factoryClass).getConstructor().newInstance()
- } catch (e: ReflectiveOperationException) {
- throw IllegalStateException(
- "Could not load factory '${manifest.factoryClass}' for plugin '${manifest.pluginId}' " +
- "in $pluginJarPath: ${e.message}",
- e,
- )
- }
- require(factory is JetWhaleHostPluginFactory) {
- "Factory '${manifest.factoryClass}' for plugin '${manifest.pluginId}' in $pluginJarPath " +
- "is not a ${JetWhaleHostPluginFactory::class.java.simpleName}"
- }
+ val factory = manifest.web?.let { web -> webFactoryFor(web, classLoader) }
+ ?: reflectFactory(manifest.factoryClass, manifest.pluginId, pluginJarPath, classLoader)
LoadedHostPlugin(manifest = manifest, factory = factory)
}
}
+ /** Builds the host-provided factory for a pure web plugin declared via the manifest `web` block. */
+ @OptIn(InternalJetWhaleHostApi::class)
+ private fun webFactoryFor(
+ web: com.kitakkun.jetwhale.host.sdk.JetWhaleHostPluginManifest.WebUi,
+ classLoader: ClassLoader,
+ ): JetWhaleHostPluginFactory =
+ webManifestPluginFactory(
+ WebPluginConfig(
+ classLoader = classLoader,
+ resourceRoot = web.resourceRoot,
+ entry = web.entry,
+ // Resolved at load time: setting the dev-server property launches against a dev server.
+ devServerUrl = web.devServerUrlProperty?.let { System.getProperty(it) },
+ ),
+ )
+
+ /** Reflectively instantiates the [JetWhaleHostPluginFactory] named by a Kotlin-authored plugin. */
+ private fun reflectFactory(
+ factoryClass: String?,
+ pluginId: String,
+ pluginJarPath: String,
+ classLoader: ClassLoader,
+ ): JetWhaleHostPluginFactory {
+ requireNotNull(factoryClass) {
+ "Plugin '$pluginId' in $pluginJarPath declares neither 'factoryClass' nor 'web'."
+ }
+ val factory = try {
+ // getConstructor (not getDeclaredConstructor): the contract is a *public* no-arg
+ // constructor, so a non-public/missing one fails clearly with NoSuchMethodException.
+ classLoader.loadClass(factoryClass).getConstructor().newInstance()
+ } catch (e: ReflectiveOperationException) {
+ throw IllegalStateException(
+ "Could not load factory '$factoryClass' for plugin '$pluginId' in $pluginJarPath: ${e.message}",
+ e,
+ )
+ }
+ require(factory is JetWhaleHostPluginFactory) {
+ "Factory '$factoryClass' for plugin '$pluginId' in $pluginJarPath " +
+ "is not a ${JetWhaleHostPluginFactory::class.java.simpleName}"
+ }
+ return factory
+ }
+
/**
* Removes [pluginIds] from every jar other than [keepJarPath] that currently provides them; a jar
* left with no plugins has its classloader closed and dropped. Their `loadedPlugins` entries are
diff --git a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginHotReloadService.kt b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginHotReloadService.kt
index 38ec4b7ea..ac51b6f09 100644
--- a/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginHotReloadService.kt
+++ b/jetwhale-host/core/data/src/main/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginHotReloadService.kt
@@ -109,7 +109,7 @@ class DefaultPluginHotReloadService(
}
val changedJarNames = key.pollEvents()
- .mapNotNull { (it.context() as? Path)?.takeIf { path -> path.extension == "jar" }?.name }
+ .mapNotNull { (it.context() as? Path)?.takeIf { path -> path.isPluginArchive() }?.name }
.toSet()
// A single build can fire several events (write + close); coalesce and let the file
@@ -138,7 +138,7 @@ class DefaultPluginHotReloadService(
var pending: WatchKey? = service.poll()
while (pending != null) {
pending.pollEvents()
- .mapNotNullTo(names) { (it.context() as? Path)?.takeIf { path -> path.extension == "jar" }?.name }
+ .mapNotNullTo(names) { (it.context() as? Path)?.takeIf { path -> path.isPluginArchive() }?.name }
pending.reset()
pending = service.poll()
}
@@ -231,3 +231,7 @@ class DefaultPluginHotReloadService(
private const val DEBOUNCE_MILLIS = 300L
}
}
+
+/** True for a watched path whose extension marks it as a plugin archive (a `.jar` or a web `.zip`). */
+private fun Path.isPluginArchive(): Boolean =
+ extension.lowercase() in AppDataDirectoryProvider.PLUGIN_ARCHIVE_EXTENSIONS
diff --git a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginTrustServiceTest.kt b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginTrustServiceTest.kt
index 8ba2f0ad1..b5f479884 100644
--- a/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginTrustServiceTest.kt
+++ b/jetwhale-host/core/data/src/test/kotlin/com/kitakkun/jetwhale/host/data/plugin/DefaultPluginTrustServiceTest.kt
@@ -61,6 +61,16 @@ class DefaultPluginTrustServiceTest {
assertEquals(setOf(jar.absolutePath), trustRepository.entries.keys)
}
+ @Test
+ fun `trustAndLoad accepts a zip (web plugin) inside the plugins directory`() = runBlocking {
+ val zip = File(pluginsDir, "webplugin.zip").apply { writeBytes(byteArrayOf(1, 2, 3)) }
+
+ service.trustAndLoad(zip.absolutePath)
+
+ assertEquals(listOf(zip.absolutePath), factoryRepository.loadedJarPaths)
+ assertEquals(setOf(zip.absolutePath), trustRepository.entries.keys)
+ }
+
@Test
fun `trustAndLoad rejects a jar outside the plugins directory`() = runBlocking {
val outsideJar = File(tempHome, "evil.jar").apply { writeBytes(byteArrayOf(1)) }
@@ -80,10 +90,10 @@ class DefaultPluginTrustServiceTest {
}
@Test
- fun `trustAndLoad rejects a non-jar file`() = runBlocking {
- val notAJar = File(pluginsDir, "plugin.zip").apply { writeBytes(byteArrayOf(1)) }
+ fun `trustAndLoad rejects a non-archive file`() = runBlocking {
+ val notAnArchive = File(pluginsDir, "plugin.txt").apply { writeBytes(byteArrayOf(1)) }
- assertFailsWith { service.trustAndLoad(notAJar.absolutePath) }
+ assertFailsWith { service.trustAndLoad(notAnArchive.absolutePath) }
assertEquals(emptyList(), factoryRepository.loadedJarPaths)
}
diff --git a/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenContext.kt b/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenContext.kt
index 3374f4dc0..7c055e7b3 100644
--- a/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenContext.kt
+++ b/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenContext.kt
@@ -1,9 +1,12 @@
package com.kitakkun.jetwhale.host.plugin
import com.kitakkun.jetwhale.host.architecture.ScreenContext
+import com.kitakkun.jetwhale.host.model.DynamicPluginBridgeProvider
import com.kitakkun.jetwhale.host.model.PluginComposeSceneQueryKey
import com.kitakkun.jetwhale.host.model.PluginComposeSceneQueryKeyFactory
import com.kitakkun.jetwhale.host.model.PluginHotReloadService
+import com.kitakkun.jetwhale.host.model.PluginInstanceService
+import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPlugin
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
@@ -21,10 +24,22 @@ class PluginScreenContext(
@Assisted val sessionId: String,
pluginComposeSceneQueryKeyFactory: PluginComposeSceneQueryKeyFactory,
pluginHotReloadService: PluginHotReloadService,
+ private val pluginInstanceService: PluginInstanceService,
+ // Injects the same host environment (theme, language, Soil) into a web plugin's Content that the
+ // off-screen compose-scene path injects for pure-Compose plugins.
+ val pluginBridgeProvider: DynamicPluginBridgeProvider,
) : ScreenContext {
val pluginComposeSceneQueryKey: PluginComposeSceneQueryKey =
pluginComposeSceneQueryKeyFactory.create(pluginId, sessionId)
+ /**
+ * The live plugin instance for this screen, or `null` if it is not initialized yet. Used to pick
+ * the rendering path: a web plugin (`JetWhaleWebHostPluginUi`) renders windowed, everything else
+ * goes through the off-screen compose scene.
+ */
+ fun resolvePluginInstance(): JetWhaleHostPlugin? =
+ pluginInstanceService.getPluginInstanceForSession(pluginId, sessionId)
+
/**
* Emits whenever this screen's plugin is hot-reloaded, so the screen can re-create its compose
* scene from the freshly loaded code. Inert in production (no dev plugins directory configured).
diff --git a/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenRoot.kt b/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenRoot.kt
index adddc2d21..f7327c844 100644
--- a/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenRoot.kt
+++ b/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/PluginScreenRoot.kt
@@ -26,10 +26,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.kitakkun.jetwhale.host.architecture.SoilDataBoundary
import com.kitakkun.jetwhale.host.architecture.SoilFallbackDefaults
+import com.kitakkun.jetwhale.host.sdk.ExperimentalJetWhaleApi
+import com.kitakkun.jetwhale.host.sdk.web.JetWhaleWebHostPluginUi
import kotlinx.coroutines.delay
import soil.query.compose.rememberQuery
-@OptIn(InternalComposeUiApi::class)
+@OptIn(InternalComposeUiApi::class, ExperimentalJetWhaleApi::class)
@Composable
context(screenContext: PluginScreenContext)
fun PluginScreenRoot() {
@@ -48,21 +50,31 @@ fun PluginScreenRoot() {
Box(Modifier.fillMaxSize()) {
key(reset) {
- SoilDataBoundary(
- state = rememberQuery(screenContext.pluginComposeSceneQueryKey),
- fallback = SoilFallbackDefaults.custom(
- suspenseFallback = SoilFallbackDefaults.default().suspenseFallback,
- errorFallback = {
- PluginScreenErrorFallback(
- pluginId = screenContext.pluginId,
- errorBoundaryContext = it,
- // force recompose when reset is clicked
- onClickReset = { reset = !reset },
- )
- },
- ),
- ) { pluginComposeScene ->
- PluginScreen(pluginComposeScene = pluginComposeScene)
+ // Web plugins render in this real windowed composition (so the embedded browser's
+ // heavyweight component can attach) instead of the off-screen compose scene.
+ val pluginInstance = remember(reset) { screenContext.resolvePluginInstance() }
+ if (pluginInstance is JetWhaleWebHostPluginUi) {
+ WebPluginScreen(
+ instance = pluginInstance,
+ bridgeProvider = screenContext.pluginBridgeProvider,
+ )
+ } else {
+ SoilDataBoundary(
+ state = rememberQuery(screenContext.pluginComposeSceneQueryKey),
+ fallback = SoilFallbackDefaults.custom(
+ suspenseFallback = SoilFallbackDefaults.default().suspenseFallback,
+ errorFallback = {
+ PluginScreenErrorFallback(
+ pluginId = screenContext.pluginId,
+ errorBoundaryContext = it,
+ // force recompose when reset is clicked
+ onClickReset = { reset = !reset },
+ )
+ },
+ ),
+ ) { pluginComposeScene ->
+ PluginScreen(pluginComposeScene = pluginComposeScene)
+ }
}
}
diff --git a/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/WebPluginScreen.kt b/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/WebPluginScreen.kt
new file mode 100644
index 000000000..1c9a96529
--- /dev/null
+++ b/jetwhale-host/feature/plugin/src/main/kotlin/com/kitakkun/jetwhale/host/plugin/WebPluginScreen.kt
@@ -0,0 +1,38 @@
+package com.kitakkun.jetwhale.host.plugin
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import com.kitakkun.jetwhale.host.model.DynamicPluginBridgeProvider
+import com.kitakkun.jetwhale.host.sdk.InternalJetWhaleHostApi
+import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPlugin
+import com.kitakkun.jetwhale.host.sdk.JetWhaleHostPluginUi
+import com.kitakkun.jetwhale.host.sdk.LocalIsScreenshotCapture
+import com.kitakkun.jetwhale.host.sdk.LocalJetWhalePluginStorage
+
+/**
+ * Renders a web plugin's `Content` directly in the host's windowed composition.
+ *
+ * Pure-Compose plugins render into an off-screen [androidx.compose.ui.scene.ComposeScene] drawn onto
+ * a Canvas, but a web plugin embeds a heavyweight browser component (via `SwingPanel`) that can only
+ * attach to a real window — so its `Content` is composed here instead. It gets the same host
+ * environment the off-screen path provides (`DefaultPluginComposeSceneService`): the plugin's own
+ * storage plus the bridge-provider entry point (theme, language, Soil).
+ *
+ * [LocalIsScreenshotCapture] is always `false`: a windowed browser cannot be rendered off-screen for
+ * a screenshot, so this path never participates in host screenshot capture.
+ */
+@OptIn(InternalJetWhaleHostApi::class)
+@Composable
+internal fun WebPluginScreen(
+ instance: JetWhaleHostPlugin,
+ bridgeProvider: DynamicPluginBridgeProvider,
+) {
+ CompositionLocalProvider(
+ LocalJetWhalePluginStorage provides instance.boundStorageForRuntime(),
+ LocalIsScreenshotCapture provides false,
+ ) {
+ bridgeProvider.PluginEntryPoint {
+ (instance as JetWhaleHostPluginUi).Content()
+ }
+ }
+}
diff --git a/jetwhale-host/feature/settings/src/main/kotlin/com/kitakkun/jetwhale/host/settings/plugin/PluginSettingsScreenRoot.kt b/jetwhale-host/feature/settings/src/main/kotlin/com/kitakkun/jetwhale/host/settings/plugin/PluginSettingsScreenRoot.kt
index d4cca7c7e..bb8b56003 100644
--- a/jetwhale-host/feature/settings/src/main/kotlin/com/kitakkun/jetwhale/host/settings/plugin/PluginSettingsScreenRoot.kt
+++ b/jetwhale-host/feature/settings/src/main/kotlin/com/kitakkun/jetwhale/host/settings/plugin/PluginSettingsScreenRoot.kt
@@ -72,7 +72,7 @@ fun PluginSettingsScreenRoot() {
}
private fun selectJarFile(parent: Frame? = null): File? {
- val dialog = FileDialog(parent, "Select Plugin Jar", FileDialog.LOAD).apply {
+ val dialog = FileDialog(parent, "Select Plugin (JAR or ZIP)", FileDialog.LOAD).apply {
isVisible = true
}
diff --git a/jetwhale-plugins/example/agent/src/commonMain/kotlin/com/kitakkun/jetwhale/plugins/example/agent/ExampleWebAgentPlugin.kt b/jetwhale-plugins/example/agent/src/commonMain/kotlin/com/kitakkun/jetwhale/plugins/example/agent/ExampleWebAgentPlugin.kt
new file mode 100644
index 000000000..a05f519de
--- /dev/null
+++ b/jetwhale-plugins/example/agent/src/commonMain/kotlin/com/kitakkun/jetwhale/plugins/example/agent/ExampleWebAgentPlugin.kt
@@ -0,0 +1,39 @@
+package com.kitakkun.jetwhale.plugins.example.agent
+
+import com.kitakkun.jetwhale.agent.sdk.JetWhaleAgentPlugin
+import com.kitakkun.jetwhale.plugins.example.protocol.ButtonClicked
+import com.kitakkun.jetwhale.plugins.example.protocol.Ping
+import com.kitakkun.jetwhale.plugins.example.protocol.Pong
+import com.kitakkun.jetwhale.protocol.messaging.JetWhaleMessageHandlers
+import com.kitakkun.jetwhale.protocol.messaging.reply
+import com.kitakkun.jetwhale.protocol.messaging.trySend
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * Agent counterpart of the experimental web-based host plugin (same `pluginId`). Behaves exactly
+ * like [ExampleAgentPlugin] — replies to [Ping] with [Pong] and can push [ButtonClicked] events —
+ * but under `com.kitakkun.jetwhale.example.web` so the web UI has an agent to talk to.
+ */
+class ExampleWebAgentPlugin : JetWhaleAgentPlugin() {
+ override val pluginId: String get() = "com.kitakkun.jetwhale.example.web"
+ override val pluginVersion: String get() = "1.0.0"
+
+ private val mutableEventLogsFlow: MutableStateFlow> = MutableStateFlow(emptyList())
+ val eventLogsFlow: StateFlow> = mutableEventLogsFlow
+
+ override fun JetWhaleMessageHandlers.configure() {
+ onRequest { _: Ping ->
+ mutableEventLogsFlow.update { it + "Request: Ping" + "Reply: Pong" }
+ reply(Pong)
+ }
+ }
+
+ /** Sends a button-clicked event to the host (dropped if the host is not connected). */
+ fun reportButtonClicked(count: Int) {
+ val event = ButtonClicked(count)
+ mutableEventLogsFlow.update { it + "Event: $event" }
+ messenger.trySend(event)
+ }
+}
diff --git a/jetwhale-plugins/example/host/src/main/resources/META-INF/jetwhale/plugin-manifest.json b/jetwhale-plugins/example/host/src/main/resources/META-INF/jetwhale/plugin-manifest.json
index 1cd132269..c35d8ea50 100644
--- a/jetwhale-plugins/example/host/src/main/resources/META-INF/jetwhale/plugin-manifest.json
+++ b/jetwhale-plugins/example/host/src/main/resources/META-INF/jetwhale/plugin-manifest.json
@@ -25,6 +25,24 @@
"activePath": "icons/window_filled.svg",
"inactivePath": "icons/window_outlined.svg"
}
+ },
+ {
+ "pluginId": "com.kitakkun.jetwhale.example.web",
+ "pluginName": "Example Web Plugin (Experimental)",
+ "version": "1.0.0",
+ "web": {
+ "entry": "index.html",
+ "resourceRoot": "web/example",
+ "devServerUrlProperty": "jetwhale.example.web.devServer"
+ },
+ "agentVersionRange": {
+ "min": "1.0.0",
+ "max": "1.0.0"
+ },
+ "icon": {
+ "activePath": "icons/window_filled.svg",
+ "inactivePath": "icons/window_outlined.svg"
+ }
}
]
}
diff --git a/jetwhale-plugins/example/host/src/main/resources/web/example/index.html b/jetwhale-plugins/example/host/src/main/resources/web/example/index.html
new file mode 100644
index 000000000..7533e4d7c
--- /dev/null
+++ b/jetwhale-plugins/example/host/src/main/resources/web/example/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+ Example Web Host Plugin
+
+
+
+
Example Web Host Plugin
+
+
Waiting for the JetWhale bridge…
+
+
+
+
+
diff --git a/jetwhale-plugins/example/host/src/main/resources/web/example/jetwhale.d.ts b/jetwhale-plugins/example/host/src/main/resources/web/example/jetwhale.d.ts
new file mode 100644
index 000000000..23fe74d1f
--- /dev/null
+++ b/jetwhale-plugins/example/host/src/main/resources/web/example/jetwhale.d.ts
@@ -0,0 +1,28 @@
+/**
+ * Type declarations for the `window.jetwhale` bridge injected by JetWhale into a web-based host
+ * plugin. Copy this file into your web project (and reference it from tsconfig) to get typed access.
+ *
+ * Payloads are JSON strings on the wire; encode/decode them yourself (e.g. JSON.stringify /
+ * JSON.parse) so message shapes stay in sync with your agent's protocol.
+ */
+interface JetWhaleBridge {
+ /** True once the bridge is installed. A `jetwhale:ready` event also fires on `window`. */
+ readonly __ready: boolean;
+
+ /** Fire-and-forget event to the agent counterpart. `type` is the message's wire name. */
+ send(type: string, payload: string): void;
+
+ /** Request-reply with the agent; resolves with the reply payload, rejects on failure/timeout. */
+ request(type: string, payload: string): Promise;
+
+ /** Registers a listener for messages the plugin forwards from the agent. */
+ onMessage(listener: (type: string, payload: string) => void): void;
+}
+
+interface Window {
+ jetwhale: JetWhaleBridge;
+}
+
+interface WindowEventMap {
+ "jetwhale:ready": Event;
+}
diff --git a/jetwhale-protocol/core/api/core.klib.api b/jetwhale-protocol/core/api/core.klib.api
index afe673e99..191021498 100644
--- a/jetwhale-protocol/core/api/core.klib.api
+++ b/jetwhale-protocol/core/api/core.klib.api
@@ -593,6 +593,8 @@ final class com.kitakkun.jetwhale.protocol.messaging/JetWhaleConnectionClosedExc
final class com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers { // com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers|null[0]
final fun <#A1: com.kitakkun.jetwhale.protocol.messaging/JetWhaleEvent> registerEvent(kotlinx.serialization/KSerializer<#A1>, kotlin.coroutines/SuspendFunction1<#A1, kotlin/Unit>) // com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers.registerEvent|registerEvent(kotlinx.serialization.KSerializer<0:0>;kotlin.coroutines.SuspendFunction1<0:0,kotlin.Unit>){0§}[0]
final fun <#A1: com.kitakkun.jetwhale.protocol.messaging/JetWhaleRequest<#B1>, #B1: kotlin/Any> registerRequest(kotlinx.serialization/KSerializer<#A1>, kotlinx.serialization/KSerializer<#B1>, kotlin.coroutines/SuspendFunction1<#A1, com.kitakkun.jetwhale.protocol.messaging/Reply<#B1>>) // com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers.registerRequest|registerRequest(kotlinx.serialization.KSerializer<0:0>;kotlinx.serialization.KSerializer<0:1>;kotlin.coroutines.SuspendFunction1<0:0,com.kitakkun.jetwhale.protocol.messaging.Reply<0:1>>){0§>;1§}[0]
+ final fun onRawEvent(kotlin.coroutines/SuspendFunction2) // com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers.onRawEvent|onRawEvent(kotlin.coroutines.SuspendFunction2){}[0]
+ final fun onRawRequest(kotlin.coroutines/SuspendFunction2) // com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers.onRawRequest|onRawRequest(kotlin.coroutines.SuspendFunction2){}[0]
final inline fun <#A1: reified com.kitakkun.jetwhale.protocol.messaging/JetWhaleEvent> onEvent(noinline kotlin.coroutines/SuspendFunction1<#A1, kotlin/Unit>) // com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers.onEvent|onEvent(kotlin.coroutines.SuspendFunction1<0:0,kotlin.Unit>){0§}[0]
final inline fun <#A1: reified com.kitakkun.jetwhale.protocol.messaging/JetWhaleRequest<#B1>, #B1: reified kotlin/Any> onRequest(noinline kotlin.coroutines/SuspendFunction1<#A1, com.kitakkun.jetwhale.protocol.messaging/Reply<#B1>>) // com.kitakkun.jetwhale.protocol.messaging/JetWhaleMessageHandlers.onRequest|onRequest(kotlin.coroutines.SuspendFunction1<0:0,com.kitakkun.jetwhale.protocol.messaging.Reply<0:1>>){0§>;1§}[0]
}
diff --git a/jetwhale-protocol/core/api/jvm/core.api b/jetwhale-protocol/core/api/jvm/core.api
index aef02cd78..22f30466d 100644
--- a/jetwhale-protocol/core/api/jvm/core.api
+++ b/jetwhale-protocol/core/api/jvm/core.api
@@ -132,6 +132,8 @@ public abstract interface class com/kitakkun/jetwhale/protocol/messaging/JetWhal
}
public final class com/kitakkun/jetwhale/protocol/messaging/JetWhaleMessageHandlers {
+ public final fun onRawEvent (Lkotlin/jvm/functions/Function3;)V
+ public final fun onRawRequest (Lkotlin/jvm/functions/Function3;)V
public final fun registerEvent (Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function2;)V
public final fun registerRequest (Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function2;)V
}
diff --git a/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/InboundFrameDispatcher.kt b/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/InboundFrameDispatcher.kt
index dee329e30..75ac7e325 100644
--- a/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/InboundFrameDispatcher.kt
+++ b/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/InboundFrameDispatcher.kt
@@ -137,13 +137,34 @@ internal class InboundFrameDispatcher(
private suspend fun handleRequest(frame: PluginFrame.Request) {
val entry = handlers.requestEntryFor(frame.messageType)
- val reply: PluginFrame.Reply = if (entry == null) {
+ val rawRequestHandler = if (entry == null) handlers.rawRequestHandler() else null
+ val reply: PluginFrame.Reply = if (entry == null && rawRequestHandler == null) {
PluginFrame.Reply.Failure(
pluginId = pluginId,
inReplyTo = frame.correlationId,
errorMessage = "No request handler registered for '${frame.messageType}'",
)
+ } else if (rawRequestHandler != null) {
+ // No typed handler, but a raw fallback is registered: hand it the undecoded payload and
+ // use whatever raw string it returns as the reply.
+ try {
+ val payload = rawRequestHandler(frame.messageType, frame.payload)
+ PluginFrame.Reply.Success(
+ pluginId = pluginId,
+ inReplyTo = frame.correlationId,
+ payload = payload,
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ PluginFrame.Reply.Failure(
+ pluginId = pluginId,
+ inReplyTo = frame.correlationId,
+ errorMessage = e.message ?: (e::class.simpleName ?: "Unknown error"),
+ )
+ }
} else {
+ checkNotNull(entry)
try {
val request = payloadFormat.decodeFromString(entry.requestSerializer.castToAny(), frame.payload)
val result = entry.handler(request)
@@ -171,6 +192,18 @@ internal class InboundFrameDispatcher(
private suspend fun dispatchNotification(frame: PluginFrame.Notification) {
val entry = handlers.eventEntryFor(frame.messageType)
if (entry == null) {
+ val rawEventHandler = handlers.rawEventHandler()
+ if (rawEventHandler != null) {
+ // No typed handler, but a raw fallback is registered: hand it the undecoded payload.
+ try {
+ rawEventHandler(frame.messageType, frame.payload)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Throwable) {
+ logger("JetWhale: raw event handler for '${frame.messageType}' (plugin '$pluginId') failed: ${e.message}")
+ }
+ return
+ }
// Forward-compatibility: an unknown event (e.g. version skew) is skipped, not fatal.
logger("JetWhale: no event handler registered for '${frame.messageType}' (plugin '$pluginId'); skipping.")
return
diff --git a/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/JetWhaleMessageHandlers.kt b/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/JetWhaleMessageHandlers.kt
index c29ca2e34..25d5a1248 100644
--- a/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/JetWhaleMessageHandlers.kt
+++ b/jetwhale-protocol/core/src/commonMain/kotlin/com/kitakkun/jetwhale/protocol/messaging/JetWhaleMessageHandlers.kt
@@ -31,11 +31,34 @@ public class JetWhaleMessageHandlers internal constructor() {
private val eventEntries = mutableMapOf()
private val requestEntries = mutableMapOf()
+ private var rawEventEntry: (suspend (messageType: String, payload: String) -> Unit)? = null
+ private var rawRequestEntry: (suspend (messageType: String, payload: String) -> String)? = null
+
/** Registers a handler for the event type [E]. One handler per type. */
public inline fun onEvent(noinline handler: suspend (E) -> Unit) {
registerEvent(serializer(), handler)
}
+ /**
+ * Registers a **raw** fallback for inbound events with no typed [onEvent] handler: it receives the
+ * wire message type and the undecoded payload string. Use it to forward messages generically
+ * without a Kotlin type per message (e.g. bridging to a web UI). One per plugin.
+ */
+ public fun onRawEvent(handler: suspend (messageType: String, payload: String) -> Unit) {
+ check(rawEventEntry == null) { "A raw event handler is already registered." }
+ rawEventEntry = handler
+ }
+
+ /**
+ * Registers a **raw** fallback for inbound requests with no typed [onRequest] handler: it receives
+ * the wire message type and the undecoded payload string and must return the raw reply payload
+ * string. One per plugin.
+ */
+ public fun onRawRequest(handler: suspend (messageType: String, payload: String) -> String) {
+ check(rawRequestEntry == null) { "A raw request handler is already registered." }
+ rawRequestEntry = handler
+ }
+
/**
* Registers a handler for the request type [REQ]. It must return — via [reply] — the reply type
* declared by `REQ : JetWhaleRequest`. The reply is sent when the handler returns, so
@@ -73,4 +96,8 @@ public class JetWhaleMessageHandlers internal constructor() {
internal fun eventEntryFor(messageType: String): EventEntry? = eventEntries[messageType]
internal fun requestEntryFor(messageType: String): RequestEntry? = requestEntries[messageType]
+
+ internal fun rawEventHandler(): (suspend (messageType: String, payload: String) -> Unit)? = rawEventEntry
+
+ internal fun rawRequestHandler(): (suspend (messageType: String, payload: String) -> String)? = rawRequestEntry
}
diff --git a/schemas/plugin-manifest.schema.json b/schemas/plugin-manifest.schema.json
index d3e1c3874..13a80b7df 100644
--- a/schemas/plugin-manifest.schema.json
+++ b/schemas/plugin-manifest.schema.json
@@ -13,7 +13,11 @@
"minItems": 1,
"items": {
"type": "object",
- "required": ["pluginId", "pluginName", "version", "factoryClass"],
+ "required": ["pluginId", "pluginName", "version"],
+ "oneOf": [
+ { "required": ["factoryClass"] },
+ { "required": ["web"] }
+ ],
"additionalProperties": false,
"properties": {
"pluginId": {
@@ -30,7 +34,27 @@
},
"factoryClass": {
"type": "string",
- "description": "Fully-qualified name of this plugin's JetWhaleHostPluginFactory implementation (e.g. com.example.MyPluginFactory). The host loads and instantiates it from the JAR."
+ "description": "Fully-qualified name of this plugin's JetWhaleHostPluginFactory implementation (e.g. com.example.MyPluginFactory). The host loads and instantiates it from the JAR. Set this OR 'web', not both."
+ },
+ "web": {
+ "type": "object",
+ "description": "Declares a pure web plugin: bundled web assets rendered by an embedded browser, with no plugin code to write or compile. Set this OR 'factoryClass', not both.",
+ "required": ["entry", "resourceRoot"],
+ "additionalProperties": false,
+ "properties": {
+ "entry": {
+ "type": "string",
+ "description": "Entry document to open, relative to resourceRoot (e.g. index.html)."
+ },
+ "resourceRoot": {
+ "type": "string",
+ "description": "Resource directory inside the JAR holding the built web app (e.g. web)."
+ },
+ "devServerUrlProperty": {
+ "type": ["string", "null"],
+ "description": "System property whose value, if set at runtime, is loaded as a dev-server URL instead of the bundle (for hot-module reload)."
+ }
+ }
},
"requiresAgent": {
"type": "boolean",
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 14d0d35b4..7c3e0dd9c 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,6 +16,15 @@ dependencyResolutionManagement {
repositories {
mavenCentral()
google()
+ // KCEF (the embedded browser for experimental web plugins) pulls JOGL/GlueGen native
+ // bindings, which are published only on the JogAmp repository, not Maven Central. Scoped by
+ // content filter so no other dependency resolution is routed here.
+ maven("https://jogamp.org/deployment/maven") {
+ content {
+ includeGroup("org.jogamp.gluegen")
+ includeGroup("org.jogamp.jogl")
+ }
+ }
}
}