From e66fbb2b4106243019862d806e72d8b6e2e86a7d Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 1 Jun 2026 16:27:29 +0200 Subject: [PATCH] initial bookmarks extension bridge --- .../GlobalComponents.kt | 5 + .../api/GeckoBookmarksApiImpl.kt | 60 ++- .../feature/GeckoBookmarksExtensionBridge.kt | 422 ++++++++++++++++++ 3 files changed, 475 insertions(+), 12 deletions(-) create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GeckoBookmarksExtensionBridge.kt diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index 4138c01b..fe0ac669 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -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() } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt index 5e1e3ea0..c8bbbbe9 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoBookmarksApiImpl.kt @@ -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) + } + } + } \ No newline at end of file diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GeckoBookmarksExtensionBridge.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GeckoBookmarksExtensionBridge.kt new file mode 100644 index 00000000..08ba34d9 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/GeckoBookmarksExtensionBridge.kt @@ -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(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 +}