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, and only once Add is pressed: it fills in what it was handed and waits, because any website can open that same address in a signed-in browser.

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.

Chrome, Brave, Opera and Vivaldi install it from the Chrome Web Store, Edge from Edge Add-ons, Firefox from Firefox Add-ons, and the buttons on Settings > Apps open those listings. The zip stays for a browser without a listing and for testing a build.

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 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, declarativeNetRequest, and offscreen and alarms for the clipboard watch below. The phrase is kept apart from these, in the extension's own IndexedDB, because the Click'n'Load content scripts can read storage.local. None of the permissions 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 for the clipboard watch, and <all_urls> in optional_host_permissions, which scripting and declarativeNetRequest need for one feature only:

The clipboard watch

Switched on in the options, the extension reads the clipboard about once a second and sends the links in a newly copied text to the default instance, the way the web interface's watch does from its own tab. Both use one link rule (extension/check-clipwatch.mjs keeps them alike), and only the links leave the browser. Chromium reads in an offscreen document, since a service worker has no document; Firefox reads in its background page and wakes it with an alarm after it unloads the page.

Every watcher in a group, the desktop app's, a web interface tab's and the extension's, renews a three-minute lease with the instance it sends to (PUT /api/clipboard-watchers/{id}, internal/clipwatch). A page whose links go to a peer of the instance serving it renews there, through /api/instances/{name}/clipboard-watchers/{id}. Switching a watch on asks for the group's list (GET /api/clipboard-watchers, which asks every member and every instance added by address for its own), and when another device already watches, a window names it and offers to switch it off there (POST /api/clipboard-watchers/{id}/stop, sent to all of them at once) or keep both. A stopped watcher hears it on its next renewal, even if its instance restarted in between. Keeping both sends each link twice, and an instance turns away a link it already has. The web interface keeps its switch in the browser, so two browsers on one instance each have their own.

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.

Taking over downloads and finding media

Both are off until switched on in the options, and each asks for its optional permissions at that moment (capture.js): taking over downloads wants downloads, cookies, notifications and the access to all websites, finding media wants webRequest, cookies and the same access. Switching one off hands back whatever neither of the others still uses, Click'n'Load included.

Taking over downloads (takeover.js). Chromium names a download before it writes it (downloads.onDeterminingFilename), so the download is held at that step; Firefox has no such event, so its download is paused right after downloads.onCreated. The rules decide first: file types (empty means all), a minimum size (a size the browser does not know yet never passes one), sites to leave alone, and a key that, held while clicking, keeps the download in the browser. bypass.js, registered only while the feature is on, reports such a click. Downloads from loopback, private and .local addresses, from a private window, and from one of the group's own web addresses always stay in the browser.

