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.