-
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Implement Custom URL Protocol Handling #15378
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| --- | ||
| nav_order: 55 | ||
| parent: Decision Records | ||
| status: proposed | ||
| date: 2026-03-06 | ||
| --- | ||
| # Use Hybrid Architecture (Protocol Handler + HTTP) for Browser Extension Communication | ||
|
|
||
| ## Context and Problem Statement | ||
|
|
||
| JabRef's browser extension imports bibliographic data from web pages into the desktop application via HTTP requests to a local server (`jabsrv`). | ||
| If JabRef is not running, the request fails silently and the import is lost. | ||
| Additionally, the server sets `Access-Control-Allow-Origin: *` without authentication, allowing any website to send requests. | ||
|
|
||
| How should the extension communicate with the desktop application to solve both problems while remaining cross-platform, cross-browser, and maintainable? | ||
|
|
||
| ## Decision Drivers | ||
|
|
||
| * Must work on Windows, macOS, and Linux | ||
| * Must work in Chrome and Firefox under Manifest V3 | ||
| * Must handle the case when JabRef is not running | ||
| * Must authenticate the extension and protect against CSRF | ||
| * Changes must be maintainable by JabRef's open-source community | ||
|
|
||
| ## Considered Options | ||
|
|
||
| * Native Messaging | ||
| * Local HTTP API (status quo) | ||
| * Protocol Handler only | ||
| * Hybrid — Protocol Handler + HTTP | ||
| * WebSocket | ||
| * Companion / Daemon | ||
|
|
||
| ## Decision Outcome | ||
|
|
||
| Chosen option: "Hybrid — Protocol Handler + HTTP", because it comes out best (see below). | ||
|
|
||
| The extension sends data via HTTP to `jabsrv` on localhost. | ||
| When JabRef is not running, a `jabref://` protocol handler starts the application; the extension then polls until the HTTP endpoint becomes reachable. | ||
|
|
||
| ### Consequences | ||
|
|
||
| * Good, because HTTP is platform- and browser-independent using standard `fetch()` API | ||
| * Good, because the protocol handler starts JabRef when it is not running, without encoding data in the URL | ||
| * Good, because `jabsrv` already provides the HTTP infrastructure | ||
| * Good, because graceful degradation to pure HTTP when handler is not registered | ||
| * Bad, because a localhost listener requires CSRF mitigations (custom headers, origin checks) | ||
| * Bad, because the protocol handler must be registered on three operating systems | ||
| * Bad, because two communication channels increase implementation complexity | ||
|
|
||
| ## Pros and Cons of the Options | ||
|
|
||
| ### Native Messaging | ||
|
|
||
| * Good, because no network listener exposed — best security properties | ||
| * Good, because the browser manages the host process lifecycle | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| * Bad, because six manifest variants and two host script languages required | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's not intrinsic to the architecture but just a design decision. In principle you could not use Python for this. Also I don't see what's the problem with using different manifest variants. |
||
| * Bad, because high packaging overhead across multiple OS and package formats | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These files are not changed for years and their installation is a simple file copying. What's the "high overhead" here? |
||
| * Bad, because JabRef already aims to remove this infrastructure due to maintenance burden (see PR #14884) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a circular argument. |
||
|
|
||
| ### Local HTTP API (status quo) | ||
|
|
||
| * Good, because platform- and browser-independent, easy to test | ||
| * Bad, because no mechanism to start JabRef when not running — import is lost | ||
|
|
||
| ### Protocol Handler only | ||
|
|
||
| * Good, because can launch JabRef when not running | ||
| * Bad, because no authentication possible — any website can trigger the URL | ||
| * Bad, because limited URL length | ||
| * Bad, because unidirectional, no response channel | ||
|
|
||
| ### Hybrid (Protocol Handler + HTTP) | ||
|
|
||
| * Good, because HTTP handles data transfer with full response channel | ||
| * Good, because protocol handler carries no data, avoiding the security issues of "Protocol Handler only" | ||
| * Good, because REST endpoints are extensible for future features | ||
| * Bad, because localhost listener requires CSRF mitigations | ||
| * Bad, because two communication channels increase complexity | ||
|
|
||
| ### WebSocket | ||
|
|
||
| * Good, because persistent bidirectional connection with low latency | ||
| * Bad, because no mechanism to start JabRef when not running | ||
| * Bad, because WebSocket is not subject to Same-Origin Policy — any website can connect | ||
|
|
||
| ### Companion / Daemon | ||
|
|
||
| * Good, because handles the offline-app case via local queue | ||
| * Bad, because three fundamentally different service managers per OS | ||
| * Bad, because effectively a second software project with own build system and CI/CD | ||
|
|
||
| ## More Information | ||
|
|
||
| * [Issue #17: Architecture discussion](https://github.com/JabRef/JabRef-Browser-Extension-fresh/issues/17) | ||
| * [PR #18: Protocol Handler PoC](https://github.com/JabRef/JabRef-Browser-Extension-fresh/pull/18) | ||
| * [PR #14884: Remove Native Messaging infrastructure](https://github.com/JabRef/jabref/pull/14884) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,26 @@ | ||
| package org.jabref.gui.desktop.os; | ||
|
|
||
| import java.awt.Desktop; | ||
| import java.io.IOException; | ||
| import java.net.URI; | ||
| import java.nio.file.Path; | ||
| import java.util.Optional; | ||
| import java.util.function.Consumer; | ||
|
|
||
| import org.jabref.architecture.AllowedToUseAwt; | ||
| import org.jabref.gui.DialogService; | ||
| import org.jabref.gui.externalfiletype.ExternalFileType; | ||
| import org.jabref.gui.externalfiletype.ExternalFileTypes; | ||
| import org.jabref.gui.frame.ExternalApplicationsPreferences; | ||
|
|
||
| import org.slf4j.LoggerFactory; | ||
|
|
||
| /// This class contains macOS (OSX) specific implementations for file directories and file/application open handling methods. | ||
| /// | ||
| /// We cannot use a static logger instance here in this class as the Logger first needs to be configured in the {@link JabKit#initLogging}. | ||
| /// The configuration of tinylog will become immutable as soon as the first log entry is issued. | ||
| /// https://tinylog.org/v2/configuration/ | ||
| @AllowedToUseAwt("Requires AWT to open a file") | ||
| @AllowedToUseAwt("Requires AWT to open a file and to register the jabref:// protocol handler") | ||
| public class OSX extends NativeDesktop { | ||
|
|
||
| @Override | ||
|
|
@@ -52,4 +57,22 @@ public void openConsole(String absolutePath, DialogService dialogService) throws | |
| public Path getApplicationDirectory() { | ||
| return Path.of("/Applications"); | ||
| } | ||
|
|
||
| @Override | ||
| public void registerJabRefProtocolHandler(Consumer<URI> whenJabRefUriOpened) { | ||
| if (!Desktop.isDesktopSupported()) { | ||
| return; | ||
| } | ||
| Desktop desktop = Desktop.getDesktop(); | ||
| if (!desktop.isSupported(Desktop.Action.APP_OPEN_URI)) { | ||
| return; | ||
| } | ||
| desktop.setOpenURIHandler(event -> { | ||
| URI uri = event.getURI(); | ||
| if ("jabref".equals(uri.getScheme())) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't we have somewhere a constant for jabref URL schema? Though I think this might be too much.. |
||
| whenJabRefUriOpened.accept(uri); | ||
| } | ||
| }); | ||
| LoggerFactory.getLogger(OSX.class).debug("Protocol handler for jabref:// registered"); | ||
|
calixtus marked this conversation as resolved.
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| package org.jabref.cli; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| import org.jabref.gui.preferences.GuiPreferences; | ||
| import org.jabref.logic.UiCommand; | ||
|
|
||
| import org.junit.jupiter.api.Test; | ||
| import org.junit.jupiter.params.ParameterizedTest; | ||
| import org.junit.jupiter.params.provider.ValueSource; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertFalse; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
| import static org.mockito.Mockito.mock; | ||
|
|
||
| class ArgumentProcessorTest { | ||
|
|
||
| private final GuiPreferences preferences = mock(GuiPreferences.class); | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(strings = {"jabref://", "jabref://open", "jabref:", "jabref://some/path"}) | ||
| void protocolHandlerUrlProducesFocusCommand(String url) { | ||
| ArgumentProcessor processor = new ArgumentProcessor( | ||
| new String[] {url}, | ||
| ArgumentProcessor.Mode.REMOTE_START, | ||
| preferences); | ||
|
|
||
| List<UiCommand> commands = processor.processArguments(); | ||
|
|
||
| assertTrue(commands.stream().anyMatch(UiCommand.Focus.class::isInstance)); | ||
| } | ||
|
|
||
| @Test | ||
| void normalArgumentsAreNotAffectedByProtocolFilter() { | ||
| ArgumentProcessor processor = new ArgumentProcessor( | ||
| new String[] {"--blank"}, | ||
| ArgumentProcessor.Mode.REMOTE_START, | ||
| preferences); | ||
|
|
||
| List<UiCommand> commands = processor.processArguments(); | ||
|
|
||
| assertTrue(commands.stream().anyMatch(UiCommand.BlankWorkspace.class::isInstance)); | ||
| assertFalse(commands.stream().anyMatch(UiCommand.Focus.class::isInstance)); | ||
| } | ||
|
|
||
| @Test | ||
| void protocolHandlerUrlCombinedWithNormalArguments() { | ||
| ArgumentProcessor processor = new ArgumentProcessor( | ||
| new String[] {"jabref://", "--blank"}, | ||
| ArgumentProcessor.Mode.REMOTE_START, | ||
| preferences); | ||
|
|
||
| List<UiCommand> commands = processor.processArguments(); | ||
|
|
||
| assertTrue(commands.stream().anyMatch(UiCommand.BlankWorkspace.class::isInstance)); | ||
| } | ||
|
|
||
| @Test | ||
| void emptyArgumentsProduceNoFocusCommand() { | ||
| ArgumentProcessor processor = new ArgumentProcessor( | ||
| new String[] {}, | ||
| ArgumentProcessor.Mode.REMOTE_START, | ||
| preferences); | ||
|
|
||
| List<UiCommand> commands = processor.processArguments(); | ||
|
|
||
| assertFalse(commands.stream().anyMatch(UiCommand.Focus.class::isInstance)); | ||
| } | ||
|
|
||
| @Test | ||
| void onlyProtocolHandlerUrlProducesOnlyFocusCommand() { | ||
| ArgumentProcessor processor = new ArgumentProcessor( | ||
| new String[] {"jabref://"}, | ||
| ArgumentProcessor.Mode.REMOTE_START, | ||
| preferences); | ||
|
|
||
| List<UiCommand> commands = processor.processArguments(); | ||
|
|
||
| assertEquals(1, commands.size()); | ||
| assertTrue(commands.getFirst() instanceof UiCommand.Focus); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
But should this ADR be in this PR and not in #15295 or some other directly related to the browser extension?