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.