Skip to content

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:

{ "port": 51234, "token": "<32 random bytes, hex>", "pid": 4242, "protocol": 1 }

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:

  • install and open may 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 fresh bridge.json for up to 45 s before replying with APP_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, query and status never 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 get APP_NOT_RUNNING immediately: 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

{ "id": "1", "type": "hello", "extensionVersion": "1.0.0", "browser": "chrome" }
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 .zip that 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 for Sources; parsed with the same rules as source_from_page_url). It may be null for context-menu installs from arbitrary pages; then downloadUrl host detection is used.
  • pageVersion: optional version scraped from the page (for example AyakaMods JSON-LD softwareVersion), recorded in the origin sidecar.
  • afterInstall: null means "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. So cool_mod_v2_ff3bb.zip updates the mod installed from cool_mod_v1_ab12c.zip, but cool_mod_red_….zip never replaces cool_mod_blue_….zip, nor armor_4k_….zip replaces armor_2k_….zip (or 1440p 1080p, or Option 2 Option 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 null when it has none), with no pageVersion.
  • 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" }
Reply:
{ "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

{ "id": "4", "type": "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.

{ "id": "5", "type": "open" }
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.

Any web page (a mod site, a mod author's page, a Discord message) can link:

ddmm://install?url=<percent-encoded https URL>

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.json is 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.