Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions api/boss-plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> (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 <init> ()V
Expand Down Expand Up @@ -7430,6 +7447,38 @@ 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;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
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 <init> (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 <init> (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 <init> (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
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
104 changes: 104 additions & 0 deletions src/main/kotlin/ai/rever/boss/plugin/browser/BrowserTitleBarBridge.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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.
* Preserve this constructor when extending the contract; add compatible overloads instead.
*/
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; [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)

private val entries = mutableStateMapOf<String, Entry>()
private val windows = mutableStateMapOf<String, Boolean>()

fun hostWindow(windowId: String, enabled: Boolean) {
if (enabled) windows[windowId] = true else windows.remove(windowId)
}

fun isWindowHosted(windowId: String): Boolean = windows[windowId] == true

private class Host(val owner: Any, val focus: () -> Unit)
private val hosts = mutableStateMapOf<String, Host>()
private val legacyHostOwner = Any()

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

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]?.focus ?: return false
focus()
return true
}
}

/**
* 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,
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,
)
Original file line number Diff line number Diff line change
@@ -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)
}
}

}
Loading