Browser bridge protocol (v1)¶
This page is the contract between the DDMM browser extension, the native messaging host, and the
running DDMM app. It also defines the ddmm:// link format. Both sides are implemented against this document.
If you change the protocol, change this page in the same pull request and bump protocol if it's incompatible.
Goals¶
- One click from any mod site. AyakaMods, Nexus Mods, ModWorkshop, GameBanana, GitHub and any other site are equal. No site is special-cased in a way that makes another site second-class.
- The browser downloads, DDMM installs. Login-gated downloads work because the browser fetches the file with the user's own session. No credentials, cookies or API keys for any site cross the bridge. (The only credentials DDMM itself can hold are the user's optional Nexus Mods API key and, where signing in is available, Nexus Mods sign-in tokens, both only for update checks and both staying inside the app.)
- Web pages can't drive DDMM. Only the extension (via native messaging) can hand DDMM a file.
ddmm://links, which any page can trigger, always require explicit confirmation in DDMM.
Components¶
mod site page ──(content script button / context menu / download capture)──► extension (background)
│ chrome.runtime.connectNative
▼
native messaging host = ddmm executable in host mode
│ 127.0.0.1 TCP + token
▼
running DDMM app
Identifiers¶
| What | Value |
|---|---|
| Native messaging host name | io.github.katsyk.ddmm |
Chrome/Edge/Brave extension ID (unpacked, from the manifest key) |
inomhciahaeeefhgdkiaabdponcfdane |
Firefox extension ID (browser_specific_settings.gecko.id) |
ddmm@katsyk.github.io |
| Deep-link scheme | ddmm:// |
| Protocol version | 1 |
A Chrome Web Store ID will be added to allowed_origins once the extension is published there.
Native messaging host¶
The host is the same ddmm executable started in host mode. The browser launches it with:
- Chromium browsers: first argument
chrome-extension://<id>/(Windows may add--parent-window=<n>). - Firefox: arguments
<path to host manifest> <extension id>.
ddmm enters host mode when argv matches either shape, and must then never open a window. It speaks standard native
messaging on stdin/stdout: each message is a 32-bit native-endian (little-endian on all supported targets) length
followed by UTF-8 JSON. Messages from the host to the browser must be ≤ 1 MB; messages from the browser are capped at
64 MB by the browser but DDMM rejects anything > 1 MB. The loopback connection has the same limits: the app rejects a
request line over 1 MB (plus room for the added origin) with BAD_REQUEST, and the host drops a reply line over
1 MB (its request then times out in the extension) instead of passing on something the browser would refuse.
The host is a relay. It validates framing, forwards each JSON message to the running app, and writes back each
reply. It adds the caller origin (chrome-extension://… or the Firefox ID) as origin to every forwarded request, and
the app rejects origins not in its allowlist.
The relay is full duplex: it forwards each request as soon as it arrives, without waiting for earlier replies, and
passes each reply back as soon as the app sends it, matched to its request by id. The app handles the requests of
one connection concurrently too. So a request that waits on the user (an install at its consent prompt, up to
2 minutes, or waiting up to 60 s for DDMM's window to be ready) never delays the hello/query/status the extension
sends meanwhile, which have a 10 s timeout. Replies can therefore arrive in a different order than their requests,
and requests sent without waiting for an earlier reply may reach the app in any order. At most 64 requests may be
in progress through one host (32 per app connection); beyond that a request gets BUSY at once. The host gives up on
a request DDMM hasn't answered after a little longer than the extension's own timeout for it (5½ min for install,
90 s for open, 30 s otherwise) and replies INTERNAL, so an unanswered request never holds its place forever.
Reaching the running app¶
When DDMM starts it listens on 127.0.0.1:<random free port> (never 0.0.0.0) and writes
<data folder>/bridge.json:
The file is created with owner-only permissions (0600 on Unix, and inherited per-user ACLs under the user profile on Windows) and deleted on clean exit. The token is regenerated on every start.
The host resolves the data folder with the same logic as the app (a ddmm-data-location.json pointer, the
portable marker, app-data; see Data folder), reads
bridge.json, connects, and sends {"hello": "<token>"} followed by a newline. A bridge.json whose pid is no
longer running is stale (DDMM crashed) and is ignored without connecting. Connecting plus the handshake must finish
within 5 s. On the app side the handshake line must be at most 256 bytes and arrive within 5 s, or the connection is
dropped; nothing else is read from a connection before its token checks out. It resolves the folder again for every
(re)connect, so a host the browser kept alive finds the app after the user moves the data folder (the app restarts
after a move and writes a fresh bridge.json in the new folder). The host never creates the data folder. The app replies {"ok": true} and closes the connection
on a wrong token. After that, both sides exchange newline-delimited JSON (one object per line).
If bridge.json is missing, stale (connection refused), or its pid is gone, DDMM isn't running. What happens next
depends on the request:
installandopenmay start DDMM. Both are explicit user actions (a click on "Install with DDMM", or "Start DDMM" in the extension popup). The host starts DDMM normally (a detached process, so the GUI appears) and polls for a freshbridge.jsonfor up to 45 s before replying withAPP_NOT_RUNNING. It won't launch DDMM again within 60 s of a previous launch, so a slow start isn't turned into several.hello,queryandstatusnever start DDMM. The extension sends them just because a mod page loaded, and browsing a mod site must never pop DDMM open. When DDMM isn't running they getAPP_NOT_RUNNINGimmediately: no launch, no polling.
The browser can keep one host process alive for much longer than one DDMM session. The host therefore checks the
connection before relaying each request and, if DDMM has exited since, reconnects the same way (reading a fresh
bridge.json, and starting DDMM only for install/open). It never resends a request the app may already have
received; a connection lost mid-request gets APP_NOT_RUNNING for that request only.
Client-side timeouts (extension)¶
| Request | Timeout | Why |
|---|---|---|
hello, query, status |
10 s | Never launch DDMM, so they answer quickly either way |
open |
60 s | May be starting DDMM (host waits up to 45 s) |
install |
5 min | May be starting DDMM, then the user may still be answering the consent prompt |
The extension tells two failures apart. If the native host itself can't be started (the browser disconnects
with "native messaging host not found" / "forbidden", meaning DDMM isn't installed or isn't set up for this
browser), the button reads Get DDMM and links to the install docs. If the host answers but DDMM isn't running
(APP_NOT_RUNNING), the button still reads Install with DDMM with the hint "DDMM will start". Clicking it sends
the install, which starts DDMM, and shows Starting DDMM… until the result arrives. In that state the extension
doesn't know whether the mod is installed, and never guesses.
Messages¶
Every request has a string id chosen by the extension. Every reply echoes the id, which is how replies are matched to
requests (they can arrive in any order, see Native messaging host). An id must be unique
among the requests still in progress; a request reusing one gets BAD_REQUEST. Unknown type → error
UNSUPPORTED. Unknown fields are ignored (forward compatibility).
hello¶
Reply:
{ "id": "1", "ok": true, "type": "hello", "appVersion": "2.0.0-rc.4", "protocol": 1,
"afterInstall": "deploy", "gameFound": true }
install¶
The extension sends this after a download has completed in the browser.
{ "id": "2", "type": "install",
"file": "C:\\Users\\me\\Downloads\\Cool Mod-4084-1-0.zip",
"pageUrl": "https://ayakamods.com/mods/cool-mod.4084/",
"downloadUrl": "https://ayakamods.com/mods/cool-mod.4084/download",
"pageVersion": "1.0",
"afterInstall": null }
file: absolute path of the finished download. The app requires a regular file (not a symlink), ≤ 2 GiB, that is a zip, 7z or rar by the same detection every other install route uses (Add, Add URL, the Downloads-folder handoff): by its content (magic bytes), never by its name alone. The one case where the name counts is a file named.zipthat starts with something else (zero padding, a self-extractor stub, data an uploader prepended): the zip reader finds a zip from the end of the file, so it is accepted if it really is one. The app never deletes or moves it.pageUrl: the mod page the user was on (the source of truth forSources; parsed with the same rules assource_from_page_url). It may be null for context-menu installs from arbitrary pages; thendownloadUrlhost detection is used.pageVersion: optional version scraped from the page (for example AyakaMods JSON-LDsoftwareVersion), recorded in the origin sidecar.afterInstall:nullmeans "use the app setting". Otherwise one of"library","profile"or"deploy".
If the installed mod that this file is a new version of came from the same source (provider + id), the app performs an
in-place update (like "Update from …") instead of failing with a duplicate. The mod keeps its GUID, and so its
profile entries (enabled state, options, position), only when the new archive has no manifest.json of its own. If the
new archive ships a manifest with its own Guid, that GUID replaces the old one and profile entries that point at the
old GUID no longer match (they are not migrated; the Mods page shows them as missing). Because an update replaces an
installed mod's files, the app only does it when the id came from a recognized mod-page pageUrl. A source derived
from downloadUrl host detection never has an id and always installs as a new mod.
When the file isn't such an update but its manifest.json has the GUID of an installed mod, the app asks the user on
the Mods page whether to replace that mod with it (the same question as for Add; see
adding mods), waiting up to 2 minutes, and never past the point where
the extension would have given up (its 5-minute install timeout, counted from when the request arrived, minus 2 minutes
for the install and the afterInstall step); with too little time left it doesn't ask. Replace swaps the file in
place of that mod, keeping its GUID and so its profile entries; the reply is then "updated": true. Cancel (or no
answer) installs nothing and replies DECLINED. If the Mods page can't show the question, the install fails as
already installed (INTERNAL), as before.
One mod page can offer several files that are installed side by side (GameBanana variants, a Nexus Mods main file and
its optional files), so sharing a source isn't enough: the app records which file each mod was installed from
(InstalledFiles in its origin sidecar) and updates a mod only with the same file:
- Nexus Mods: the archive names (both of Nexus's naming schemes) have the same mod id and the same file name; the
version and upload time may differ. The names are read against the page's mod id, so a version made of long
numbers (
Cool Mod-456-2024-06-01-….zip) isn't mistaken for another mod's id. - Other sites: the names have the same "shape": the extension, a browser's
(1), GameBanana's upload suffix and version-shaped parts (v2,1.2.0,1-2-0, a date) are removed, and everything else counts, numbers included. Socool_mod_v2_ff3bb.zipupdates the mod installed fromcool_mod_v1_ab12c.zip, butcool_mod_red_….zipnever replacescool_mod_blue_….zip, norarmor_4k_….zipreplacesarmor_2k_….zip(or1440p1080p, orOption 2Option 1). When in doubt, the file installs as a new mod.
Any other file from that page installs as a new mod. The one exception is a mod installed before DDMM recorded its file: when it's the only mod from that page, it's updated in place as before (there's nothing to tell files apart by), unless the file's Nexus archive name says it's from a different Nexus mod. Such a file (a requirement linked from the page, say) is never an update of the page's mod: it installs as a new mod, recorded as the mod it really is.
For a GitHub release asset, the version recorded is the release tag named in downloadUrl
(https://github.com/<owner>/<repo>/releases/download/<tag>/<file>), the same rule Add URL uses, not whatever the
latest release is.
Which pageUrl the extension sends. The page the user is on is only context, since a link on mod A's page can be
mod B's file. The extension therefore attributes every download to the mod the file's URL names:
A URL "names" a mod when it's a mod page URL, or a Nexus Mods file URL
(https://<host>.nexusmods.com/cdn/<gameId>/<modId>/<file> or https://<host>.nexus-cdn.com/<gameId>/<modId>/<file>),
whose mod id is in its path. A Helldivers 2 file (gameId 6119) names https://www.nexusmods.com/helldivers2/mods/<modId>;
a file of another game names a mod with no page.
- The download/link URL names the page's own mod: the page URL, plus the page's scraped
pageVersion. - The download/link URL names some other mod: that mod's page URL (or
nullwhen it has none), with nopageVersion. - The download/link URL names no mod (CDN, bare file):
- right-click installs send
pageUrl: null, so the app falls back to host detection with no id; - armed/auto capture send the page the download was started from, with no
pageVersion.
Capture doesn't depend on how a download starts. Armed capture (the button on a site with no direct file link)
takes the next archive the browser downloads from that site's hosts (its pages, or its CDN hosts) within 10 minutes,
whichever tab it's in and whatever started it: the site's own button, a download manager or a userscript. The extension
never clicks, skips or times anything on the site itself. A download that finished up to 5 minutes before the click is
also taken, but only when it provably belongs to the page's mod (its URL names that mod, or it names no mod and its
referrer is that mod's page). The armed state is kept in storage.session, so a restarted MV3 service worker keeps it.
downloadUrl is the URL the user chose (the right-clicked link or the page's download link).
Permission check (app side): the first time a site (registrable domain of pageUrl, else downloadUrl; only an
http(s) URL names a site) sends an install, DDMM asks: "Allow the DDMM browser extension to install mods from
ayakamods.com?" with Always allow, Just this once and Deny. "Always allow" is stored in settings
(BridgeAllowedSites) and can be revoked in Settings. The prompt is skipped for allowed sites; that's what makes it one
click. An install with no http(s) URL at all has no site that could have been allowed, so it always gets the prompt
(naming the file, with only Just this once and Deny).
While DDMM installs a file for the extension, and for 10 minutes after, the opt-in auto-import from the Downloads folder doesn't offer that same file.
Reply on success:
{ "id": "2", "ok": true, "type": "installed",
"mod": { "guid": "…", "name": "Cool Mod", "source": { "provider": "ayakamods", "id": "4084" }, "version": "1.0" },
"updated": false, "addedToProfile": "Default", "deployed": true, "warnings": [] }
query¶
Lets the extension label its button ("Install with DDMM" / "Installed ✓" / "Update with DDMM").
{ "id": "3", "type": "query", "pageUrl": "https://ayakamods.com/mods/cool-mod.4084/", "pageVersion": "1.1" }
{ "id": "3", "ok": true, "type": "queryResult", "installed": true,
"mod": { "guid": "…", "name": "Cool Mod", "installedVersion": "1.0" }, "updateAvailable": true }
updateAvailable compares pageVersion with the installed version (the same comparison as DDMM's update checks:
a leading v and -/. differences are ignored, and a numerically older page version isn't an update). When
the page has no version (for example Nexus Mods pages), it is true if DDMM's own most recent update check this
session found an update for this mod from this site, so the extension can show "Update with DDMM" there too.
Otherwise it is null when either version is unknown. It is never guessed.
status¶
Reply: { "id": "4", "ok": true, "type": "status", "gameFound": true, "activeProfile": "Default", "modCount": 12, "busy": false }
open¶
Brings DDMM's window to the front, starting DDMM first if it isn't running (the popup's "Start DDMM" button). It installs nothing, so there is no consent prompt; the origin allowlist still applies.
Reply:{ "id": "5", "ok": true, "type": "opened" }
Errors¶
{ "id": "2", "ok": false, "error": { "code": "DECLINED", "message": "You chose not to install this mod." } }
| Code | Meaning |
|---|---|
APP_NOT_RUNNING |
DDMM isn't running (hello/query/status), or the host couldn't start or reach it within 45 s (install/open) |
BAD_REQUEST |
Malformed message / missing field / message too large |
UNSUPPORTED |
Unknown message type or protocol version |
FORBIDDEN_ORIGIN |
Caller isn't an allowed extension |
DECLINED |
The user answered Deny / closed the prompt, or kept the installed mod when asked whether to replace it |
NOT_ARCHIVE |
File isn't a zip/7z/rar |
UNSAFE_ARCHIVE |
Archive failed a pre-install check: path traversal / symlink validation, a file name Windows can't use, or the size / entry-count limits |
FILE_NOT_FOUND |
file doesn't exist or isn't a regular file |
GAME_NOT_FOUND |
afterInstall: deploy but the game path isn't set/valid (mod is still installed) |
DEPLOY_FAILED |
Installed, but deploy failed (mod is still installed) |
BUSY |
Another install/deploy is running; retry |
INTERNAL |
Anything else (message says what) |
Installs are serialized in the app; concurrent installs queue (up to 20), and beyond that they get BUSY. Other
requests are answered while installs wait.
ddmm:// links¶
Any web page (a mod site, a mod author's page, a Discord message) can link:
url is either a mod page (handled like Add URL: browser handoff for login-gated sites) or a direct https download.
DDMM always shows a confirmation ("A link wants to install a mod from example.com. Install?") with no
"always allow". Only https URLs are accepted. Anything else in the link is ignored. ddmm://open just focuses DDMM.
Deep links reach an already-running DDMM through the single-instance mechanism. A second window is never opened.
A link that starts DDMM arrives before the window has loaded, and the Mods page may not be showing when one arrives (Settings, or the data folder recovery screen). So the app queues install links, and the Mods page takes them and shows the confirmation once it's ready. Each link is shown once. The same link arriving again while it's still waiting, or while its confirmation or install is still in progress, is ignored. Links that arrive on the recovery screen are passed on to the restart that follows it. At most 16 links wait at once (more are ignored), a link whose target is longer than 2048 characters is ignored, and the restart passes on only as many links as fit in 8 KB. If the restart fails with links, it's retried once without them.
Security checklist¶
- TCP listener bound to loopback only, random port, per-run 256-bit token, constant-time token compare. Nothing is handled on a connection before its token handshake succeeds.
bridge.jsonis owner-only, deleted on exit.- Host mode never opens a GUI and only relays. Message size limits are enforced both ways, on stdin/stdout and on the loopback connection, and the number of requests in progress is capped.
- App-side origin allowlist (the extension IDs above).
- Per-site "Always allow" consent for extension installs; an install with no
http(s)URL is always confirmed.ddmm://always confirms. - Consent prompts and afterInstall reports are matched to their install by a fresh key the app generates for each,
never by the extension's request
id(which restarts when the browser restarts the extension), so a late answer can't resolve a different install. - Content-script buttons act only on trusted clicks (
event.isTrusted), so a page can't script-click them. - Every install still goes through archive validation (paths, symlinks, magic bytes, size cap).
- No credentials, cookies or tokens for any mod site ever cross the bridge.