From f01fc8f7ebcfa8285e6d988649c05a7542f591c4 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sat, 26 Sep 2026 03:53:34 -0700 Subject: [PATCH 1/3] feat(browser): add optional native title-bar bridge --- api/boss-plugin-api.api | 48 ++++++++++++ build.gradle.kts | 1 + .../plugin/browser/BrowserTitleBarBridge.kt | 77 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt diff --git a/api/boss-plugin-api.api b/api/boss-plugin-api.api index 77475f8..bd3b471 100644 --- a/api/boss-plugin-api.api +++ b/api/boss-plugin-api.api @@ -7178,6 +7178,23 @@ public final class ai/rever/boss/plugin/bookmark/WorkspacePanelTarget$Companion public final fun serializer ()Lkotlinx/serialization/KSerializer; } +public final class ai/rever/boss/plugin/browser/BrowserAddressBarState { + public static final field $stable I + public fun (Ljava/lang/String;IILjava/lang/String;ZZILkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;)V + public final fun getCompletion ()Ljava/lang/String; + public final fun getHasSelectedSuggestion ()Z + public final fun getOnCommand ()Lkotlin/jvm/functions/Function1; + public final fun getOnDismiss ()Lkotlin/jvm/functions/Function0; + public final fun getOnEdit ()Lkotlin/jvm/functions/Function3; + public final fun getOnFocusLost ()Lkotlin/jvm/functions/Function0; + public final fun getRevision ()I + public final fun getSelectionEnd ()I + public final fun getSelectionStart ()I + public final fun getShowSuggestions ()Z + public final fun getSuggestions ()Lkotlin/jvm/functions/Function2; + public final fun getText ()Ljava/lang/String; +} + public final class ai/rever/boss/plugin/browser/BrowserAuthSpec { public static final field $stable I public fun ()V @@ -7430,6 +7447,37 @@ public final class ai/rever/boss/plugin/browser/BrowserService$DefaultImpls { public static fun stashPopupPost (Lai/rever/boss/plugin/browser/BrowserService;Ljava/lang/String;[BLjava/lang/String;)V } +public final class ai/rever/boss/plugin/browser/BrowserTitleBarBridge { + public static final field $stable I + public static final field INSTANCE Lai/rever/boss/plugin/browser/BrowserTitleBarBridge; + public final fun focus (Ljava/lang/String;)Z + public final fun host (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public final fun hostWindow (Ljava/lang/String;Z)V + public final fun isHosted (Ljava/lang/String;)Z + public final fun isWindowHosted (Ljava/lang/String;)Z + public final fun publish (Ljava/lang/String;Ljava/lang/Object;Lai/rever/boss/plugin/browser/BrowserTitleBarState;)V + public final fun remove (Ljava/lang/String;Ljava/lang/Object;)V + public final fun state (Ljava/lang/String;)Lai/rever/boss/plugin/browser/BrowserTitleBarState; +} + +public final class ai/rever/boss/plugin/browser/BrowserTitleBarState { + public static final field $stable I + public fun (Ljava/lang/String;ZZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lai/rever/boss/plugin/browser/BrowserAddressBarState;)V + public synthetic fun (Ljava/lang/String;ZZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;Lai/rever/boss/plugin/browser/BrowserAddressBarState;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAddress ()Lai/rever/boss/plugin/browser/BrowserAddressBarState; + public final fun getBack ()Lkotlin/jvm/functions/Function0; + public final fun getBookmark ()Lkotlin/jvm/functions/Function0; + public final fun getBookmarked ()Z + public final fun getCanGoBack ()Z + public final fun getCanGoForward ()Z + public final fun getForward ()Lkotlin/jvm/functions/Function0; + public final fun getLoading ()Z + public final fun getNavigate ()Lkotlin/jvm/functions/Function1; + public final fun getReloadOrStop ()Lkotlin/jvm/functions/Function0; + public final fun getShare ()Lkotlin/jvm/functions/Function0; + public final fun getUrl ()Ljava/lang/String; +} + public final class ai/rever/boss/plugin/browser/FormFieldInfo { public static final field $stable I public fun (Lai/rever/boss/plugin/browser/FormFieldType;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V diff --git a/build.gradle.kts b/build.gradle.kts index 123b5d7..5fcbfe7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -594,6 +594,7 @@ group = "ai.rever.boss.plugin.bundled" // CLI selection, rather than asking a consumer to duplicate routing policy. New types only: // ApiClassLoader/minApiVersion, no host release. // +// Next release (1.0.94): optional native browser title-bar navigation bridge. version = "1.0.93" java { diff --git a/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt b/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt new file mode 100644 index 0000000..0ce7872 --- /dev/null +++ b/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt @@ -0,0 +1,77 @@ +package ai.rever.boss.plugin.browser + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateMapOf + +/** Browser-owned navigation state presented by an optional native host toolbar. */ +class BrowserTitleBarState( + val url: String, + val canGoBack: Boolean, + val canGoForward: Boolean, + val loading: Boolean, + val bookmarked: Boolean, + val navigate: (String) -> Unit, + val back: () -> Unit, + val forward: () -> Unit, + val reloadOrStop: () -> Unit, + val bookmark: () -> Unit, + val share: (() -> Unit)? = null, + val address: BrowserAddressBarState? = null, +) + +/** UI-thread-only handshake. Plugins retain their toolbar until the host claims their handle. */ +object BrowserTitleBarBridge { + private class Entry(val owner: Any, val state: BrowserTitleBarState) + + private val entries = mutableStateMapOf() + private val windows = mutableStateMapOf() + + fun hostWindow(windowId: String, enabled: Boolean) { + if (enabled) windows[windowId] = true else windows.remove(windowId) + } + + fun isWindowHosted(windowId: String): Boolean = windows[windowId] == true + + private val hosts = mutableStateMapOf Unit>() + + fun state(handleId: String): BrowserTitleBarState? = entries[handleId]?.state + + fun publish(handleId: String, owner: Any, state: BrowserTitleBarState) { + entries[handleId] = Entry(owner, state) + } + + fun remove(handleId: String, owner: Any) { + if (entries[handleId]?.owner === owner) { + entries.remove(handleId) + hosts.remove(handleId) + } + } + + fun host(handleId: String, focus: (() -> Unit)?) { + if (focus == null) hosts.remove(handleId) else hosts[handleId] = focus + } + + fun isHosted(handleId: String): Boolean = hosts.containsKey(handleId) + + fun focus(handleId: String): Boolean { + val focus = hosts[handleId] ?: return false + focus() + return true + } +} + +/** Editing and suggestions remain owned by the browser plugin, including keyboard semantics. */ +class BrowserAddressBarState( + val text: String, + val selectionStart: Int, + val selectionEnd: Int, + val completion: String?, + val showSuggestions: Boolean, + val hasSelectedSuggestion: Boolean, + val revision: Int, + val onEdit: (String, Int, Int) -> Unit, + val onCommand: (String) -> Unit, + val onFocusLost: () -> Unit, + val onDismiss: () -> Unit, + val suggestions: @Composable () -> Unit, +) From 17f4623b95580749fc61ca56ce917c3258c7d78e Mon Sep 17 00:00:00 2001 From: Shivang Date: Sat, 26 Sep 2026 03:58:07 -0700 Subject: [PATCH 2/3] fix(browser): retain focus ownership when tabs move between windows --- api/boss-plugin-api.api | 1 + .../boss/plugin/browser/BrowserTitleBarBridge.kt | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/api/boss-plugin-api.api b/api/boss-plugin-api.api index bd3b471..66fe959 100644 --- a/api/boss-plugin-api.api +++ b/api/boss-plugin-api.api @@ -7451,6 +7451,7 @@ public final class ai/rever/boss/plugin/browser/BrowserTitleBarBridge { public static final field $stable I public static final field INSTANCE Lai/rever/boss/plugin/browser/BrowserTitleBarBridge; public final fun focus (Ljava/lang/String;)Z + public final fun host (Ljava/lang/String;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;)V public final fun host (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public final fun hostWindow (Ljava/lang/String;Z)V public final fun isHosted (Ljava/lang/String;)Z diff --git a/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt b/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt index 0ce7872..ca6be1b 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt @@ -32,7 +32,9 @@ object BrowserTitleBarBridge { fun isWindowHosted(windowId: String): Boolean = windows[windowId] == true - private val hosts = mutableStateMapOf Unit>() + private class Host(val owner: Any, val focus: () -> Unit) + private val hosts = mutableStateMapOf() + private val legacyHostOwner = Any() fun state(handleId: String): BrowserTitleBarState? = entries[handleId]?.state @@ -47,14 +49,18 @@ object BrowserTitleBarBridge { } } - fun host(handleId: String, focus: (() -> Unit)?) { - if (focus == null) hosts.remove(handleId) else hosts[handleId] = focus + fun host(handleId: String, focus: (() -> Unit)?) = host(handleId, legacyHostOwner, focus) + + /** A disposed window cannot unregister the focus handler of a newer host. */ + fun host(handleId: String, owner: Any, focus: (() -> Unit)?) { + if (focus != null) hosts[handleId] = Host(owner, focus) + else if (hosts[handleId]?.owner === owner) hosts.remove(handleId) } fun isHosted(handleId: String): Boolean = hosts.containsKey(handleId) fun focus(handleId: String): Boolean { - val focus = hosts[handleId] ?: return false + val focus = hosts[handleId]?.focus ?: return false focus() return true } From cb269ff4178e8d516c69b3c0f587cb9ce9cb2537 Mon Sep 17 00:00:00 2001 From: Shivang Date: Sat, 26 Sep 2026 04:05:36 -0700 Subject: [PATCH 3/3] fix(browser): keep publication and host focus lifetimes independent --- .../plugin/browser/BrowserTitleBarBridge.kt | 29 ++++++- .../plugin/api/BrowserTitleBarBridgeTest.kt | 82 +++++++++++++++++++ 2 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 src/test/kotlin/ai/rever/boss/plugin/api/BrowserTitleBarBridgeTest.kt diff --git a/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt b/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt index ca6be1b..d319fd3 100644 --- a/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt +++ b/src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt @@ -1,9 +1,13 @@ package ai.rever.boss.plugin.browser +import ai.rever.boss.plugin.api.HostImplemented import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateMapOf -/** Browser-owned navigation state presented by an optional native host toolbar. */ +/** + * Browser-owned navigation state presented by an optional native host toolbar. + * Preserve this constructor when extending the contract; add compatible overloads instead. + */ class BrowserTitleBarState( val url: String, val canGoBack: Boolean, @@ -19,7 +23,19 @@ class BrowserTitleBarState( val address: BrowserAddressBarState? = null, ) -/** UI-thread-only handshake. Plugins retain their toolbar until the host claims their handle. */ +/** + * UI-thread-only handshake; [focus] invokes its callback synchronously on the caller thread. + * The browser package is shared parent-first. On newer hosts the host-compiled copy runs; + * older hosts use the API JAR copy, which remains unclaimed and keeps the in-tab toolbar. + * API 1.0.94 makes these types available; native hosting additionally requires a supporting host. + * Future member additions need a host release and minBossVersion gating. + * + * Plugins pair every [publish] with [remove] during composition disposal, including unload. + * Hosts independently own window claims and focus registrations and must release both on disposal. + * Browser publication cleanup deliberately cannot release a host-owned focus registration. + * A browser hides its toolbar when its window or handle is hosted. + */ +@HostImplemented object BrowserTitleBarBridge { private class Entry(val owner: Any, val state: BrowserTitleBarState) @@ -45,7 +61,6 @@ object BrowserTitleBarBridge { fun remove(handleId: String, owner: Any) { if (entries[handleId]?.owner === owner) { entries.remove(handleId) - hosts.remove(handleId) } } @@ -66,7 +81,13 @@ object BrowserTitleBarBridge { } } -/** Editing and suggestions remain owned by the browser plugin, including keyboard semantics. */ +/** + * Editing and suggestions remain owned by the browser plugin, including keyboard semantics. + * Commands: submit, next, previous, accept, right, cancel, delete. Unknown commands are ignored. + * Revision changes acknowledge commands even when text is unchanged; hasSelectedSuggestion + * indicates that keyboard navigation currently selects a suggestion. + * Preserve this constructor when extending the contract; add compatible overloads instead. + */ class BrowserAddressBarState( val text: String, val selectionStart: Int, diff --git a/src/test/kotlin/ai/rever/boss/plugin/api/BrowserTitleBarBridgeTest.kt b/src/test/kotlin/ai/rever/boss/plugin/api/BrowserTitleBarBridgeTest.kt new file mode 100644 index 0000000..073b846 --- /dev/null +++ b/src/test/kotlin/ai/rever/boss/plugin/api/BrowserTitleBarBridgeTest.kt @@ -0,0 +1,82 @@ +package ai.rever.boss.plugin.api + +import ai.rever.boss.plugin.browser.BrowserTitleBarBridge +import ai.rever.boss.plugin.browser.BrowserTitleBarState +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class BrowserTitleBarBridgeTest { + private fun state() = BrowserTitleBarState("https://example.com", false, false, false, false, {}, {}, {}, {}, {}) + + @Test + fun `unhosting restores toolbar and focus falls back`() { + val owner = Any() + val state = state() + try { + BrowserTitleBarBridge.publish("test-browser", owner, state) + assertFalse(BrowserTitleBarBridge.isHosted("test-browser")) + var focused = false + BrowserTitleBarBridge.host("test-browser") { focused = true } + assertTrue(BrowserTitleBarBridge.focus("test-browser")) + assertTrue(focused) + BrowserTitleBarBridge.host("test-browser", null) + assertFalse(BrowserTitleBarBridge.focus("test-browser")) + assertSame(state, BrowserTitleBarBridge.state("test-browser")) + } finally { + BrowserTitleBarBridge.remove("test-browser", owner) + } + assertNull(BrowserTitleBarBridge.state("test-browser")) + } + + @Test + fun `disposing an old composition cannot unregister its replacement`() { + val old = Any() + val replacement = Any() + val state = state() + try { + BrowserTitleBarBridge.publish("moved-browser", old, state()) + BrowserTitleBarBridge.publish("moved-browser", replacement, state) + BrowserTitleBarBridge.remove("moved-browser", old) + assertSame(state, BrowserTitleBarBridge.state("moved-browser")) + } finally { + BrowserTitleBarBridge.remove("moved-browser", replacement) + } + } + + @Test + fun `old window cleanup preserves a browser moved to a new window`() { + val oldWindow = Any() + val newWindow = Any() + var focused = false + try { + BrowserTitleBarBridge.host("moved-focus", oldWindow) {} + BrowserTitleBarBridge.host("moved-focus", newWindow) { focused = true } + BrowserTitleBarBridge.host("moved-focus", oldWindow, null) + assertTrue(BrowserTitleBarBridge.focus("moved-focus")) + assertTrue(focused) + } finally { + BrowserTitleBarBridge.host("moved-focus", newWindow, null) + } + assertFalse(BrowserTitleBarBridge.isHosted("moved-focus")) + } + + @Test + fun `browser disposal leaves host focus ownership intact`() { + val browser = Any() + val host = Any() + try { + BrowserTitleBarBridge.publish("recomposed-browser", browser, state()) + BrowserTitleBarBridge.host("recomposed-browser", host) {} + BrowserTitleBarBridge.remove("recomposed-browser", browser) + assertNull(BrowserTitleBarBridge.state("recomposed-browser")) + assertTrue(BrowserTitleBarBridge.focus("recomposed-browser")) + } finally { + BrowserTitleBarBridge.remove("recomposed-browser", browser) + BrowserTitleBarBridge.host("recomposed-browser", host, null) + } + } + +}