Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We added a CLI option `--field-formatters` to the `convert` and `generate-bib-from-aux` commands to apply field formatters during export. [#11520](https://github.com/JabRef/jabref/issues/11520)
- We added a preference to skip the import dialog for entries received from browser extensions, allowing direct import into the current library. The import dialog is shown by default; users can enable direct import in Preferences.
- We added support for dragging entries from the "Citation relations" tab to other libraries. [#15135](https://github.com/JabRef/jabref/issues/15135)
- We added `jabref://` protocol handler registration so the browser extension can launch or foreground JabRef when the HTTP server is unreachable. [#15294](https://github.com/JabRef/jabref/pull/15294)
- We added a fetcher selection dropdown to the citation count field in the General tab, allowing users to choose between Semantic Scholar, OpenAlex, OpenCitations, and scite.ai as the source. The selected fetcher is now persisted across restarts and can also be configured in the Entry Editor preferences. [#15134](https://github.com/JabRef/jabref/issues/15134)
- We added support for citation properties in the CAYW endpoint. [#13821](https://github.com/JabRef/jabref/issues/13821)
- We added an export format for [`academicpages`](https://academicpages.github.io/) format. [#12727](https://github.com/JabRef/jabref/issues/12727)
Expand Down
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
* Bad, because six manifest variants and two host script languages required
* Bad, because high packaging overhead across multiple OS and package formats
* Bad, because JabRef already aims to remove this infrastructure due to maintenance burden (see PR #14884)

### 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)
2 changes: 1 addition & 1 deletion flatpak/org.jabref.jabref.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ Exec=JabRef %U
Keywords=bibtex;biblatex;latex;bibliography
Categories=Office;
StartupWMClass=org-jabref-JabRefMain
MimeType=text/x-bibtex;
MimeType=text/x-bibtex;x-scheme-handler/jabref;
2 changes: 1 addition & 1 deletion jabgui/buildres/linux/JabRef.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Icon=APPLICATION_ICON
Terminal=false
Type=Application
Categories=DEPLOY_BUNDLE_CATEGORY
DESKTOP_MIMES
MimeType=text/x-bibtex;x-scheme-handler/jabref;

GenericName=BibTeX Editor
Keywords=bibtex;biblatex;latex;bibliography
Expand Down
11 changes: 11 additions & 0 deletions jabgui/buildres/macos/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@
<string>DEPLOY_BUNDLE_CFBUNDLE_VERSION</string>
<key>NSHumanReadableCopyright</key>
<string>DEPLOY_BUNDLE_COPYRIGHT</string>DEPLOY_FILE_ASSOCIATIONS
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>JabRef Protocol</string>
<key>CFBundleURLSchemes</key>
<array>
<string>jabref</string>
</array>
</dict>
</array>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSSupportsAutomaticGraphicsSwitching</key>
Expand Down
16 changes: 16 additions & 0 deletions jabgui/buildres/windows/main.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,25 @@
<RegistryValue Type="string" Name="update_url" Value="https://edge.microsoft.com/extensionwebstorebase/v1/crx" />
</RegistryKey>
</Component>
<Component Id="RegistryJabRefProtocolHandler" Guid="a7d751c0-85f4-4d1c-abc0-06972d8a0379" Win64="yes">
<RegistryKey Root="HKMU" Key="SOFTWARE\Classes\jabref" Action="createAndRemoveOnUninstall" ForceCreateOnInstall="yes">
<RegistryValue Type="string" Value="URL:JabRef Protocol"/>
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
</RegistryKey>
<RegistryKey Root="HKMU" Key="SOFTWARE\Classes\jabref\DefaultIcon" Action="createAndRemoveOnUninstall" ForceCreateOnInstall="yes">
<RegistryValue Type="string" Value="[INSTALLDIR]JabRef.exe,0"/>
</RegistryKey>
<RegistryKey Root="HKMU" Key="SOFTWARE\Classes\jabref\shell\open" Action="createAndRemoveOnUninstall" ForceCreateOnInstall="yes">
<RegistryValue Type="string" Name="FriendlyAppName" Value="JabRef"/>
</RegistryKey>
<RegistryKey Root="HKMU" Key="SOFTWARE\Classes\jabref\shell\open\command" Action="createAndRemoveOnUninstall" ForceCreateOnInstall="yes">
<RegistryValue Type="string" Value="&quot;[INSTALLDIR]JabRef.exe&quot; &quot;%1&quot;"/>
</RegistryKey>
</Component>
</DirectoryRef>
<Feature Id="BrowserExtension" Level="1">
<ComponentRef Id="RegistryJabRefBrowserEntries" />
<ComponentRef Id="RegistryJabRefProtocolHandler" />
</Feature>
</Product>
</Wix>
22 changes: 21 additions & 1 deletion jabgui/src/main/java/org/jabref/cli/ArgumentProcessor.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.jabref.cli;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.prefs.BackingStoreException;

Expand All @@ -18,6 +19,7 @@

public class ArgumentProcessor {
private static final Logger LOGGER = LoggerFactory.getLogger(ArgumentProcessor.class);
private static final String JABREF_PROTOCOL_SCHEME = "jabref:";

public enum Mode { INITIAL_START, REMOTE_START }

Expand All @@ -28,6 +30,7 @@ public enum Mode { INITIAL_START, REMOTE_START }

private final List<UiCommand> uiCommands = new ArrayList<>();
private boolean guiNeeded = true;
private final boolean protocolHandlerInvoked;

public ArgumentProcessor(String[] args,
Mode startupMode,
Expand All @@ -36,8 +39,20 @@ public ArgumentProcessor(String[] args,
this.preferences = preferences;
this.guiCli = new GuiCommandLine();

String[] filteredArgs = filterProtocolHandlerArgs(args);
this.protocolHandlerInvoked = filteredArgs.length < args.length;

cli = new CommandLine(this.guiCli);
cli.parseArgs(args);
cli.parseArgs(filteredArgs);
}

/// Removes `jabref://` protocol handler URLs from the argument list.
/// These are passed by the OS when the `jabref://` URL scheme is triggered
/// and must not reach picocli, which would try to parse them as file paths.
private static String[] filterProtocolHandlerArgs(String[] args) {
return Arrays.stream(args)
.filter(arg -> !arg.startsWith(JABREF_PROTOCOL_SCHEME))
.toArray(String[]::new);
}

public List<UiCommand> processArguments() {
Expand Down Expand Up @@ -94,6 +109,11 @@ public List<UiCommand> processArguments() {
if (guiCli.importBibtex != null) {
uiCommands.add(new UiCommand.AppendBibTeXToCurrentLibrary(guiCli.importBibtex));
}

if (protocolHandlerInvoked) {
uiCommands.add(new UiCommand.Focus());
}

return uiCommands;
}

Expand Down
6 changes: 6 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/JabRefGUI.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import javafx.stage.WindowEvent;

import org.jabref.gui.clipboard.ClipBoardManager;
import org.jabref.gui.desktop.os.NativeDesktop;
import org.jabref.gui.frame.JabRefFrame;
import org.jabref.gui.help.VersionWorker;
import org.jabref.gui.icon.IconTheme;
Expand Down Expand Up @@ -467,6 +468,11 @@ public void startBackgroundTasks() {
if (remotePreferences.enableLanguageServer()) {
languageServerController.start(cliMessageHandler, remotePreferences.getLanguageServerPort());
}

NativeDesktop.get().registerJabRefProtocolHandler(uri -> {
LOGGER.debug("Received URI via protocol handler: {}", uri);
Platform.runLater(() -> mainFrame.handleUiCommands(List.of(new UiCommand.Focus())));
});
}

private void setupHttpServerEnabledListener() {
Expand Down
10 changes: 10 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.regex.Pattern;

import org.jabref.architecture.AllowedToUseAwt;
Expand Down Expand Up @@ -360,4 +361,13 @@ public void moveToTrash(Path path) {
public boolean moveToTrashSupported() {
return Desktop.getDesktop().isSupported(Desktop.Action.MOVE_TO_TRASH);
}

/// Registers a handler for the {@code jabref://} URL scheme so that the app is focused when a link is opened.
/// On macOS, URI invocations are delivered as Apple Events; this method registers the handler there.
/// On Windows and Linux the URL arrives as a CLI argument and is handled by [ArgumentProcessor], so this is a no-op.
///
/// @param whenJabRefUriOpened callback invoked when a jabref:// URI is received (e.g. focus the main window; run on FX thread if needed)
public void registerJabRefProtocolHandler(Consumer<URI> whenJabRefUriOpened) {
// Default: no-op. Overridden in OSX.
}
}
25 changes: 24 additions & 1 deletion jabgui/src/main/java/org/jabref/gui/desktop/os/OSX.java
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
Expand Down Expand Up @@ -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())) {
whenJabRefUriOpened.accept(uri);
}
});
LoggerFactory.getLogger(OSX.class).debug("Protocol handler for jabref:// registered");
}
}
83 changes: 83 additions & 0 deletions jabgui/src/test/java/org/jabref/cli/ArgumentProcessorTest.java
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);
}
}
Loading
Loading