A matching download goes to the default instance as POST /api/links with headers (the browser's cookies for that address, the Referer, the user agent), source, and file with the name the browser gave it. That marks the link as a file, so the instance fetches it as one even when its address has no file extension, rather than handing it to yt-dlp as a page, and saves it under the browser's name. If a file of that name is already in the folder, your setting for that case applies (overwrite, skip or number it), as for any other download. Only after the instance answers with the created task is the browser's download cancelled and erased; any failure, including a link the instance already had or its filter held back, lets it carry on. A notification names the instance.

Finding media (media.js, popup-media.js) watches responses with webRequest.onResponseStarted and keeps, per tab in storage.session, the HLS and DASH playlists and the files a player element loaded. Fragments (.ts, .m4s) and byte ranges a script fetches are left out. The popup lists them, and each send carries the page as source and Referer, plus the cookies for the stream's address; yt-dlp on the instance does the rest.

On the instance the headers are checked and scoped by app.BrowserHeaders: only Cookie, Referer and User-Agent, only with a single link, no control characters, and bound to that link's origin through hostheaders.Set, which prints header names only. They live in memory beside the task, never in the task record or the store, and go when the download finishes or the task is removed; a restart drops them. A plain file goes through the header-profile preflight, which follows redirects itself and strips the headers on any hop off the origin. yt-dlp gets the cookies in a cookie file scoped to the host, and the Referer and user agent as --add-header. Such a link is staged as it is: no crawl and no playlist listing, which without the browser's session would only see a login page.

Limits: a download the browser started with a POST cannot be fetched again by address; a one-time link the browser already used is spent; partitioned and first-party-isolated cookies are not read; and a stream whose address carries a short-lived token has to be sent while it is still valid.

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.

The download button on videos

On YouTube, Vimeo, Dailymotion, Twitch and Bilibili, and on any other site whose video yt-dlp can download, a small button sits at the top of the player and sends the video being watched to the default instance with one click, the way a desktop download manager offers it. video.js holds the sites, the address each page sends and the switch. video-worker.js registers video-button.js with scripting.registerContentScripts on those five sites, or on every page while all other sites are on, in every frame so embedded players get it too, and brings it into tabs that were already open.

The button sends the one address a video goes by, whatever form the page came in: a YouTube Short, live page or embedded player sends https://www.youtube.com/watch?v=<id>, and the playlist goes only when the menu asks for it. The worker works the address out again from the page's own, so a button can only send the video it shows. It goes as POST /api/links, with a format when the menu picked one: video for the video row alone at its best, audio for the audio row alone, or a resolution such as 1080p. That stands in for the host's preset for this link (HosterPreset.ForLink), and the preset itself stays as it was. The send also carries "resolver": "ytdlp", which pins every row of the link to yt-dlp, as a pin set on a task does (POST /api/tasks/options). The link skips the crawl and goes to yt-dlp even on a host whose links the instance gives to another backend, such as archive.org, which JDownloader has a plugin for. Links pasted or sent any other way keep their routing. resolver takes any backend the instance has, and one it lacks refuses the submission.

The menu lists only what the video has. Opened, it asks the instance with GET /api/ytdlp/link?url=<address>&pinned=true, which answers from one yt-dlp probe that the link's staging shares a moment later, and says it is checking until the answer comes. It then offers the best video, each resolution yt-dlp found and the audio alone where the video has sound, or the best video alone where the instance could not say. A resolution sent from a menu that is out of date becomes the one the video has that downloads the same track. pinned=true asks as for a link pinned to yt-dlp, the way the button sends it. Without it the instance answers only for links it would give to yt-dlp itself, and refuses the rest with 400.

The button anchors on the largest video on screen and on the element in fullscreen, never on a site's class names, and steps down from the top edge past any link or button the player draws there. It follows a site's navigation from video to video, shows while the pointer moves over the video, and keeps its clicks and keys from reaching the player. It lives in a closed shadow root and wears the extension's own look.

On every other site the button waits for a video that somebody is watching: at least 200 by 112 pixels, half of it on screen and started. A muted video without controls that starts or loops by itself is a background or an advert and does not count. The button then asks the instance once with the same GET /api/ytdlp/link and shows only if yt-dlp found a video track or a resolution there. The worker keeps that answer by origin and path in session storage, a no for ten minutes and a yes until the browser closes, so a page is asked once, and the menu opens on the offer the check brought back. Until then the script asks the worker nothing, so a page without such a video costs no message at all. A click sends the page's own address without its fragment, with the defaults the options set for all other sites.

The options show the sites in a card of their own, each as a row with its icon, its switch and a line on what a click downloads, and the row opens a window with that site's choices. Sites can be added there by address. An added site has a switch of its own, listed under its host name in videoButtonHidden, and runs on the access to that host and every host under it, so it keeps the button with all other sites off. It keeps defaults of its own under the same name (videoCustomSites, videoDefaults, videoSeen), which the button and the worker look up for that host and every host under it before falling back to all other sites (videoDefaultsKey).

It is on by default. On the five sites it needs access to those sites only, which the access to all websites for Click'n'Load includes. All other sites run on the access to all websites itself: where Click'n'Load already has it they are on at once, and otherwise their own switch asks for it, so a no there keeps the button on the five sites. Turning Click'n'Load off keeps that access while the button uses it. The button's switch is in the options and in the toolbar popup, both on one stored setting, and the options switch it off per site. The button's own menu can hide it on the site it is on. Switching off takes it out of open tabs at once.

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 (parleyport.halleluja.design), which forwards sealed messages it cannot read, and there is nothing to sign into.