-
Notifications
You must be signed in to change notification settings - Fork 0
Device Linking Production Testing
Status: v0.4.0-test — 4 additional bugs found and fixed ✅ (see bottom) Previous: v0.1.2-test — 4 bugs found and fixed ✅ Date: 2026-02-28 Scope: Desktop (Windows) ↔ Mobile (Android) over same WiFi / desktop hotspot
During v0.1.2-test, device linking worked in dev mode but failed completely when testing the installed .exe + .apk over a real local network. This document records what was tested, the four root-cause bugs found, and the fixes applied.
| Component | Version |
|---|---|
| Desktop installer | desktop-temp_0.1.0_x64-setup.exe |
| Android APK |
app-release.apk (release build, JS bundled via assembleRelease) |
| Desktop OS | Windows 11 Home (arm64) |
| Mobile OS | Android |
| Network | Same WiFi router + Windows Mobile Hotspot |
Root cause: local_ip() from the local-ip-address crate returns the primary outbound interface IP only (e.g., 192.168.1.133). When the desktop creates a Windows Mobile Hotspot, Windows creates a virtual adapter at 192.168.137.1. The phone connects to the hotspot subnet (192.168.137.x) and cannot route to 192.168.1.133 — a completely different subnet.
File: apps/desktop/src-tauri/src/device_linking.rs — generate_qr_code()
Fix: Use if_addrs::get_if_addrs() to enumerate all non-loopback IPv4 addresses and embed them comma-separated in the QR payload:
zelara://pair?ips=192.168.137.1,192.168.1.133&port=8765&token=xyz
Root cause: The WebSocket server bound to {local_ip}:8765 — a specific interface. Even if the mobile knew the hotspot IP, the server wasn't listening on that interface.
File: apps/desktop/src-tauri/src/device_linking.rs — start_pairing_server(), line:
let addr = format!("{}:8765", ip); // was: only one interfaceFix: Bind to 0.0.0.0:8765 so the server accepts connections on all interfaces simultaneously:
let addr = "0.0.0.0:8765".to_string();Root cause: In dev mode, the first run of the Rust binary triggers Windows' "Allow access?" popup, which the developer approved. The production .exe installs to a different path — it never gets that popup and no firewall rule is added. Windows Firewall silently drops all inbound connections on port 8765.
Fix (in-app): On first server start, check if the rule exists; if not, spawn an elevated PowerShell process (one-time UAC prompt) to add the rule:
let rule_exists = std::process::Command::new("netsh")
.args(["advfirewall", "firewall", "show", "rule", "name=Zelara Device Linking"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !rule_exists {
let _ = std::process::Command::new("powershell")
.args(["-WindowStyle", "Hidden", "-Command",
"Start-Process netsh -ArgumentList 'advfirewall firewall add rule \
name=\"Zelara Device Linking\" dir=in action=allow protocol=TCP \
localport=8765' -Verb RunAs -Wait"])
.output();
}The UAC prompt appears once per machine. After the rule is created it is never shown again.
Root cause: Android 9+ blocks all cleartext (unencrypted) traffic by default. WebSocket ws:// is cleartext. The debug manifest (src/debug/AndroidManifest.xml) already sets android:usesCleartextTraffic="true", but this only applies to debug builds. Release builds use the main manifest (src/main/AndroidManifest.xml) exclusively, which had no cleartext setting → Android blocks all ws:// connections before they reach the network.
Symptom: ws.onerror fires immediately for all IPs (not after the 3-second timeout), and the hardcoded "Connection refused" message masks the real Android error (Cleartext HTTP traffic to X not permitted). This makes the failure indistinguishable from a real TCP refusal.
Files: apps/mobile/android/app/src/main/AndroidManifest.xml — missing networkSecurityConfig
Fix: Create apps/mobile/android/app/src/main/res/xml/network_security_config.xml with cleartextTrafficPermitted="true" for all hosts (local IPs are dynamic so cannot be enumerated statically), and reference it from the main manifest:
<!-- network_security_config.xml -->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors><certificates src="system"/></trust-anchors>
</base-config>
</network-security-config><!-- AndroidManifest.xml <application> tag -->
android:networkSecurityConfig="@xml/network_security_config"| File | Change |
|---|---|
src-tauri/Cargo.toml |
Added if-addrs = "0.13" dependency |
src-tauri/src/device_linking.rs |
get_all_local_ips() using if_addrs; QR uses ips= param; server binds 0.0.0.0:8765; UAC firewall rule on first run |
src/components/DevicePairing.tsx |
Displays all available IPs in the UI |
| File | Change |
|---|---|
src/services/DeviceLinkingService.ts |
connect() accepts string | string[]; tries each IP with 3s timeout, stops on first success; onerror logs actual event for debugging |
src/screens/DevicePairingScreen.tsx |
Parses ips= param (comma-separated); falls back to legacy ip= param |
android/app/src/main/res/xml/network_security_config.xml |
New — allow cleartext ws:// in release builds (Bug 4 fix) |
android/app/src/main/AndroidManifest.xml |
Reference network_security_config to fix cleartext blocking in release builds (Bug 4 fix) |
cd apps/desktop
npm run tauri:devcd apps/desktop
npm run tauri:build
# Installer: src-tauri/target/release/bundle/nsis/desktop-temp_0.1.0_x64-setup.execd apps/mobile/android
./gradlew assembleRelease
adb install -r app/build/outputs/apk/release/app-release.apk# 1. Start Metro bundler (keep running in a separate terminal)
cd apps/mobile
npm start
# 2. Build and install the APK
cd apps/mobile/android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
# 3. Forward Metro port to device
adb reverse tcp:8081 tcp:8081
# 4. The app might need a proper restart
adb shell am force-stop ai.zelara.mobile
adb shell am start -n ai.zelara.mobile/.MainActivity- Both devices on same network (same WiFi router or phone connected to desktop's Windows Mobile Hotspot)
- Desktop app running, WebSocket server started
-
Verify firewall rule (first run only):
netsh advfirewall firewall show rule name="Zelara Device Linking"
Expected: rule listed with
Action: Allow,LocalPort: 8765 -
Verify server is listening on all interfaces:
netstat -an | findstr :8765 # netstat -an | Select-String ":8765"
Expected:
TCP 0.0.0.0:8765 0.0.0.0:0 LISTENING -
Generate QR code on desktop → confirm multiple IPs shown in UI (e.g.,
192.168.137.1, 192.168.1.133) -
Scan QR from mobile → mobile auto-tries each IP → connects to the reachable one
-
Confirm connection:
Location Expected output Desktop terminal New connection from: 192.168.x.x:PORTDesktop UI Device appears after "Refresh Devices" Mobile "Device Linked!" alert
- Same WiFi (router): connects without manual firewall steps on second+ run
- Desktop hotspot: phone connects via
192.168.137.1(hotspot adapter IP) - First run: UAC prompt appears, user approves, rule added, linking works
- Subsequent runs: no UAC prompt
| Limitation | Status |
|---|---|
| Mobile data (cellular) linking | Not supported — private IP unreachable over carrier NAT. Requires relay server or NAT traversal (future) |
| Token expires on desktop restart | Re-scan QR after restarting desktop. Persistent pairing planned for v2 |
| macOS/Linux firewall |
netsh is Windows-only. Other platforms may need ufw/pfctl equivalents (not yet implemented) |
- Device Linking Architecture — Protocol design, QR pairing spec
- Device Linking Offline Revision — Enhanced offline capabilities planning
- Desktop: Device Pairing Troubleshooting — End-user troubleshooting guide
Status: Fixed ✅ Date: 2026-02-28 Scope: Desktop UI real-time updates + Mobile camera-based image inversion test
Root cause: DevicePairing.tsx only called get_linked_devices when the user clicked "Refresh Devices". The Rust backend registered devices correctly, but the React frontend had no way to know about new connections in real time.
Fix: Used Tauri v2's event system to push a notification from the backend to the UI immediately when a device registers.
| File | Change |
|---|---|
apps/desktop/src-tauri/src/device_linking.rs |
Added app_handle: tauri::AppHandle param to start_pairing_server; passed handle through the call chain; emits device-linked event after device registers |
apps/desktop/src/components/DevicePairing.tsx |
Subscribes to device-linked event on mount; appends new device to list without requiring refresh |
apps/desktop/src/components/TestingPanel.tsx |
Also subscribes to device-linked to keep device count accurate |
Result: Desktop UI shows the newly linked device instantly when mobile scans the QR code — no button press required.
Root cause: TestingScreen.tsx used a hardcoded 1×1 px red pixel instead of a real camera photo. The desktop TestingPanel.tsx had no mechanism to display test results — it only showed a placeholder log with a "Simulate Test" button.
Fix:
- Mobile now uses
react-native-vision-camerato take a real camera photo, reads it as base64 withreact-native-fs, and sends it to Desktop. - Desktop Rust processes the inversion and emits an
image-inversion-resultTauri event with both the original and inverted base64 images. - Desktop React (
TestingPanel.tsx) listens for the event and displays both images side-by-side in real time.
| File | Change |
|---|---|
apps/desktop/src-tauri/src/device_linking.rs |
After successful inversion, emits image-inversion-result event with { original, inverted, device, timestamp }
|
apps/desktop/src/components/TestingPanel.tsx |
Subscribes to image-inversion-result; renders original + inverted images when received; shows real device count via get_linked_devices on mount |
apps/desktop/src/components/TestingPanel.css |
Added .test-result-display, .image-comparison, .image-comparison-item styles |
apps/mobile/src/screens/TestingScreen.tsx |
Full camera capture flow (Take Photo → Retake → preview → Run Test); sends real JPEG; modal shows original JPEG + inverted PNG |
- Desktop app running (dev or production build)
- Mobile APK installed (release or debug with Metro)
- Both devices on the same WiFi or desktop hotspot
-
Start Desktop, click Generate QR Code
- Expected: QR code appears, multiple IPs shown below it
-
On Mobile, go to Device Pairing → scan QR code
- Expected (Desktop UI): "Linked Devices (1)" appears immediately in the Device Pairing section, without clicking "Refresh Devices"
- Expected (Desktop Testing panel): Device count changes from 0 to 1 and a log entry appears
-
On Mobile, navigate to Testing tab
- Expected: "Connected to Desktop" shown in green
-
Tap Take Photo, point camera at any object, press capture button
- Expected: photo preview appears in the Testing screen
-
Tap Run Image Inversion Test
- Expected (Mobile): spinner while processing, then modal appears showing original photo on the left and the color-inverted version on the right
- Expected (Desktop Testing panel): immediately shows "Last Inversion Test" section with the same two images, plus a log entry with the device address and timestamp
- Desktop device list updates without refresh after QR scan
- Desktop Testing panel device count increments when mobile connects
- Mobile camera opens and captures a real photo
- Mobile inversion test sends real photo (not hardcoded pixel)
- Desktop displays both images within ~1 second of mobile sending
- Mobile modal shows original and inverted images correctly
Status: Implemented ✅ Date: 2026-03-01 Scope: Mobile → Desktop pairing handshake + Desktop toast notification
In v0.2.0-test, the Desktop only registered a mobile device when the first WebSocket message with a token arrived. The first such message was a counter_update from the Testing screen — meaning the Desktop's "Linked Devices" list stayed empty until the user navigated to the Testing tab.
The mobile showed "Device Linked!" immediately after ws.onopen, before any message was sent to the Desktop. This was not a true handshake confirmation.
| File | Change |
|---|---|
src/services/DeviceLinkingService.ts |
Added sendHandshake() method — sends { taskType: 'handshake', payload: { token } } and returns a promise that resolves only on Desktop confirmation (5s timeout) |
src/screens/DevicePairingScreen.tsx |
Added await DeviceLinkingService.sendHandshake() immediately after connect() — "Device Linked!" alert now only appears after Desktop confirms |
| File | Change |
|---|---|
src-tauri/src/device_linking.rs |
Added "handshake" arm to the match request.task_type.as_str() block — returns { message: "Handshake successful" } (device registration + device-linked event already fired by the token verification block above the match) |
src/App.tsx |
Added listen('device-linked', ...) effect; shows toast notification for 3 seconds when a device connects |
src/App.css |
Added .toast-notification styles — fixed-position top-right, green, animated fade-in |
- Desktop app running (dev or production build)
- Mobile APK installed (release or debug with Metro)
- Both devices on same WiFi or desktop hotspot
-
Start Desktop, click Generate QR Code
- Expected: QR code appears, multiple IPs shown below it
-
On Mobile, go to Device Pairing → scan QR code
- Mobile shows spinner ("Connecting to Desktop...")
- Mobile sends
handshakemessage immediately afterws.onopen - Desktop registers device, emits
device-linkedevent - Expected (Desktop): green toast notification pops up at top-right: "Device linked: Mobile (x.x.x.x:PORT)"
- Expected (Desktop device list): "Linked Devices (1)" appears immediately, no refresh needed
- Expected (Desktop Testing panel): device count increments + log entry added
- Expected (Mobile): "Device Linked!" alert appears only after Desktop sends handshake response
-
Navigate to Testing tab on mobile
- Expected: "Connected to Desktop" in green
- Counter starts incrementing and appears on Desktop — same as before
- "Device Linked!" alert on mobile appears only after Desktop sends handshake response
- Desktop toast notification appears within ~1 second of QR scan
- Desktop device list updates without clicking "Refresh Devices"
- Desktop Testing panel device count increments on connection (no counter needed)
- Counter still works after handshake (no regression)
- If handshake times out (Desktop unreachable after connect), mobile shows "Connection Failed" instead of "Device Linked!"
Status: Fixed ✅ Date: 2026-03-13 Scope: Stale connection state, auto-reconnect, duplicate device registrations, BLE section clarification
Root cause (Desktop): handle_websocket_connection() exited its loop on Message::Close or Err with no cleanup code. Disconnected devices were never removed from linked_devices and no event was emitted — so the Desktop UI showed phantom "connected" devices indefinitely.
Root cause (Mobile): The ws.onerror handler was only set before the connection opened (inside connectToSingle). After ws.onopen fired, the pre-open onerror was replaced with a connection-established one, but the onclose path was the only handler that cleared this.connected. An abrupt network drop (airplane mode) triggers an error on the socket, not a clean close — so this.connected stayed true.
Root cause: DeviceLinkingService stored no credentials after a successful connect, and both onclose and onerror simply cleared state without scheduling a retry. After turning off airplane mode, the connection stayed dead and the user had to re-scan the QR code.
Root cause: Device deduplication on Desktop used d.name == device.name where the name included the ephemeral TCP source port (e.g., Mobile (192.168.0.32:43008)). Every reconnect from the same phone used a different ephemeral port, so the dedup check always passed and a new entry was added. Combined with Bug 1 (old entries never removed), the list grew with each pairing attempt.
Root cause: The Testing Module's "Bluetooth Discovery Test" section was named and described as a test, but contains zero Bluetooth code. It is purely a CSS/RN animation that visualises what BLE will carry in Phase 3. The animation only activates after QR pairing succeeds, which confused the expectation that BLE would enable pairing without QR.
| File | Change |
|---|---|
src-tauri/src/device_linking.rs |
Track registered_device_id in connection handler; on exit (clean or error), call retain() to remove device and emit device-disconnected event. Use stable device_id from handshake payload for dedup (d.id == device.id); if same device reconnects, update entry in-place instead of appending |
src/components/TestingPanel.tsx |
Listen to device-disconnected event, decrement connectedDevices, log entry |
modules/testing/desktop/src/components/TestingPanel.tsx |
Same as above (mirror file) |
| File | Change |
|---|---|
src/services/DeviceLinkingService.ts |
Stable deviceId (UUID) loaded from / persisted to AsyncStorage; sent as device_id in handshake payload. lastKnownCredentials stored on successful connect. Persistent onerror and onclose set after open — both call scheduleReconnect(). Exponential backoff reconnect: 2 s → 4 s → 8 s … 30 s max. onConnectionChange() subscription API for reactive UI. disconnect() cancels reconnect timer and clears credentials |
src/screens/DevicePairingScreen.tsx |
No change — sendHandshake() already called; reconnect path calls it too |
| File | Change |
|---|---|
mobile/src/components/TestingScreen.tsx |
Subscribe to DeviceLinkingService.onConnectionChange() in useEffect so isConnected updates automatically without manual refresh. BLE section renamed to "BLE Discovery — Phase 3 Preview" with clear simulation disclaimer |
desktop/src/components/TestingPanel.tsx |
device-disconnected listener. BLE section renamed to "BLE Discovery — Phase 3 Preview" with clear simulation disclaimer |
- Link devices via QR → verify counter increments on Desktop
- Turn on Airplane Mode on mobile
- Expected within ~5 s:
- Mobile: "Not connected" indicator updates automatically
- Desktop:
device-disconnectedlog entry appears, device count drops to 0
- (Continue from Test 1 or re-link)
- Turn off Airplane Mode on mobile
- Expected within ~2–8 s:
- Mobile reconnects automatically (no QR scan)
- Counter resumes on Desktop
- Device count returns to 1
- Pair mobile via QR → verify 1 device shown
- Force-stop and re-launch mobile app
- Re-pair via QR scan
- Expected: Desktop shows exactly 1 device (not 2)
- Open Testing Module on both devices
- Verify section title reads "BLE Discovery — Phase 3 Preview"
- Verify description makes clear no Bluetooth is active
- Airplane mode → device count drops to 0 automatically (both sides)
- Network recovery → auto-reconnect within 30 s, counter resumes
- Re-link after app restart → exactly 1 device shown
- BLE section clearly labelled as Phase 3 preview / visual simulation
Status: Implemented ✅ Date: 2026-03-14 Scope: Desktop BLE advertising (Windows) + Mobile BLE scanning → automatic WSS connection without QR
QR pairing remains the fallback. BLE auto-discovery means:
- Desktop starts advertising its IP + port over Bluetooth LE (WinRT, Windows-only for now)
- Mobile scans for Zelara BLE advertisements in the background
- Mobile auto-connects via the existing WSS/TLS path — no user action required
- TestingPanel shows a live BLE status badge and animates the IP-ball only for BLE-discovered connections
| File | Change |
|---|---|
src-tauri/src/ble_advertising.rs |
New — WinRT BLE publisher; start_advertising(ip, port), stop_advertising(), BleStatus enum; non-Windows stub returns NotSupported. Service UUID not included in advertisement (128-bit UUID struct + manufacturer data + Flags exceeds 31-byte BLE legacy limit on Windows) |
src-tauri/src/lib.rs |
Register BleAdvertisingState; auto-start BLE in setup hook; add start_ble_advertising, stop_ble_advertising, get_ble_status Tauri commands |
src-tauri/Cargo.toml |
windows = { version = "0.58", features = ["Devices_Bluetooth_Advertisement", "Foundation_Collections", "Storage_Streams"] } (Windows-only target) |
src-tauri/src/device_linking.rs |
DeviceInfo gains discovery_method: String; added is_ble_connection session flag (sticky — prevents token errors on subsequent messages); BLE connections skip token validation; device-disconnected event includes discovery_method
|
src/components/TestingPanel.tsx |
BLE status badge (advertising/idle/notSupported/error); ball animation only for BLE connections; discovery_method tracked per device; listens to ble-status-changed event; renamed section to "BLE Auto-Discovery" |
src/App.tsx |
invoke('start_pairing_server') on mount — WSS/TLS server now auto-starts at launch alongside BLE, so BLE-discovered connections succeed immediately without the user opening the QR screen |
| File | Change |
|---|---|
src/services/BLEDiscoveryService.ts |
New — scans for company ID 0xFFFE in manufacturer data (no UUID filter — UUID not in advertisement); parses IP:port payload; calls DeviceLinkingService.connect() with discoveryMethod: 'ble'; restarts scan on disconnect. Updated: exponential backoff (5 s → 60 s) after WSS failure; waitingForPower guard prevents duplicate subscriptions when BLE toggles off; scan logs reduced to compact heartbeat every 10 devices |
src/services/DeviceLinkingService.ts |
ConnectionInfo gains discoveryMethod?: 'ble' | 'qr'; connect() accepts optional discoveryMethod param; sendHandshake() includes discovery_method in payload; reconnect preserves discovery method |
App.tsx |
BLEDiscoveryService.startScan() on mount, destroy() on unmount |
package.json |
Added react-native-ble-plx: "^3.4.0"
|
android/app/src/main/AndroidManifest.xml |
BLE permissions (Android 12+: BLUETOOTH_SCAN, BLUETOOTH_CONNECT; Android < 12: BLUETOOTH, BLUETOOTH_ADMIN, ACCESS_FINE_LOCATION); android.hardware.bluetooth_le feature (not required) |
Service UUID: not included in advertisement — the 128-bit UUID AD structure (18 bytes) + manufacturer data (10 bytes) + auto-added Flags (3 bytes) = 31 bytes exactly, causing Start() to fail with E_INVALIDARG on Windows. Mobile scans without UUID filter and identifies Zelara packets by company ID 0xFFFE.
Manufacturer data (company ID 0xFFFE):
| Bytes | Content |
|---|---|
[0] [1]
|
Company ID low/high byte (0xFE 0xFF = 0xFFFE little-endian) |
[2]–[5]
|
IPv4 octets (big-endian) |
[6] [7]
|
Port number (big-endian u16) |
Total: 8 bytes manufacturer data.
BLE connections use proximity as trust — no pairing token required. Rationale:
- BLE range (~10 m) implies physical proximity
- WSS connection still requires same local network (WiFi)
- The
is_ble_connectionflag is set once on handshake and remainstruefor the whole session — subsequent messages (counter, image tests) are not re-authenticated
QR connections always require the pairing token.
- Desktop: Windows with Bluetooth adapter enabled
- Mobile: Android with Bluetooth enabled, on same WiFi as Desktop
- Both apps freshly installed
- Start Desktop app — do not click "Generate QR Code"
- Check
get_ble_statusreturnsAdvertising { ip, port }(verify in DevTools or logs) - On Mobile, launch app (do not navigate to Device Pairing)
- Wait up to 10 s — mobile should auto-connect
- Expected:
- Desktop: toast "Device linked: Mobile (BLE)"
- Desktop Testing panel: device count = 1, BLE status badge shows "BLE Advertising"
- Desktop Testing panel: IP-ball animation active
- Mobile: counter starts incrementing on Desktop
- Open Testing Module on Desktop (connected via BLE from Test 1)
- Verify section heading reads "BLE Auto-Discovery"
- Verify BLE status badge shows "BLE Advertising"
- Verify IP-ball animation is running
- Disconnect Mobile
- Re-link via QR code (Device Pairing screen)
- Open Testing Module on Desktop
- Verify IP-ball animation is hidden (QR = no BLE animation)
- Verify BLE status badge still shows "BLE Advertising" (BLE stack unaffected by QR session)
- Connected via BLE (Test 1)
- Turn on Airplane Mode on Mobile
- Expected: Desktop device count drops to 0; BLE badge unchanged
- Turn off Airplane Mode
- Expected: Mobile re-discovers Desktop via BLE (or reconnects via stored credentials) within 30 s
- Disable Bluetooth adapter on Desktop (or test on VM without BLE)
- Launch Desktop app
- Expected:
get_ble_statusreturnsNotSupported; no crash; QR pairing still works normally
- Mobile auto-connects via BLE within ~10 s of launch (no QR)
- Desktop BLE status badge shows "BLE Advertising" while advertising
- IP-ball animation shows only for BLE connections, not QR
- QR pairing still works as fallback
- No crash on machines with no BLE adapter (
NotSupported) - BLE re-discovery works after reconnect (mobile finds Desktop again after network drop)