User reported that outgoing direct calls from macOS show up in the
history list as "missed" even when the call completes successfully.
Adds two changes to fix / diagnose:
1. history::log now dedupes by call_id. If an entry for this call_id
already exists in the store, it updates the existing row's
direction + timestamp in place instead of appending a duplicate.
Protects against double-emit (caller side adding Missed on top of
Placed, or any future signal loop that fires twice). One row per
call_id, which matches what the user intuitively expects.
2. history::log now logs every write with tracing::info — call_id,
peer_fp, direction, alias. Plus an extra line when we replace
an existing entry: "history::log replacing existing entry
from=Placed to=Missed" etc. Makes it easy to see in the desktop
stderr which side is writing what, so we can find the outgoing =>
missed regression immediately if it recurs.
3. main.ts now renders an explicit text label next to the direction
arrow: "Outgoing", "Incoming", or "Missed" instead of just the ↗
↙ ✗ icons. Removes any ambiguity about what the icon means so
future users can't misread a Placed entry as Missed based on icon
shape alone.
Side fix for scripts/build-windows-cloud.sh:
- die() and the do_full ERR trap now respect WZP_KEEP_VM=1 so a failed
build doesn't auto-destroy the debug VM (previously the trap fired
before the KEEP_VM check and tore down the VM on any error).
- Bump default server type cx23 → cx33. 4GB RAM is not enough for a
cold tauri + rustls + quinn + wzp-client cross-compile — the cx23
run got "Read from remote host ... Connection reset by peer"
partway through rustc, which is the classic signature of an OOM
kill on the SSH session. cx33 has 8GB RAM and 8 vCPU which should
comfortably fit the build.
Persistent JSON-backed call history for the direct-call screen so users
can see what they've placed / received / missed and dial back with one
click. Also fixes two small latent UX issues reported alongside.
Backend (Rust)
- new crate/module desktop/src-tauri/src/history.rs: thread-safe in-
process store (OnceLock<RwLock<Vec<CallHistoryEntry>>>) backed by
<APP_DATA_DIR>/call_history.json. Atomic writes via temp+rename. Max
200 entries, FIFO pruning. CallDirection { Placed, Received, Missed }.
- Log hooks in the signal loop + commands:
* place_call → Placed entry (with target fingerprint)
* DirectCallOffer → Missed entry up front; upgraded to Received
inside answer_call when accept_mode != Reject
via history::mark_received_if_pending(call_id).
If user rejects or never answers, it stays Missed.
- New Tauri commands:
* get_call_history() → all entries, newest first
* get_recent_contacts() → unique peers by fp, newest interaction first
* clear_call_history() → wipes JSON + in-memory
* deregister() → tears down signal transport + endpoint
Backend emits `history-changed` events so the UI can live-refresh
without polling.
Frontend (main.ts + index.html + style.css)
- Direct-call panel now has:
* Recent contacts chip row (top 6 unique peers). Click a chip → dial.
* Call history list (up to 50 rows). Direction icon (↗ placed, ↙
received, ✗ missed), peer alias/fp, relative timestamp, callback
button. Both click handlers populate target-fp and fire place_call.
* Deregister button in the "registered" header — calls the new
deregister command, tears down the signal transport, returns the
UI to the pre-register state.
* Clear-history link in the history header.
- Subscribes to `history-changed` events so the list updates the moment
the backend logs a new entry. Also refreshed on register + after a
clear.
- Nothing is rendered until there is data — empty sections stay hidden.
Tasks #20 + #21 (small UX items bundled in)
- Default room "general" for new installations: the html input value
attribute is now "general" and loadSettings() defaults match. Existing
users' localStorage still wins.
- Random alias on desktop: already latent but confirmed working — the
startup IIFE at main.ts:374 calls get_app_info() and prefills the
alias input from derive_alias(seed) when the input is empty. No code
change needed, just verified it flows through the same path as the
Android client.
Known follow-ups (deferred to step 6 polish)
- Call duration tracking (currently all entries have no duration field)
- Hangup signal from an unanswered incoming should emit history-changed
so the missed state is visible even when the user never tapped accept
- Android UI layout fit-check on the smaller Nothing screen
Build 4f2ad65 wired the Speaker button to AudioManager.setSpeakerphoneOn
but user testing found that flipping speakerphone on an active Oboe
VoiceCommunication stream silently tears down the AAudio streams on
Pixel-class devices — both capture and playout stop producing data.
Only ending the call and rejoining brings audio back (because the fresh
Oboe open runs with the new routing already applied).
Also the earpiece state showed up red in the UI because the button was
getting the `.muted` CSS class when speakerphoneOn=false. Earpiece is a
valid routing state, not a muted one.
Fix set_speakerphone Tauri command:
1. Flip AudioManager.setSpeakerphoneOn via JNI (as before).
2. If the Oboe backend is currently running, stop it, sleep 50 ms to
let AAudio finalise the transition, then start it again. The Rust
send/recv tokio tasks keep running across the gap — they just read
zero samples and write into the preserved ring buffers for a few
frames, which is acceptable. The AudioBackend singleton's ring
state is preserved across stop+start because it's in a 'static
OnceLock.
3. Debounce the UI click via speakerphoneBusy + spkBtn.disabled so
users can't queue up multiple toggles during the restart window.
Fix main.ts Speaker button:
- Remove the `.muted` classList toggle (added `.speaker-on` for CSS).
- Update label text to "🔊 Speaker" / "🔈 Earpiece" for clarity.
- On showCallScreen(), invoke is_speakerphone_on to sync the label
with the real AudioManager state, so it matches reality after a
rejoin (which was another symptom the user hit — the button label
desynced from the actual routing after ending and restarting a
call).
- Debounce click + disable button while the restart is in flight.
Drops #[allow(dead_code)] from wzp_native::audio_is_running now that it
is actually called from the set_speakerphone restart guard.
Build 9e37201 confirmed on-device that Usage::VoiceCommunication +
MODE_IN_COMMUNICATION + speakerphoneOn=false routes Oboe playout to the
handset earpiece and the callback drains the ring correctly. Next step:
let the user flip speakerphoneOn at runtime so the existing Speaker
button actually switches audio routing instead of just gating writes.
- Cargo.toml (android target): pull in `jni = 0.21` and
`ndk-context = 0.1`. Both are already transitively in the lockfile
via Tauri/Wry, so this just promotes them to direct deps.
- desktop/src-tauri/src/android_audio.rs: new module. Grabs the JavaVM +
current Activity from `ndk_context::android_context()`, attaches a
JNI thread, calls `activity.getSystemService("audio")` to get the
AudioManager, and exposes `set_speakerphone(bool)` +
`is_speakerphone_on()` helpers that call the AudioManager method of
the same name. All gated behind `#[cfg(target_os = "android")]`.
- lib.rs: adds `mod android_audio;` (android only), two new Tauri
commands `set_speakerphone(on)` and `is_speakerphone_on()` — desktop
gets no-op stubs so the same frontend invoke() works everywhere.
Both registered in the invoke_handler.
- desktop/src/main.ts: the Speaker button (previously toggled the
playout-write gate via `toggle_speaker`) now calls `set_speakerphone`
and reads back the new routing state. Labels switched from
"Spk" / "Spk Off" to "Earpiece" / "Speaker" so users can't be
confused into thinking clicking turns audio off. pollStatus no longer
clobbers the spkBtn label based on engine spk_muted, since the two
concepts are now decoupled.
WIP because this has NOT been built or tested yet — committing at night
to save the work. Tomorrow: build #50 with this change, smoke-test the
Handset↔Speaker toggle, then move on to call history + last-contacts UI
and the Speaker-button mute bug on the other phone.
Adds 172.16.81.125:4433 (the laptop's LAN IP) as the first default relay
so the Android rewrite can be tested against a relay whose logs are on the
same host as the builds and screenshots. On fresh installs the Laptop
relay is pre-selected as index 0. On upgrades from an older cached
settings blob, a one-shot migration unshifts it to the front if missing,
so we don't have to tap through Manage Relays after every reinstall.
Marked "remove once Android rewrite is stable" — the address is a hardcoded
LAN IP that won't be valid in other environments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three home-screen issues from the first Tauri Android APK:
1. Alias was empty (no seed-derived name).
Port the adjective+noun word lists from the old Kotlin SettingsRepository
into a `derive_alias()` helper that maps the first 4 bytes of the seed to
indices in those lists. Same seed → same alias forever, different seeds →
effectively random aliases — so reinstalls keep the user's identity AND
the friendly name they're used to.
2. Build identity was invisible — couldn't tell which APK was actually
installed (this caused us a lot of grief on the Kotlin app).
build.rs now captures `git rev-parse --short HEAD` and emits it as
`WZP_GIT_HASH`, exposed via a new `get_app_info` command. The frontend
stamps `build <hash> • <alias>` under the fingerprint on the home screen.
3. Register on relay failed with `Permission denied (os error 13)`.
Root cause: I hardcoded `/data/data/com.wzp.phone/files/.wzp` as the
identity dir, but the Tauri Android package id is `com.wzp.desktop` —
so the app was trying to write into another app's data directory and
getting EACCES at the filesystem layer. Fix: resolve the data dir from
Tauri's `path().app_data_dir()` API in the `setup()` callback and stash
it in a `OnceLock<PathBuf>`. Works on Android, macOS, Linux, Windows
without any cfg gymnastics.
Also: `get_app_info` returns the resolved `data_dir` so we can debug
storage issues from the UI (it's set as the build-hash element's title).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Desktop now shows codec badges like Android:
- Green TX badge: e.g. "Opus64k"
- Blue RX badge: e.g. "Opus24k"
Displayed in the stats line below the call controls.
Engine tracks tx_codec (set on encoder init) and rx_codec (updated
from incoming packet headers). Passed through EngineStatus → CallStatus
→ frontend.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
RoomParticipant now has optional relay_label field. Desktop client
groups participants by relay: "This Relay" (green dot) for local,
peer label (blue dot) for federated. Shows all relays in the chain
including intermediate ones.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds three new codec IDs (Opus32k=6, Opus48k=7, Opus64k=8) and
corresponding STUDIO_32K, STUDIO_48K, STUDIO_64K quality profiles.
All use 20ms frames with minimal FEC (10%) for maximum quality on
good networks.
Updated across: wire protocol (codec_id.rs), encoder/decoder
(opus_enc/dec.rs), adaptive codec switch (call.rs), CLI
(--profile studio-64k), desktop engine + UI slider (8 quality
levels from Studio 64k green to Codec2 1.2k red).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the relay's server key changes (e.g. after restart), show a
styled in-app warning dialog instead of the ugly browser confirm().
The dialog shows old vs new fingerprints and lets the user accept
the new key or cancel. Accepting updates the saved fingerprint and
refreshes the relay button state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the quality dropdown with a range slider in the settings
panel. The slider goes from Auto (green) through Opus 24k, Opus 6k
(yellow), Codec2 3.2k (orange) to Codec2 1.2k (dark red). The
track uses a green-to-red gradient and the label color updates
to match the selected level. Removed the quality dropdown from
the connect screen — quality is now settings-only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a Quality dropdown (Auto / Opus 24k / Opus 6k / Codec2 3.2k /
Codec2 1.2k) to both the connect screen and settings panel. The
selected profile is passed through to the engine which configures
the encoder and decoder accordingly.
The desktop engine recv path now auto-switches the decoder codec
when incoming packets use a different codec than expected, enabling
cross-codec interop between clients on different quality settings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server may be reachable even if ping failed (transient timeout).
User should always be able to try connecting. Fingerprint change
still shows confirm dialog (accept/reject).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Identicon generator:
- Deterministic 5x5 symmetric pattern from fingerprint hash
- HSL-derived colors, rendered as inline SVG
- Click any identicon to copy its fingerprint to clipboard
- Used for participants, user identity, and relay servers
Server identity (TOFU — Trust On First Use):
- Ping returns server fingerprint (QUIC peer certificate hash)
- First contact: auto-saved as known fingerprint
- Subsequent pings: compared against known fingerprint
- Lock icons: locked (verified), unlocked (new), warning (changed), red (offline)
- Fingerprint mismatch shows confirmation dialog before connecting
UI updates:
- Participants show identicons instead of letter avatars
- User identity shows identicon + fingerprint on connect screen
- Manage Relays shows identicon per server with lock status
- Relay button shows lock icon instead of colored dot
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Click relay button opens Manage Relays dialog directly (no dropdown)
- Click a relay in the dialog to select it (highlighted with accent border)
- × button to delete, Add Relay button to add new
- Removed all dropdown menu code and CSS
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dialog fits within 360px window (was overflowing at 420px)
- Add inputs stacked: name + host:port in a row, "Add Relay" button below
- Text overflow with ellipsis on relay names and addresses
- Proper min-width: 0 on flex children to prevent overflow
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Relay selector as dropdown with green/yellow/red status dots
(green < 200ms, yellow > 200ms, red = offline, gray = unknown)
- All relays pinged on startup, RTT shown next to each
- "Manage Relays..." dialog: add/remove servers, see live status
- Clicking a relay in dropdown selects it, fills connect form
- Recent room chips auto-select matching relay
- Migrates old single-relay settings format automatically
- Prevents connecting to offline relays
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New ping_relay Tauri command: QUIC connect with 3s timeout, returns RTT ms
- Relay status shown next to input field: "42ms" (green) or "offline" (red)
- Auto-pings on app startup and debounced on relay input change
- Fix SyncWrapper dead_code warning with #[allow(dead_code)]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#7 Fingerprint shown before connecting — new get_identity command reads
~/.wzp/identity at startup (generates if missing). Click to copy.
#8 Recent rooms store (relay, room) pairs — clicking a chip fills both
fields. Settings panel shows relay alongside room name. Migrates
old string[] format automatically.
#9 Auto-reconnect on unexpected disconnect — exponential backoff
(1s, 2s, 4s... max 10s), up to 5 attempts. Yellow blinking dot
shows reconnecting state. Stops if user clicks hangup.
#10 Audio handle cleanup — CPAL handles stored in SyncWrapper (no more
mem::forget), dropped properly on CallEngine::stop().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Change status() from blocking_lock to async lock().await —
fixes "Cannot block the current thread from within a runtime" panic
that froze the call timer and broke audio
- Click fingerprint to copy to clipboard (both connect and settings screens)
- Show "Copied!" feedback on click
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Full settings page as modal overlay (blur backdrop)
- Opens via gear icon on connect/call screens or Cmd+, (Ctrl+, on Win/Linux)
- Escape or click outside to close
- Settings: relay, room, alias, OS AEC toggle, AGC toggle
- Identity section showing fingerprint and identity file path
- Recent rooms management (remove individual, clear all)
- Save syncs back to connect form
- Gear icon on both connect and in-call screens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Audio level meter with log-scale RMS visualization
- Call duration timer
- VPIO (OS AEC) wired through to engine with fallback to CPAL
- "You" badge on own participant entry
- Recent rooms list (click to reuse)
- Enter key to connect from form fields
- Improved dark theme with pulse animation on status dot
- Settings persistence via localStorage (relay, room, alias, AEC, recent rooms)
- Fingerprint display on connect screen
- Keyboard shortcuts skip input fields
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- New desktop/ directory with Tauri v2 + Vite + TypeScript
- Rust backend: CallEngine wrapping wzp-client audio + transport
- Web frontend: connect screen, in-call screen with participants,
mic/speaker mute, keyboard shortcuts (m/s/q)
- Dark theme UI, settings persistence via localStorage
- Platform-aware --os-aec: warns on Windows/Linux (not yet implemented)
- Workspace updated to include desktop/src-tauri
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>