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 credential DDMM itself can hold is the user's optional Nexus Mods API key for update checks, which stays 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 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.
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. 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. 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), an allowed archive type by magic bytes, and ≤ 2 GiB. 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 a mod from the same source (provider + id) is already installed, the app performs an in-place update (keeping the
GUID and profile config, like "Update from …") instead of failing with a duplicate. 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.
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/downloadUrl) 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.
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 |
NOT_ARCHIVE |
File isn't a zip/7z/rar |
UNSAFE_ARCHIVE |
Archive failed path-traversal / symlink validation |
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 requests queue (up to 20), and beyond that they get BUSY.
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.
Security checklist¶
- TCP listener bound to loopback only, random port, per-run 256-bit token, constant-time token compare.
bridge.jsonis owner-only, deleted on exit.- Host mode never opens a GUI and only relays. Message size limits are enforced both ways.
- App-side origin allowlist (the extension IDs above).
- Per-site "Always allow" consent for extension installs.
ddmm://always confirms. - 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.