DropQTT 🚀
The MQTT workbench — file transfer, console, bridge, history and ops diagnostics
中文 README · English
DropQTT is a lightweight (~10–15 MB) desktop MQTT workbench built with Tauri v2, Rust (rumqttc), and React + Tailwind CSS. It began as verifiable file transfer across isolated network environments where standard HTTP uploads, P2P, SCP, or cloud storage are blocked, but standard MQTT broker connectivity (port 1883, 8883 TLS, or WebSocket) is permitted. It is now five workspaces — File Transfer, MQTT Console, Data Bridge, Message History, and Ops & Diagnostics — held together by one rule: a refusal, a drop, or a stall is stated out loud rather than hidden behind a green light.
🌟 Key Features
- ⚡ Lightweight Footprint: Packaged with Tauri v2 instead of Electron, yielding an installation size under 15MB and low memory consumption.
- 🧩 Dynamic Chunking & Reassembly: Conquers broker packet limits by breaking files into customizable chunks (64 KB to 2 MB) with streaming flow control.
- 🛡️ Bit-Perfect SHA-256 Verification: End-to-end cryptographic hashing ensures files are received without corrupted or missing packets.
- 🌐 Ubiquitous Broker Support: Works out of the box with public brokers (EMQX, HiveMQ, Mosquitto) or any private self-hosted MQTT v3.1.1/5.0 broker with optional TLS encryption and authentication.
- 🤝 MQTT 5.0 Full Support: Per-connection protocol selection, clean start semantics, and v5 publish properties (Message Expiry, Content-Type, User Properties) on console publishing.
- 📡 MQTTX-style Pub/Sub Console: Live message feed with Auto/JSON/SenML/Text/Markdown/HTML/CBOR/Base64/Hex payload views (dependency-free RFC 8949 codec; RFC 8428 SenML reader for both the JSON and CBOR encodings, resolving base names, inherited units and the 2^28 absolute-vs-relative time rule into an aligned reading table; collapsible syntax-colored JSON tree; DOMPurify-sanitized Markdown & fully-sandboxed HTML previews), a user payload codec (
function transform(topic, payload, qos, retain)running in the same sandboxed QuickJS engine as the bridge, display-only so history/export/replay keep the exact bytes), a live-preview split editor with byte counters and draft persistence, feed pause/buffer, per-filter subscription hit statistics, JSON/CSV message export, one-click message replay (verbatim topic/payload/QoS/retain/v5 properties) and click-to-subscribe topic badges, a retained-message clearer, MQTT5 subscription options (No Local / Retain As Published / Retain Handling) that survive reconnects, plus a subscription registry that is automatically re-applied on every reconnect. - 🔁 Resilient Transfers: NACK-driven missing-chunk retransmission, zombie-task watchdogs, and graceful CONNACK-driven subscription recovery.
- 🧭 Transfer Lifecycle Controls: Sender-originated PAUSE / RESUME / CANCEL controls now propagate to the receiver, suspend watchdog timeouts while paused, and produce an immediate terminal state.
- 🛑 Silence Watchdog: alert rules that POST to a webhook when a topic filter stops carrying traffic — the "has this device gone quiet?" question a normal MQTT client cannot answer. Per-rule silence threshold and cooldown, wildcard filters, and recovery-aware suppression (a device that reports again clears its own timer, so a flapping node keeps alerting instead of being swallowed by an open cooldown). Timers are tracked per rule, never in the LRU-evicted topic table, and are disarmed while the link is down so your own reconnect is never blamed on the device.
- 🔀 Data Bridge (Broker → Broker and Broker → HTTP): Run two independent bridge connections and forward messages by topic rules. Multi-line source filters subscribe many topics with one rule; exclusions carve out sub-trees; topic rewrites support keep / prefix / fixed-aggregate / regex-capture / per-topic mapping tables; payloads pass verbatim or through prefix-suffix tags, JSON envelopes, and a sandboxed embedded-JavaScript transform (
function transform(topic, payload, qos, retain)with 100 ms / 4 MB limits, null-to-drop semantics and an in-form dry-run tester). A rule can target an HTTP/Webhook endpoint instead of a broker — JSON envelope or raw body, custom headers, and three starter recipes (telemetry → business API, threshold alert, edge-site aggregation). Per-rule rate limiting, forwarded/dropped counters, a live forward log, rules export/import as JSON, and auto-reconnect that restores remembered endpoints and re-applies changed QoS on CONNACK. - ✅ Receive Approval Mode: Toggle auto-accept off to gate incoming files behind an explicit Approve/Reject review after SHA-256 verification.
- 🎨 Themeable UI: Four design-token driven themes (Cyberpunk, OLED Obsidian, Nord, Solaris light) with CSS custom properties, plus 4-language i18n (简中/English/繁中/日本語).
- 🚪 Room / Channel Isolation: Share files simply by agreeing on a channel code (e.g.
#my-secure-room). - 🔥 Live Topic Traffic & Bench Lab: Per-actual-topic traffic ranking (msgs/sec, byte volume, peak rate, last-active) with second-accurate counters that stay exact under load, a hot-topic flame highlight, and a built-in publish stress lab (up to 20k msg/sec) that loops back through your own subscriptions to verify stat accuracy and UI responsiveness.
- 🚦 Overload-Proof Console Feed: The backend batches the message feed at ~10 Hz (200 msgs/emit) with counted overflow drops — thousands of msgs/sec never flood the webview, and the UI shows a red notice when display rows were dropped while stats remain precise.
- 📈 Field Value Forensics: give it a path like
temp.corv[2]and see when that field changed, what it was before, and how long it has been that way — the half of "compare two messages" that no MQTT GUI ships. Non-JSON payloads, rows missing the field and rows stored truncated are each counted on screen, and the series breaks at a silence gap instead of drawing a flat line through an hour of nothing. Paths discovered by the comparison card are offered as one-click follow-ups. - 📋 Delivery Audit: per topic, from a sequence number in the payload — holes, duplicates, reordering and p50/p95/max latency against a publisher send stamp. A hole is reported as "not in what we stored", never as proof the broker dropped it, and unreadable rows inside the observed span are counted separately because they may be the row whose sequence we could not read. Negative latency is filed as clock skew rather than averaged into p95.
- 🔍 Message Compare / Diff: pick any two stored rows and get the field that actually moved — JSON is walked to paths like
temp.corv[2], text is diffed line-by-line (LCS after trimming agreed head and tail), binary payloads are compared as whole bytes; carrier deltas (topic, direction, QoS, retain, length, time gap) are listed separately. What it cannot do is admitted rather than hidden: depth and change-count caps, a diff too large to compute line-by-line, and — the important one — a row stored truncated never gets to claim the two messages matched, because only their stored prefixes do. - 🗂️ Searchable Message History: Every console-feed row is mirrored to SQLite (bounded at 100k rows) so traffic stays inspectable after the live feed scrolls away. Free-text search over topic and payload, direction filter, 5m/15m/1h/24h/All time windows, and a trend chart whose buckets use exactly the same predicates as the result list. Filtered results export to JSON/CSV, payloads inspect as Text/JSON/Hex/Base64/CBOR, and MQTT5 properties (response topic, correlation data, user properties) survive the round-trip to replay. Rows whose stored bytes are incomplete — including captures written by older versions — are detected and blocked from replay rather than silently re-published truncated.
- 🧟 Retained-Value Lineage, and the Zombie It Finds: a broker hands every new subscriber the retained value it currently holds and never says how old that value is, so a config pushed once three years ago still arrives looking brand new. Every retained publish this app records is one replacement of a topic's value, so history is read back as a lineage: the current value, how many versions were seen, when it last changed, whether it was deleted (an empty retained publish is a deletion in MQTT, not an empty value, and it cannot go stale), and which topics are past a staleness bar you choose — with the zombies sorted first. The bound is stated rather than implied: only changes seen while connected are counted, a backend that never answered is never rendered as "nothing retained", and a value stored truncated is labelled a prefix, not the whole thing.
- 🩺 Operations Diagnostics Center: A dedicated Ops workspace reports runtime/platform metadata, broker connection state, transfer activity, feed pressure, SQLite history status and bridge health. Active checks cover download-directory writability, history availability, TLS posture, subscriptions and overload indicators; the sanitized report can be copied or exported without passwords, usernames or certificate paths.
- 🔐 TLS Material Forensics: point the settings pane at your CA bundle, client cert and key and it reads back what those files actually are — validity window with an explicit
expired/expiring_soon/not_yet_validverdict, subject and issuer, SAN names, and whether the host you are about to connect to matches any of them. It catches the mistakes that otherwise only surface as a bare “cannot connect”: a leaf in the CA slot, a private key in the certificate slot, and the classic trap where the subject CN matches but the SANs do not (rustls ignores CN entirely once SANs exist). Chain verification stays rustls' job at connect time; this only stops you from discovering a wrong file selection by watching a handshake fail. - 🔐 Broker Credentials in the OS Keyring: The connect password goes to Windows Credential Manager / macOS Keychain / Secret Service and local settings keep only a random reference — so the app's storage folder, a support report and an exported environment bundle all stay free of it. There is no path that reads a stored password back into the UI: the reference is resolved where the socket is opened. A reference with nothing behind it fails the connect with an explicit "re-enter it" instead of quietly going anonymous, and a machine with no usable credential store says so on the settings field rather than pretending.
- ♿ Keyboard & Responsive Polish: Fluid container-driven layouts work at the default window size, dialogs trap and restore focus, key interactive surfaces are keyboard reachable, document language follows the selected locale, decorative motion respects
prefers-reduced-motion, and code surfaces use theme tokens. - 🎨 Pristine Modern UI & Brand Identity: Designed with the
app-logo-design-engineskill, featuring an origami vector mark rendered via native Swift + CoreGraphics producing true 32-bit RGBA (ColorType 6) icons without white squircle borders. - 📦 Cross-Platform Matrix: Builds native installers for macOS (
.dmg), Linux (.deb,.AppImage), and Windows (.msi, NSIS.exe).
📐 MQTT Transfer Protocol Specification
dropqtt/
└── {channel}/
├── meta <- File metadata & SHA-256 (QoS 1)
├── chunk/{transferId}/{seq} <- Binary raw chunk payloads
└── ctrl/{transferId} <- Transfer control: ACK, PAUSE, RESUME, CANCEL, COMPLETED
- Handshake (
meta): The sender computes the full-file SHA-256 and broadcasts file metadata (transferId,fileName,fileSize,chunkSize,totalChunks,sha256). - Chunk Streaming (
chunk): The sender streams binary chunks to sequence-addressed topics with paced flow control. The receiver writes directly to temporary storage. - Verification & Delivery (
ctrl): Upon receiving the final chunk, the receiver re-calculates the complete SHA-256 hash. If verified, the file is atomically moved to the destination folder and aCOMPLETEDreceipt is emitted.
🛠️ Development & Building
Prerequisites
- Node.js >= 20,
pnpm - Rust toolchain (stable)
- macOS (Xcode Command Line Tools), Linux (WebKitGTK dev packages), or Windows (C++ Build Tools including the Windows 10/11 SDK —
link.exealone is not enough; without the SDK'sucrt.lib/kernel32.libno Rust binary can be linked)
Run in Development
# Install dependencies
pnpm install
# Start Tauri development mode
pnpm tauri dev
Tests
pnpm test # Vitest unit tests (history decode/replay/export/chart filling)
pnpm test:ui # Playwright browser tests (mocked Tauri IPC, real React UI)
(cd src-tauri && cargo test) # Rust: SQLite history, bridge rules, webhook, QuickJS sandbox
pnpm test:ui starts the Vite dev server on 127.0.0.1:1420 and drives the real components with a stubbed IPC layer, so it validates rendering and interaction — not live MQTT or HTTP networking.
Build Locally
# Build production bundle for current platform
pnpm tauri build
🤖 Headless CLI (dropqtt-cli)
The same Rust protocol engine as the desktop app, without a window, so a broker
contract can be asserted in CI. It links transport / assertions / topic
directly rather than speaking MQTT through a second client library — a CLI that
disagrees with its own GUI is worse than no CLI.
cargo build --bin dropqtt-cli # in src-tauri/
dropqtt-cli connect --host 127.0.0.1 --port 1883
dropqtt-cli sub --topic 'devices/#' --count 20 --payload
dropqtt-cli pub --topic devices/gw1/cmd --payload '{"mode":"ota"}' --qos 1 --retain
dropqtt-cli rpc --topic devices/gw1/get --payload '{}' --response-topic cli/reply --timeout 5s
dropqtt-cli verify --topic 'devices/#' --assert '$.tempC < 80' --for 30s --junit > report.xml
dropqtt-cli verify --scenario nightly.dqscn --for 30s --json > verdict.json
dropqtt-cli verify --scenario nightly.dqscn --for 30s --bench-rate 2000 --bench-qos 1
verify takes the desktop app's own assertion grammar ($.field <op> <value>,
qos >= 1, $.fw present) and the same SUBACK/PUBACK verdicts, so a rule that
passes here means the same thing when it is armed in the console.
--scenario reads a .dqscn saved from the acceptance panel: its subscriptions
get armed, its assertion rules join the ones passed on the command line, and the
verdict is produced by the same Rust code the panel displays. A performance bar
(minRate / maxP99Ms / maxLost) is only measured when you drive the load with
--bench-rate N (plus --bench-size B, --bench-qos 0|1|2): the CLI publishes to
the rig's bench topics for the --for window, then waits up to 2 s for the last
acknowledgements before judging. The rate is a flag rather than read from
minRate because offering exactly the bar measures nothing. Without it the bar is
reported as not proven (exit 4), and a bar that the settings could never meet —
maxLost over QoS 0, maxP99Ms with payloads too small to carry a timestamp — is
refused before connecting.
Exit codes are a contract — 0 pass, 1 refused or violated, 2 bad command
line, 3 broker unreachable, 4 not proven. The last one exists because "no
message matched this filter" and "the rule held" look identical to a pipeline that
was not told otherwise, and a green gate on a broker nobody was talking to is the
exact failure this project spends its time removing.
A password is never read from the command line (arguments land in the process list
and shell history): point --password-env VAR at an environment variable instead.
The contract above is enforced by scripts/cli-gate.sh, which runs the real binary
against a live broker and asserts every exit code — including the ones that must
not be zero. CI does it on every push. Before its first assertion the gate runs a
broker-identity check (an anonymous publish must be rejected and the denied topic
must come back 0x87): "something answers on this port" is not the same as "this is the
broker the job configured", and confusing the two once sent the whole gate green against
a broker nobody had set up.
# one command: throwaway broker with CI's exact shape, then both gates
bash scripts/gate-rig.sh
# locally, against any broker you already have running
bash scripts/cli-gate.sh ./src-tauri/target/debug/dropqtt-cli 127.0.0.1 18831
# and against a broker with an ACL, to assert a genuine refusal (0x87 -> exit 1)
GATE_DENY_TOPIC=secret/never-granted bash scripts/cli-gate.sh \
./src-tauri/target/debug/dropqtt-cli 127.0.0.1 18831
# scenario verdicts, including the ones that must stay at 4
bash scripts/scenario-gate.sh ./src-tauri/target/debug/dropqtt-cli 127.0.0.1 18831
🚀 Automated Cross-Platform Release (GitHub Actions)
DropQTT packages installers for macOS (Apple Silicon & Intel), Ubuntu Linux, and Windows via GitHub Actions.
Releases are triggered strictly by pushing semantic version tags:
# 1. Update the version in package.json, src-tauri/tauri.conf.json and
# src-tauri/Cargo.toml (the workflow fails if they disagree with the tag)
# 2. Commit and tag:
git tag -a v0.9.0 -m "Release v0.9.0"
git push origin v0.9.0
GitHub Actions will automatically build the artifacts and attach them to a new GitHub Release.
Installing on macOS (no Apple developer account needed)
macOS builds are signed with a self-signed code-signing identity, because that is what makes the login keychain remember your broker password across updates. It is not a Developer ID certificate, so Gatekeeper still refuses anything it thinks you downloaded from a browser — the DMG included. Measured against this repository's own artifacts:
the same signed .app |
result |
|---|---|
without the com.apple.quarantine attribute |
launches |
| with it (what a browser download leaves behind) | refused, process never starts |
spctl --assess reports "rejected" in both rows, so it is not the test — whether the app opens is.
Installing from the release's .app.tar.gz with curl + tar never sets that attribute, which is
also why in-app auto-update was never blocked:
curl -fsSL https://github.com/Timskt/DropQTT/releases/latest/download/install-macos.sh | bash
The script is published as a release asset — the same host and path the app itself downloads from — so a second domain going down cannot make this command fail. From a clone of this repo it is the same file:
bash scripts/install-macos.sh # latest release → /Applications
bash scripts/install-macos.sh --tag v0.12.6
The script refuses to install a bundle whose designated requirement is not this project's certificate, so it cannot be tricked into installing a re-signed or ad-hoc copy — a property a checksum fetched from the same server would not give you. After installing, the first broker connection asks for the keychain password one last time: the bundle's identity changed, so the old grant no longer matches. Click Always Allow and it stays allowed through future updates.
If you already have the DMG: dragging it to /Applications and then approving the app under
System Settings → Privacy & Security is the alternative, and xattr -d com.apple.quarantine only
works from a shell that has Full Disk Access (it returns Operation not permitted without it).
Permanently fixing this for strangers — double-click and go — requires a paid Developer ID
certificate plus notarization; the pipeline already has that branch prepared.
Built With
- iot
- mqtt