Skip to content

Bookmarklet, browser extension, and the PWA share target

Three ways to hand KnightLoader a link from somewhere that is not KnightLoader itself: a page you are already looking at, a right-click menu, or your device's own Share sheet. All three are configured from Settings > Apps. The bookmarklet and the share target land on /quickadd (web/src/pages/QuickAdd.tsx); the extension no longer does, and the section on it below says what it does instead and why.

This page overlaps with Click'n'Load (docs/clicknload.md), which was not true when it was first written. CnL answers a button a website put on its own page, aimed at a download manager on the same machine at a fixed loopback port; the tools here answer a link with no button anywhere. Since the extension learned to intercept CnL submissions itself (extension/src/cnl-main.js), the two meet: the extension catches the button a website offers and routes it through the relay, so it reaches an instance that is not on that machine at all, which the loopback port cannot do.

Why the bookmarklet and the share target open a window

internal/api/api.go's sameOrigin middleware refuses any request that carries an Origin header not matching this instance's own host, so that no other page can drive a signed-in browser's session against the API. There is no bearer token a bookmarklet could hold instead of a session cookie. So neither of them calls /api/links from where it runs: each opens a small window at <this instance>/quickadd?..., same-origin, where the ordinary session cookie (and the ordinary sign-in screen, if the instance is password-locked) applies exactly as if you had typed the address in yourself. /quickadd is what actually stages the link.

This is also why neither needs a copy of the instance's password: they only ever need to know where the instance is, never a credential of their own.

The extension used to work the same way, and stopped: opening a window needs an address, and the extension no longer knows any.

Bookmarklet

Settings > Apps shows a link built from window.location.origin and the base path, whatever address you are looking at the settings page on. Drag it to your bookmarks bar. Clicking it on any page opens /quickadd with that page's URL and title, plus whatever text you had selected (useful for a page listing several links in prose, none of them individually a "download button").

