initial bookmarks extension bridge

This commit is contained in:
Fabian Freund
2026-06-01 16:27:29 +02:00
parent b977504667
commit e66fbb2b41
3 changed files with 475 additions and 12 deletions
@@ -29,6 +29,7 @@ import eu.weblibre.flutter_mozilla_components.addons.AddonPrefs
import eu.weblibre.flutter_mozilla_components.api.GeckoViewportApiImpl
import eu.weblibre.flutter_mozilla_components.api.GeckoEngineSettingsApiImpl
import eu.weblibre.flutter_mozilla_components.feature.DefaultSelectionActionDelegate
import eu.weblibre.flutter_mozilla_components.feature.GeckoBookmarksExtensionBridge
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@@ -407,6 +408,10 @@ object GlobalComponents {
Logger.error("Failed to initialize web extension support", e)
}
// Answer browser.bookmarks requests from extensions (e.g. floccus)
// out of the shared application-services Places store.
GeckoBookmarksExtensionBridge.register()
GlobalScope.launch(Dispatchers.IO) {
newComponents.core.fileUploadsDirCleaner.cleanUploadsDirectory()
}
@@ -1,6 +1,7 @@
package eu.weblibre.flutter_mozilla_components.api
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.feature.GeckoBookmarksExtensionBridge
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkInfo
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkNode
import eu.weblibre.flutter_mozilla_components.pigeons.BookmarkNodeType
@@ -147,11 +148,13 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
) {
coroutineScope.launch {
withContext(Dispatchers.Main) {
components.core.bookmarksStorage.addItem(parentGuid, url, title, position?.toUInt())
.fold(
{ guid -> callback(Result.success(guid)) },
{ e -> callback(Result.failure(e)) }
)
val result =
components.core.bookmarksStorage.addItem(parentGuid, url, title, position?.toUInt())
result.fold(
{ guid -> callback(Result.success(guid)) },
{ e -> callback(Result.failure(e)) }
)
emitCreated(result.getOrNull())
}
}
}
@@ -164,11 +167,13 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
) {
coroutineScope.launch {
withContext(Dispatchers.Main) {
components.core.bookmarksStorage.addFolder(parentGuid, title, position?.toUInt())
.fold(
{ guid -> callback(Result.success(guid)) },
{ e -> callback(Result.failure(e)) }
)
val result =
components.core.bookmarksStorage.addFolder(parentGuid, title, position?.toUInt())
result.fold(
{ guid -> callback(Result.success(guid)) },
{ e -> callback(Result.failure(e)) }
)
emitCreated(result.getOrNull())
}
}
}
@@ -186,10 +191,21 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
title = info.title,
url = info.url
)
components.core.bookmarksStorage.updateNode(guid, conceptInfo).fold(
val result = components.core.bookmarksStorage.updateNode(guid, conceptInfo)
result.fold(
{ callback(Result.success(Unit)) },
{ e -> callback(Result.failure(e)) }
)
if (result.isSuccess) {
components.core.bookmarksStorage.getBookmark(guid).getOrNull()?.let { node ->
if (info.title != null || info.url != null) {
GeckoBookmarksExtensionBridge.emitChanged(node)
}
if (info.parentGuid != null || info.position != null) {
GeckoBookmarksExtensionBridge.emitMoved(node)
}
}
}
}
}
}
@@ -200,12 +216,32 @@ class GeckoBookmarksApiImpl() : GeckoBookmarksApi {
) {
coroutineScope.launch {
withContext(Dispatchers.Main) {
val node = components.core.bookmarksStorage.getBookmark(guid).getOrNull()
components.core.bookmarksStorage.deleteNode(guid).fold(
{ deleted -> callback(Result.success(deleted)) },
{ deleted ->
callback(Result.success(deleted))
if (deleted && node != null) {
GeckoBookmarksExtensionBridge.emitRemoved(node)
}
},
{ e -> callback(Result.failure(e)) }
)
}
}
}
/**
* Notifies extension `bookmarks.onCreated` listeners about a node created
* through the app UI, so extensions (e.g. floccus) observe app-side edits to
* the shared store.
*/
private suspend fun emitCreated(guid: String?) {
if (guid == null) {
return
}
components.core.bookmarksStorage.getBookmark(guid).getOrNull()?.let {
GeckoBookmarksExtensionBridge.emitCreated(it)
}
}
}
@@ -0,0 +1,422 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.feature
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import mozilla.components.concept.storage.BookmarkInfo
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.concept.storage.BookmarkNodeType
import mozilla.components.concept.storage.BookmarksStorage
import mozilla.components.support.base.log.logger.Logger
import org.mozilla.gecko.EventDispatcher
import org.mozilla.gecko.util.BundleEventListener
import org.mozilla.gecko.util.EventCallback
import org.mozilla.gecko.util.GeckoBundle
/**
* Bridges the WebExtension `browser.bookmarks` API (implemented in Gecko's
* mobile/shared/components/extensions/ext-bookmarks.js) onto the application's
* application-services Places store (`components.core.bookmarksStorage`).
*
* GeckoView builds disable Gecko's own Places (MOZ_GECKOVIEW_HISTORY instead of
* MOZ_PLACES), so the JS API has no in-process bookmark store to talk to.
* Instead, ext-bookmarks.js sends `GeckoView:Bookmarks:*` request events over
* the global EventDispatcher; this listener answers them from the shared store
* that also backs the app's own bookmark UI (and FxA Sync), so extensions such
* as floccus operate on exactly the bookmarks the user sees.
*
* Wire format mirrors the android-components [BookmarkNode] shape (guid /
* parentGuid / position / type as "item"|"folder"|"separator"); ext-bookmarks.js
* maps it to/from the WebExtension BookmarkTreeNode shape.
*/
object GeckoBookmarksExtensionBridge : BundleEventListener {
private val logger = Logger("GeckoBookmarksExtensionBridge")
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
// application-services Places root guids. Keep in sync with ext-bookmarks.js.
private const val ROOT_GUID = "root________"
private const val UNFILED_GUID = "unfiled_____"
private const val TYPE_ITEM = "item"
private const val TYPE_FOLDER = "folder"
private const val TYPE_SEPARATOR = "separator"
private const val REQUEST_GET = "GeckoView:Bookmarks:Get"
private const val REQUEST_GET_TREE = "GeckoView:Bookmarks:GetTree"
private const val REQUEST_GET_CHILDREN = "GeckoView:Bookmarks:GetChildren"
private const val REQUEST_SEARCH = "GeckoView:Bookmarks:Search"
private const val REQUEST_CREATE = "GeckoView:Bookmarks:Create"
private const val REQUEST_MOVE = "GeckoView:Bookmarks:Move"
private const val REQUEST_UPDATE = "GeckoView:Bookmarks:Update"
private const val REQUEST_REMOVE = "GeckoView:Bookmarks:Remove"
private const val EVENT_ON_CREATED = "GeckoView:Bookmarks:OnCreated"
private const val EVENT_ON_REMOVED = "GeckoView:Bookmarks:OnRemoved"
private const val EVENT_ON_CHANGED = "GeckoView:Bookmarks:OnChanged"
private const val EVENT_ON_MOVED = "GeckoView:Bookmarks:OnMoved"
private const val DEFAULT_SEARCH_LIMIT = 100
private val REQUEST_EVENTS = arrayOf(
REQUEST_GET,
REQUEST_GET_TREE,
REQUEST_GET_CHILDREN,
REQUEST_SEARCH,
REQUEST_CREATE,
REQUEST_MOVE,
REQUEST_UPDATE,
REQUEST_REMOVE,
)
@Volatile
private var registered = false
/** Registers the request listener with the global EventDispatcher. Idempotent. */
@Synchronized
fun register() {
if (registered) {
return
}
EventDispatcher.getInstance().registerUiThreadListener(this, *REQUEST_EVENTS)
registered = true
}
private val storage: BookmarksStorage?
get() = GlobalComponents.components?.core?.bookmarksStorage
override fun handleMessage(
event: String,
message: GeckoBundle,
callback: EventCallback?,
) {
if (callback == null) {
return
}
val store = storage
if (store == null) {
callback.sendError("Bookmarks storage is not available")
return
}
scope.launch {
try {
when (event) {
REQUEST_GET -> handleGet(store, message, callback)
REQUEST_GET_TREE -> handleGetTree(store, message, callback)
REQUEST_GET_CHILDREN -> handleGetChildren(store, message, callback)
REQUEST_SEARCH -> handleSearch(store, message, callback)
REQUEST_CREATE -> handleCreate(store, message, callback)
REQUEST_MOVE -> handleMove(store, message, callback)
REQUEST_UPDATE -> handleUpdate(store, message, callback)
REQUEST_REMOVE -> handleRemove(store, message, callback)
else -> callback.sendError("Unknown bookmarks request: $event")
}
} catch (e: Exception) {
logger.error("Bookmarks request failed: $event", e)
callback.sendError(e.message ?: "Bookmarks operation failed")
}
}
}
// region request handlers
private suspend fun handleGet(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val guids = message.getStringArray("guids") ?: emptyArray()
val nodes = ArrayList<GeckoBundle>(guids.size)
for (guid in guids) {
val node = fetchNode(store, guid, recursive = false)
if (node == null) {
callback.sendError("Bookmark not found: $guid")
return
}
nodes.add(nodeToBundle(node, includeChildren = false))
}
callback.sendSuccess(nodes.toTypedArray())
}
private suspend fun handleGetTree(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val guid = message.getString("guid") ?: ROOT_GUID
val recursive = message.getBoolean("recursive", true)
val node = store.getTree(guid, recursive).getOrNull()
if (node == null) {
callback.sendError("Bookmark not found: $guid")
return
}
callback.sendSuccess(nodeToBundle(node, includeChildren = recursive))
}
private suspend fun handleGetChildren(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val guid = message.getString("guid")
if (guid == null) {
callback.sendError("Missing bookmark id")
return
}
val node = store.getTree(guid, recursive = false).getOrNull()
if (node == null) {
callback.sendError("Bookmark not found: $guid")
return
}
val children = node.children
?.map { nodeToBundle(it, includeChildren = false) }
?.toTypedArray()
?: emptyArray()
callback.sendSuccess(children)
}
private suspend fun handleSearch(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val url = message.getString("url")
val results = if (url != null) {
// Desktop bookmarks.search({url}) matches the URL exactly.
store.getBookmarksWithUrl(url).getOrDefault(emptyList())
} else {
val query = message.getString("query") ?: message.getString("title") ?: ""
val limit = message.getInt("limit", DEFAULT_SEARCH_LIMIT).coerceAtLeast(1)
store.searchBookmarks(query, limit).getOrDefault(emptyList())
}
val nodes = results.map { nodeToBundle(it, includeChildren = false) }.toTypedArray()
callback.sendSuccess(nodes)
}
private suspend fun handleCreate(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val parentGuid = message.getString("parentGuid") ?: UNFILED_GUID
val position = optionalPosition(message, "index")
val title = message.getString("title") ?: ""
val url = message.getString("url")
val type = message.getString("type") ?: TYPE_ITEM
val newGuid = when (type) {
TYPE_FOLDER -> store.addFolder(parentGuid, title, position)
TYPE_SEPARATOR -> store.addSeparator(parentGuid, position)
else -> store.addItem(parentGuid, url ?: "", title, position)
}.getOrThrow()
val node = store.getBookmark(newGuid).getOrNull()
if (node == null) {
callback.sendError("Failed to read created bookmark")
return
}
emitCreated(node)
callback.sendSuccess(nodeToBundle(node, includeChildren = false))
}
private suspend fun handleMove(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val guid = message.getString("guid")
if (guid == null) {
callback.sendError("Missing bookmark id")
return
}
val parentGuid = message.getString("parentGuid")
var position = optionalPosition(message, "index")
if (position == null && parentGuid != null) {
// Desktop move() uses DEFAULT_INDEX (append to the end of the new
// parent). a-s keeps the current position when position is null, so
// resolve an explicit append index from the target parent's size.
position = (store.getTree(parentGuid, recursive = false)
.getOrNull()?.children?.size ?: 0).toUInt()
}
store.updateNode(
guid,
BookmarkInfo(parentGuid = parentGuid, position = position, title = null, url = null),
).getOrThrow()
val node = store.getBookmark(guid).getOrNull()
if (node == null) {
callback.sendError("Bookmark not found: $guid")
return
}
emitMoved(node)
callback.sendSuccess(nodeToBundle(node, includeChildren = false))
}
private suspend fun handleUpdate(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val guid = message.getString("guid")
if (guid == null) {
callback.sendError("Missing bookmark id")
return
}
val title = message.getString("title")
val url = message.getString("url")
store.updateNode(
guid,
BookmarkInfo(parentGuid = null, position = null, title = title, url = url),
).getOrThrow()
val node = store.getBookmark(guid).getOrNull()
if (node == null) {
callback.sendError("Bookmark not found: $guid")
return
}
emitChanged(node)
callback.sendSuccess(nodeToBundle(node, includeChildren = false))
}
private suspend fun handleRemove(
store: BookmarksStorage,
message: GeckoBundle,
callback: EventCallback,
) {
val guid = message.getString("guid")
if (guid == null) {
callback.sendError("Missing bookmark id")
return
}
val tree = message.getBoolean("tree", true)
// Capture the node before deletion so listeners get its parent/index.
val node = store.getBookmark(guid).getOrNull()
if (!tree && node != null && node.type == BookmarkNodeType.FOLDER) {
// Match desktop bookmarks.remove(): refuse non-empty folders
// (preventRemovalOfNonEmptyFolders). removeTree() passes tree=true.
val children = store.getTree(guid, recursive = false).getOrNull()?.children
if (!children.isNullOrEmpty()) {
callback.sendError("Cannot remove a non-empty folder")
return
}
}
store.deleteNode(guid).getOrThrow()
if (node != null) {
emitRemoved(node)
}
callback.sendSuccess(true)
}
// endregion
/**
* Fetches a node, falling back to a children-stripped single-level tree when
* [BookmarksStorage.getBookmark] returns null for a folder root.
*/
private suspend fun fetchNode(
store: BookmarksStorage,
guid: String,
recursive: Boolean,
): BookmarkNode? {
store.getBookmark(guid).getOrNull()?.let { return it }
return store.getTree(guid, recursive).getOrNull()?.let {
if (recursive) it else it.copy(children = null)
}
}
private fun optionalPosition(message: GeckoBundle, key: String): UInt? {
val raw = message.getInt(key, -1)
return if (raw < 0) null else raw.toUInt()
}
// region change-event emission (UI edits -> extensions)
/** Notify extension listeners that a bookmark was created. */
fun emitCreated(node: BookmarkNode) {
dispatch(EVENT_ON_CREATED, nodeToBundle(node, includeChildren = false))
}
/** Notify extension listeners that a bookmark's title and/or url changed. */
fun emitChanged(node: BookmarkNode) {
val bundle = GeckoBundle()
bundle.putString("guid", node.guid)
node.title?.let { bundle.putString("title", it) }
node.url?.let { bundle.putString("url", it) }
dispatch(EVENT_ON_CHANGED, bundle)
}
/** Notify extension listeners that a bookmark moved to a new parent/index. */
fun emitMoved(node: BookmarkNode) {
val bundle = GeckoBundle()
bundle.putString("guid", node.guid)
node.parentGuid?.let { bundle.putString("parentId", it) }
node.position?.let { bundle.putInt("index", it.toInt()) }
dispatch(EVENT_ON_MOVED, bundle)
}
/** Notify extension listeners that a bookmark was removed. */
fun emitRemoved(node: BookmarkNode) {
val bundle = GeckoBundle()
bundle.putString("guid", node.guid)
node.parentGuid?.let { bundle.putString("parentId", it) }
node.position?.let { bundle.putInt("index", it.toInt()) }
bundle.putBundle("node", nodeToBundle(node, includeChildren = false))
dispatch(EVENT_ON_REMOVED, bundle)
}
private fun dispatch(event: String, bundle: GeckoBundle) {
if (!registered) {
return
}
try {
EventDispatcher.getInstance().dispatch(event, bundle)
} catch (e: Exception) {
logger.warn("Failed to dispatch $event", e)
}
}
// endregion
// region serialization
private fun typeName(type: BookmarkNodeType): String = when (type) {
BookmarkNodeType.ITEM -> TYPE_ITEM
BookmarkNodeType.FOLDER -> TYPE_FOLDER
BookmarkNodeType.SEPARATOR -> TYPE_SEPARATOR
}
private fun nodeToBundle(node: BookmarkNode, includeChildren: Boolean): GeckoBundle {
val bundle = GeckoBundle()
bundle.putString("guid", node.guid)
bundle.putString("type", typeName(node.type))
node.parentGuid?.let { bundle.putString("parentGuid", it) }
node.position?.let { bundle.putInt("position", it.toInt()) }
node.title?.let { bundle.putString("title", it) }
node.url?.let { bundle.putString("url", it) }
bundle.putLong("dateAdded", node.dateAdded)
bundle.putLong("lastModified", node.lastModified)
if (includeChildren) {
node.children?.let { children ->
bundle.putBundleArray(
"children",
children.map { nodeToBundle(it, includeChildren = true) }.toTypedArray(),
)
}
}
return bundle
}
// endregion
}