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") + } + } } }