diff --git a/CHANGELOG.md b/CHANGELOG.md index 42073477526..e371ed4579e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- We added `jabref://` protocol handler registration so the browser extension can launch or foreground JabRef when the HTTP server is unreachable. [#15378](https://github.com/JabRef/jabref/pull/15378) - We added support for downloading full-text PDFs from Wiley journals via the Wiley TDM API. [#13404](https://github.com/JabRef/jabref/issues/13404) - We added `--key-patterns` option to CLI parameters to allows users to set a citation key's pattern for a specific entry type. [#14707](https://github.com/JabRef/jabref/issues/14707) - 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) diff --git a/docs/decisions/0055-browser-extension-communication-architecture.md b/docs/decisions/0055-browser-extension-communication-architecture.md new file mode 100644 index 00000000000..6ed74a5ffc4 --- /dev/null +++ b/docs/decisions/0055-browser-extension-communication-architecture.md @@ -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) diff --git a/flatpak/org.jabref.jabref.desktop b/flatpak/org.jabref.jabref.desktop index 839978b0c88..c6081f1981a 100644 --- a/flatpak/org.jabref.jabref.desktop +++ b/flatpak/org.jabref.jabref.desktop @@ -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; diff --git a/jabgui/buildres/linux/JabRef.desktop b/jabgui/buildres/linux/JabRef.desktop index a5d3afddf18..618dcf14eff 100644 --- a/jabgui/buildres/linux/JabRef.desktop +++ b/jabgui/buildres/linux/JabRef.desktop @@ -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 diff --git a/jabgui/buildres/macos/Info.plist b/jabgui/buildres/macos/Info.plist index 2c733d1931f..b95c33238f4 100644 --- a/jabgui/buildres/macos/Info.plist +++ b/jabgui/buildres/macos/Info.plist @@ -31,6 +31,17 @@ DEPLOY_BUNDLE_CFBUNDLE_VERSION NSHumanReadableCopyright DEPLOY_BUNDLE_COPYRIGHTDEPLOY_FILE_ASSOCIATIONS + CFBundleURLTypes + + + CFBundleURLName + JabRef Protocol + CFBundleURLSchemes + + jabref + + + NSHighResolutionCapable true NSSupportsAutomaticGraphicsSwitching diff --git a/jabgui/buildres/windows/main.wxs b/jabgui/buildres/windows/main.wxs index 2bbd99d17f9..362379b87e0 100644 --- a/jabgui/buildres/windows/main.wxs +++ b/jabgui/buildres/windows/main.wxs @@ -160,9 +160,25 @@ + + + + + + + + + + + + + + + + diff --git a/jabgui/src/main/java/org/jabref/cli/ArgumentProcessor.java b/jabgui/src/main/java/org/jabref/cli/ArgumentProcessor.java index 7ace2484925..9fbeb38b11b 100644 --- a/jabgui/src/main/java/org/jabref/cli/ArgumentProcessor.java +++ b/jabgui/src/main/java/org/jabref/cli/ArgumentProcessor.java @@ -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; @@ -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 } @@ -28,6 +30,7 @@ public enum Mode { INITIAL_START, REMOTE_START } private final List uiCommands = new ArrayList<>(); private boolean guiNeeded = true; + private final boolean protocolHandlerInvoked; public ArgumentProcessor(String[] args, Mode startupMode, @@ -36,8 +39,24 @@ 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 -> !isJabRefProtocolArgument(arg)) + .toArray(String[]::new); + } + + private static boolean isJabRefProtocolArgument(String arg) { + return arg.regionMatches(true, 0, JABREF_PROTOCOL_SCHEME, 0, JABREF_PROTOCOL_SCHEME.length()); } public List processArguments() { @@ -94,6 +113,11 @@ public List processArguments() { if (guiCli.importBibtex != null) { uiCommands.add(new UiCommand.AppendBibTeXToCurrentLibrary(guiCli.importBibtex)); } + + if (protocolHandlerInvoked) { + uiCommands.add(new UiCommand.Focus()); + } + return uiCommands; } diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java index dac75765918..83129deae9b 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java @@ -20,6 +20,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; @@ -469,6 +470,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() { diff --git a/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java b/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java index a3f86c090f0..dcfbe92401f 100644 --- a/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java +++ b/jabgui/src/main/java/org/jabref/gui/desktop/os/NativeDesktop.java @@ -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; @@ -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 whenJabRefUriOpened) { + // Default: no-op. Overridden in OSX. + } } diff --git a/jabgui/src/main/java/org/jabref/gui/desktop/os/OSX.java b/jabgui/src/main/java/org/jabref/gui/desktop/os/OSX.java index 2d484620e22..f19474f0108 100644 --- a/jabgui/src/main/java/org/jabref/gui/desktop/os/OSX.java +++ b/jabgui/src/main/java/org/jabref/gui/desktop/os/OSX.java @@ -1,8 +1,11 @@ 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; @@ -10,12 +13,14 @@ 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 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"); + } } diff --git a/jabgui/src/test/java/org/jabref/cli/ArgumentProcessorTest.java b/jabgui/src/test/java/org/jabref/cli/ArgumentProcessorTest.java new file mode 100644 index 00000000000..220dac2e5cd --- /dev/null +++ b/jabgui/src/test/java/org/jabref/cli/ArgumentProcessorTest.java @@ -0,0 +1,73 @@ +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.mockito.Mockito.mock; + +class ArgumentProcessorTest { + + private final GuiPreferences preferences = mock(GuiPreferences.class); + + @ParameterizedTest + @ValueSource(strings = { + "jabref://", + "jabref://open", + "jabref:", + "jabref://some/path", + "JABREF://open", + "JabRef://open"}) + void protocolHandlerUrlProducesFocusCommand(String url) { + ArgumentProcessor processor = new ArgumentProcessor( + new String[] {url}, + ArgumentProcessor.Mode.REMOTE_START, + preferences); + + List commands = processor.processArguments(); + + assertEquals(List.of(new UiCommand.Focus()), commands); + } + + @Test + void normalArgumentsAreNotAffectedByProtocolFilter() { + ArgumentProcessor processor = new ArgumentProcessor( + new String[] {"--blank"}, + ArgumentProcessor.Mode.REMOTE_START, + preferences); + + List commands = processor.processArguments(); + + assertEquals(List.of(new UiCommand.BlankWorkspace()), commands); + } + + @Test + void protocolHandlerUrlCombinedWithNormalArguments() { + ArgumentProcessor processor = new ArgumentProcessor( + new String[] {"jabref://", "--blank"}, + ArgumentProcessor.Mode.REMOTE_START, + preferences); + + List commands = processor.processArguments(); + + assertEquals(List.of(new UiCommand.BlankWorkspace()), commands); + } + + @Test + void emptyArgumentsProduceNoFocusCommand() { + ArgumentProcessor processor = new ArgumentProcessor( + new String[] {}, + ArgumentProcessor.Mode.REMOTE_START, + preferences); + + List commands = processor.processArguments(); + + assertEquals(List.of(), commands); + } +} diff --git a/snap/gui/jabref.desktop b/snap/gui/jabref.desktop index 3444b73fc8a..aa322a44add 100644 --- a/snap/gui/jabref.desktop +++ b/snap/gui/jabref.desktop @@ -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;