How Ani-Mime instances discover each other on the local network and exchange dog visits.
Multiple Ani-Mime instances on the same LAN discover each other and send their dogs to visit other screens. Discovery runs across three parallel channels so that if one is blocked by the network, the others still work.
Machine A Machine B
┌───────────────────┐ ┌───────────────────┐
│ Ani-Mime │ 1. mDNS (5353) │ Ani-Mime │
│ │ ◄───────────────────────► │ │
│ discovery.rs │ 2. Multicast (1235) │ discovery.rs │
│ broadcast.rs │ ◄───────────────────────► │ broadcast.rs │
│ │ 3. Unicast /24 (1235) │ │
│ │ ◄───────────────────────► │ │
│ │ │ │
│ HTTP :1234 │ POST /visit │ HTTP :1234 │
│ │ ─────────────────────────► │ │
│ │ POST /visit-end │ │
│ │ ─────────────────────────► │ │
└───────────────────┘ └───────────────────┘
All three channels write into the same AppState.peers HashMap keyed by instance_name, so a peer found via multiple channels appears once in the UI.
| # | Channel | Source | Cadence | Target | When it wins |
|---|---|---|---|---|---|
| 1 | mDNS | discovery.rs |
TTL-driven | _ani-mime._tcp.local. via 224.0.0.251 |
Home / small office networks |
| 2 | Multicast announce | broadcast.rs::announce_loop |
every 5s | 224.0.0.200:1235 |
Networks that allow arbitrary link-local multicast |
| 3 | Unicast subnet scan | broadcast.rs::unicast_scan_loop |
every 30s | every host in local /24 at :1235 |
Networks that block all multicast but allow unicast (managed WiFi / Bonjour Gateways) |
| Network type | mDNS | Multicast 224.0.0.200 | Unicast /24 scan |
|---|---|---|---|
| Home WiFi / small office | ✅ | ✅ | ✅ |
| Managed WiFi w/ Bonjour Gateway | ❌ | ✅ | |
| WiFi with full client isolation | ❌ | ❌ | ❌ |
| Guest WiFi / different VLAN | ❌ | ❌ | ❌ (different subnet) |
If unicast scan doesn't work either, the only path forward is a signaling server outside the LAN — not currently implemented.
On startup, each instance registers an mDNS service:
- Service type:
_ani-mime._tcp.local. - Instance name:
{nickname}-{process_id}(e.g.,Alice-12345) - Properties:
nickname,pet(advertised to peers) - Port: HTTP server port (default 1234)
The discovery module detects the local IP via UDP socket trick (connect to 8.8.8.8:80, read local addr - no traffic sent). Falls back through:
- Non-loopback IPv4
- Any IPv4
- Non-loopback IPv6
- Any available address
When a peer is discovered (ServiceResolved event):
- Extract IP, port, nickname, pet from mDNS properties
- Filter out own instance (by instance name comparison)
- Store in
AppState.peersHashMap - Emit
peers-changedevent to frontend
When a peer disappears (ServiceRemoved event):
- Remove from
AppState.peers - Emit
peers-changedto update UI
A background thread checks every 30 seconds. If no peers are found after the first check, emits discovery-hint: "no_peers" once (shown as speech bubble).
The broadcast module runs independently from mDNS and provides two channels: multicast announce (fast, when it works) and unicast subnet scan (slow but network-proof).
Every announce — multicast or unicast — carries the same JSON payload:
{
"magic": "ani-mime/1",
"instance_name": "Alice-12345",
"nickname": "Alice",
"pet": "rottweiler",
"ip": "192.168.20.42",
"port": 1234
}magic— protocol tag. Foreign traffic on:1235that doesn't match is silently dropped.instance_name—{nickname}-{pid}. Unique per running process. Used as the HashMap key for dedup across channels.ip/port— where the sender's HTTP visit endpoint is reachable.
Payload is JSON-encoded plaintext and kept under ~200 bytes to stay well inside one UDP datagram.
broadcast.rs::start_broadcast spawns four threads:
| Thread | Purpose |
|---|---|
announce_loop |
Every ANNOUNCE_INTERVAL_SECS (5s), sends the payload to 224.0.0.200:1235 multicast. |
unicast_scan_loop |
Every UNICAST_SCAN_INTERVAL_SECS (30s), sends the payload to every IP .1–.254 in the local /24 at :1235. Spaced at UNICAST_SEND_SPACING_MS (10ms) between sends to stay under ~100 pps. |
listen_loop |
Binds 0.0.0.0:1235, joins multicast group 224.0.0.200. Receives both multicast (via group join) and unicast (by virtue of the bind) on the same socket. Calls handle_announce for each valid packet. |
expiry_loop |
Every 5s, removes peers from AppState.peers whose AppState.broadcast_seen entry is older than PEER_EXPIRY_SECS (30s). Emits peers-changed. |
handle_announce for any received announce:
- Verifies
magic == "ani-mime/1". - Filters out our own instance (one-shot
self-loop confirmedlog the first time — used as a health check for local multicast). - Inserts/updates
AppState.peers[instance_name]with freshPeerInfo. - Updates
AppState.broadcast_seen[instance_name]with the current timestamp. - If this is a new peer, logs
NEW peer:and emitspeers-changed. - If already known, only logs
refresh peer:at most once per minute per peer to avoid log spam.
The listen socket is bound to 0.0.0.0:1235. It receives:
- Multicast packets sent to
224.0.0.200:1235— because we joined the group viajoin_multicast_v4on the detected local IPv4 interface. - Unicast packets sent to
<our-ip>:1235— because our bind accepts unicast on that port by default.
Both go through the same handle_announce code path — the sender's channel is invisible to the receiver. The from address in the log is the sender's IP, not 224.0.0.200, in both cases.
unicast_scan_loop derives the scan range from our detected local IPv4 by taking the first three octets (a /24 assumption). This covers the vast majority of home and office networks (192.168.*.*, 10.0.*.*). Networks using /16 or non-standard masks will only have the first 256 hosts scanned — a known limitation, acceptable for current usage.
- User right-clicks mascot → context menu shows discovered peers
- User selects a peer →
start_visit(peer_id, nickname, pet)command - Backend:
- Looks up peer in
AppState.peers - Sets
AppState.visiting = Some(peer_id) - Sends
POST /visitto peer's HTTP server with JSON body:{ "instance_name": "Alice-12345", "pet": "rottweiler", "nickname": "Alice", "duration_secs": 15 } - Emits
dog-away: true(hides local mascot) - Spawns thread: sleeps for
VISIT_DURATION_SECS(8s)
- Looks up peer in
/visitroute receives POST with visitor info- Creates
VisitingDog { instance_name, pet, nickname, arrived_at, duration_secs } - Adds to
AppState.visitors - Emits
visitor-arrivedevent → frontend shows visitor sprite
After the visit duration:
- Spawned thread wakes up
- Sends
POST /visit-endto peer with{ "instance_name": "Alice-12345", "nickname": "Alice" } - Clears
AppState.visiting - Emits
dog-away: false(shows local mascot again)
Peer side:
/visit-endremoves visitor byinstance_name(falls back tonicknamefor older peers)- Emits
visitor-leftevent → frontend removes visitor sprite
The watchdog also monitors visitors:
- If
now - arrived_at >= duration_secs, automatically removes the visitor - This handles cases where the
/visit-endcall fails (network issues)
Right-click on mascot shows available peers:
// In App.tsx
const peers = usePeers();
// Rendered as context menu items with peer nickname + pet icon- 96x96 sprites (smaller than main 128x128)
- Horizontally mirrored (
scaleX(-1)) to face the main mascot - Positioned on the right side with staggered offsets
- Slide-in animation (0.5s ease-out from right)
- Always display
idlesprite
When the local dog is visiting someone:
- Status overridden to
"visiting"viadog-awayevent - Main mascot hidden (replaced with placeholder or hidden entirely)
- Status pill shows purple "visiting" dot
Peer discovery requires entitlements for release builds. These are defined in src-tauri/Entitlements.plist:
| Entitlement | Purpose |
|---|---|
com.apple.security.cs.allow-jit |
WebView JIT under Hardened Runtime |
com.apple.security.cs.disable-library-validation |
Required by macOSPrivateApi (window transparency) |
com.apple.security.network.server |
mDNS multicast sockets + HTTP server on :1234 |
com.apple.security.network.client |
Outgoing HTTP (visit requests) + UDP (IP detection) |
Additionally, src-tauri/Info.plist declares:
NSBonjourServices:_ani-mime._tcp— triggers the macOS Local Network permission dialogNSLocalNetworkUsageDescription— explains why the app needs network access
Important: Tauri does not embed entitlements for ad-hoc (no Developer ID) builds. The post-build script src-tauri/script/post-build-sign.sh re-signs the app with entitlements and re-creates the DMG. See the release build section in the README.
Walk through these in order — each rules out a layer:
- Both machines must be on the same WiFi/LAN subnet — none of our channels cross subnets.
- macOS Local Network permission — on first launch, macOS asks to allow local network access. If denied, go to System Settings > Privacy & Security > Local Network and enable ani-mime.
- Check
/debugendpoint —curl http://127.0.0.1:1234/debugshould show yourinstance_name,registered_addrs, and the current peers list. - Read the log, filter for
[broadcast]and[discovery]:Key lines to look for:grep -E '\[broadcast\]|\[discovery\]' ~/Library/Logs/com.vietnguyenwsilentium.ani-mime/ani-mime.log | tail -40
[broadcast] listen socket bound on 0.0.0.0:1235, joined multicast group 224.0.0.200— socket OK[broadcast] self-loop confirmed— local multicast end-to-end OK[broadcast] unicast scan #N done: ... sent_ok=253— unicast scan completed[broadcast] NEW peer: ...— 🎉 a peer was found
- Verify mDNS registration —
dns-sd -B _ani-mime._tcp local.should show your instance. - Verify unicast works between machines —
curl --max-time 3 http://<peer-ip>:1234/debugfrom your machine should return the peer's state. If this fails, your network has client-to-client isolation and none of our channels will work. - Entitlements missing — if shared via DMG without the post-build sign step, mDNS silently fails. Re-build with
bun run tauri build && bash src-tauri/script/post-build-sign.sh - Quarantine attribute — apps transferred between Macs get quarantined. Run
xattr -cr /Applications/ani-mime.appon the receiving machine.
| Log pattern | Meaning |
|---|---|
FAILED to bind/join multicast ... |
Port conflict, permission denied, or interface has no IPv4. Socket never came up. |
self-loop confirmed present, no NEW peer on either side |
Network blocks multicast between clients but local pipeline works. Unicast scan should still find the peer within 30s. |
self-loop confirmed absent after 30s |
Local multicast is broken — usually interface-level issue. Unicast scan is your only remaining channel. |
unicast scan #N done: ... send_err=X with large X |
Most sends are hitting EHOSTUNREACH — could mean ARP is failing, or the subnet is sparsely populated (harmless). |
# 1. mDNS — see all ani-mime instances via Bonjour
dns-sd -B _ani-mime._tcp local.
# 2. Multicast — listen on the broadcast group
# (kill with Ctrl+C; use tcpdump if available)
sudo tcpdump -i any -n 'host 224.0.0.200 and port 1235'
# 3. Unicast — direct debug request to a peer
curl --max-time 3 http://192.168.20.29:1234/debug# Make a fake mDNS peer appear in your context menu
dns-sd -R "TestBuddy-9999" "_ani-mime._tcp" "local." 1234 nickname=Buddy pet=dalmatian- LAN only — no channel crosses subnet boundaries or reaches over WAN.
/24assumption in unicast scan — only the first 256 hosts of larger subnets are reached.- Multicast-hostile networks degrade to 30s discovery — the unicast scan cadence is the floor.
- No authentication — any Ani-Mime instance on the network can visit; any announce that matches the magic string is trusted.
- No rejection — visits are automatically accepted.
- No encryption — HTTP traffic and UDP announces are plaintext.
- Single visit — can only visit one peer at a time.
- Fixed visit duration — 8 seconds, not configurable by user.