The link is generated client-side (web/src/lib/browserTools.ts's buildBookmarklet), never server-rendered with a fixed address baked in at build time. A self-hosted app has no fixed address to bake in, and whichever address you used to reach the settings page is, by construction, one that already works for you.

Browser extension (Manifest V3)

Source lives in extension/src, embedded into the Go binary (extension/embed.go) and packaged on demand by GET /api/browser-extension.zip (internal/api/routes_browsertools.go). It is built against MV3 because MV2 is being retired across browsers; there is no MV2 fallback.

The zip a running instance serves is byte-identical to extension/src in the repository. It used to bake that instance's address into a config.default.json; the file is gone, because the extension holds no addresses at all any more. That also makes the store package reproducible: anyone can build it from a checkout and compare.

Setup is one connection phrase

The options page asks for the same twelve words the instances themselves are paired with (docs/connecting.md, internal/seedphrase) and for nothing else: no name, no address, no password. extension/src/phrase.js is a WebCrypto port of the Go derivation, so the browser derives the identical two keys: one that joins the relay group, one that encrypts the frames.

From there extension/src/relay.js is a relay client in the extension itself. The group roster is read live at the moment a window opens, which is why the popup has a loading state: an instance that is switched off is not offered, and one that came online a minute ago is, with nobody telling this browser anything. Sends go out as POST /api/links through the relay, admitted because membership in the group is the credential. The sameOrigin guard is not worked around; it is not on that path.

There are four context-menu entries (page, link, image, selection) and a toolbar popup, and both draw the group as instance cards, the same card the web UI's own Instances tab draws, with a Standard badge on the default and a right-click to move it. A card shows whether its instance is online and what its queue is doing, and links to its web interface. It has no start or stop button: running the queue belongs to the web interface, the app and the desktop tray, and the extension only hands links over.

Permissions: activeTab, contextMenus, storage (the phrase, a random browser ID, the default instance, the language, the appearance and whether to follow an instance's, the Click'n'Load switch and countdown, whether the pin hint was shown, and in session storage a send waiting for the popup), scripting and declarativeNetRequest. None of them asks for access to any website at install. activeTab is what lets the popup read the current tab's address and title, and a right-click send the page title: both are a user's click on the extension, which grants access to that one tab until it navigates. The relay is a WebSocket, which needs no host permission.

Two permissions are optional and asked for only when they are needed: clipboardRead for the paste button next to the phrase, and <all_urls> in optional_host_permissions, which scripting and declarativeNetRequest need for one feature only:

Click'n'Load, in the browser

cnl-main.js runs in the page's MAIN world and takes over every way a Click'n'Load button reaches the port: fetch, XHR, HTMLFormElement.submit and a capture-phase submit listener, navigator.sendBeacon, window.open (and, inside same-origin windows it opens, the fetch, XHR and form hooks), the src setter of iframe, image and script elements, and a capture-phase click on plain links. It decodes the payload with cnl.js (AES-128-CBC, key equals IV, both padding conventions found in the wild) and hands the links to the service worker, which relays them to the chosen instance with origin: 'cnl'. The page is answered success\r\n, exactly as a local JDownloader would answer it. Detection works because cnl-main.js sets window.jdownloader at document_start, before the site's jdcheck.js looks.

It needs access to every site, because a button can be on any of them, and the jdcheck.js redirect needs access to the page that asks as well as to the port. That access is optional (optional_host_permissions), so the install dialog names none. A fresh install stores Click'n'Load as wanted, and the options page it opens leads with a card whose Allow access button calls chrome.permissions.request. The switch on the Click'n'Load card does the same. The switch shows whether the scripts run, never only whether they are wanted: it cannot read "on" while the browser withholds the access. A refusal leaves it off with the reason underneath. The popup shows the same notice with a button to the options page, because Chrome's permission prompt closes the popup and the request with it.

The service worker applies the switch and the access together (applyCnl in background.js) on install, update, browser start, a change of the switch and permissions.onAdded/onRemoved, so access withdrawn in the browser's own settings switches the feature off as well. Switching it off on the options page also calls chrome.permissions.remove. chrome.scripting.unregisterContentScripts takes both scripts away, verifiable with chrome.scripting.getRegisteredContentScripts(), which is more than a static content_scripts entry could ever offer, and the cnl ruleset that answers jdcheck.js is switched off with it. The ruleset follows the scripts, not the flag: if registering fails, both go off, so a site never shows a button that nothing catches. That holds for pages opened or reloaded afterwards; a tab already open keeps its script until it reloads, and a submission caught there is dropped.

An update from a version that required <all_urls> keeps the grant in Chromium, because the permission is still in the manifest as an optional one. Where a browser drops it, the update opens the options page on the card that asks again.

This is the answer to the case a loopback port cannot serve: KnightLoader on a server, a browser on a laptop, and a CnL button on a website that only knows how to talk to 127.0.0.1:9666.

Selection Rules (pre-defining which instance a given file type goes to, the way MyJDownloader's extension can) is still not built. Choosing per send and setting a default are; a rule engine on top of that is real but niche, and nothing about this shape blocks it later.

PWA share target

web/public/manifest.webmanifest declares share_target pointing at quickadd beside the manifest, under the base path, with a plain GET (url/text/title become query parameters), the same shape the bookmarklet already uses, so there is exactly one page that knows how to turn a shared blob into a staged link. web/public/sw.js is an empty pass-through service worker. It exists only because most browsers gate the install prompt behind "has a fetch-handling service worker", and it caches nothing (see that file's own comment for why caching this app's assets a second time, next to internal/api/api.go's existing ETag scheme, would be a bug and not an optimisation).

Installing is what turns the share target on: an uninstalled tab has no Share-menu entry to offer. web/src/lib/pwaInstall.ts exports useInstallPrompt(), a small shared hook around the browser's beforeinstallprompt event. Settings > Apps uses it for the "Install" button on its phone card, the one caller, so the event is captured in one place.

What this deliberately does not do

There is no account, and no attempt to speak MyJDownloader's own vocabulary or protocol. The same ruling /api/help states for the API generally applies here. Sends reach the instances in your group through the project's relay (relay.halleluja.design), which forwards sealed messages it cannot read, and there is nothing to sign into.