From f9c6b75897311f96b84930a2047ea5cfc6f67a07 Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 19 Apr 2026 05:34:32 +0200 Subject: [PATCH 1/4] feat: send chat messages via helix wires a Tauri command through the sidecar control plane to POST /helix/chat/messages, with a bottom input bar for sending --- .../src-sidecar/internal/control/control.go | 5 + .../src-sidecar/internal/sidecar/sidecar.go | 80 ++++++++ .../src-sidecar/internal/twitch/helix.go | 52 +++++ .../src-sidecar/internal/twitch/helix_test.go | 85 ++++++++ apps/desktop/src-tauri/src/host.rs | 37 ++++ apps/desktop/src-tauri/src/lib.rs | 16 +- .../desktop/src-tauri/src/sidecar_commands.rs | 182 ++++++++++++++++++ .../src-tauri/src/sidecar_supervisor.rs | 36 ++-- apps/desktop/src/App.tsx | 2 + apps/desktop/src/components/MessageInput.tsx | 135 +++++++++++++ apps/desktop/src/lib/messageInput.test.ts | 65 +++++++ apps/desktop/src/lib/messageInput.ts | 60 ++++++ apps/desktop/src/lib/twitchAuth.ts | 18 ++ 13 files changed, 758 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src-tauri/src/sidecar_commands.rs create mode 100644 apps/desktop/src/components/MessageInput.tsx create mode 100644 apps/desktop/src/lib/messageInput.test.ts create mode 100644 apps/desktop/src/lib/messageInput.ts diff --git a/apps/desktop/src-sidecar/internal/control/control.go b/apps/desktop/src-sidecar/internal/control/control.go index e731150..2c3feed 100644 --- a/apps/desktop/src-sidecar/internal/control/control.go +++ b/apps/desktop/src-sidecar/internal/control/control.go @@ -55,6 +55,11 @@ type Command struct { Reason string `json:"reason,omitempty"` MessageID string `json:"message_id,omitempty"` + // Message body for send_chat_message. Capped at 500 bytes by Twitch's + // Helix POST /chat/messages endpoint; the sidecar enforces the limit + // before issuing the request. + Message string `json:"message,omitempty"` + // YouTube fields VideoID string `json:"video_id,omitempty"` LiveChatID string `json:"live_chat_id,omitempty"` diff --git a/apps/desktop/src-sidecar/internal/sidecar/sidecar.go b/apps/desktop/src-sidecar/internal/sidecar/sidecar.go index a6be5ab..705b302 100644 --- a/apps/desktop/src-sidecar/internal/sidecar/sidecar.go +++ b/apps/desktop/src-sidecar/internal/sidecar/sidecar.go @@ -288,6 +288,8 @@ func DispatchCommand(ctx context.Context, cmd control.Command, clients map[strin HandleTimeoutUser(cmd, logger) case "delete_message": HandleDeleteMessage(cmd, logger) + case "send_chat_message": + HandleSendChatMessage(ctx, cmd, notify, logger) default: logger.Info().Str("cmd", cmd.Cmd).Str("channel", cmd.Channel).Msg("received command") } @@ -386,6 +388,84 @@ func HandleDeleteMessage(cmd control.Command, logger zerolog.Logger) { Msg("delete_message (scaffold: no Helix call yet)") } +// SendChatResultPayload is the body of a `send_chat_result` notification +// emitted to the host after a send_chat_message attempt. The frontend uses +// this to surface failures (drop reasons, auth errors) without having to +// poll any other state. +type SendChatResultPayload struct { + Ok bool `json:"ok"` + MessageID string `json:"message_id,omitempty"` + DropCode string `json:"drop_code,omitempty"` + DropMessage string `json:"drop_message,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` +} + +// HandleSendChatMessage posts the user's message to Twitch via Helix and +// emits a `send_chat_result` notification with either the assigned +// message_id or the drop reason / transport error. Validation mirrors the +// Helix endpoint so obvious misuse (empty fields, oversized body) fails +// without consuming a request. +func HandleSendChatMessage(ctx context.Context, cmd control.Command, notify twitch.Notify, logger zerolog.Logger) { + if cmd.BroadcasterID == "" || cmd.UserID == "" || cmd.ClientID == "" || cmd.Token == "" { + logger.Warn(). + Str("broadcaster", cmd.BroadcasterID). + Str("user", cmd.UserID). + Msg("send_chat_message missing required field; ignoring") + notify("send_chat_result", SendChatResultPayload{ + Ok: false, + ErrorMessage: "missing broadcaster, user, client_id, or token", + }) + return + } + if cmd.Message == "" { + notify("send_chat_result", SendChatResultPayload{ + Ok: false, + ErrorMessage: "empty message", + }) + return + } + if len(cmd.Message) > twitch.MaxChatMessageBytes { + notify("send_chat_result", SendChatResultPayload{ + Ok: false, + ErrorMessage: fmt.Sprintf("message exceeds %d bytes", twitch.MaxChatMessageBytes), + }) + return + } + client := &twitch.HelixClient{ + ClientID: cmd.ClientID, + AccessToken: cmd.Token, + } + resp, err := client.SendChatMessage(ctx, cmd.BroadcasterID, cmd.UserID, cmd.Message) + if err != nil { + logger.Warn().Err(err).Str("broadcaster", cmd.BroadcasterID).Msg("send_chat_message failed") + notify("send_chat_result", SendChatResultPayload{ + Ok: false, + ErrorMessage: err.Error(), + }) + return + } + if len(resp.Data) == 0 { + notify("send_chat_result", SendChatResultPayload{ + Ok: false, + ErrorMessage: "empty response from helix", + }) + return + } + first := resp.Data[0] + if !first.IsSent { + notify("send_chat_result", SendChatResultPayload{ + Ok: false, + DropCode: first.DropReason.Code, + DropMessage: first.DropReason.Message, + }) + return + } + notify("send_chat_result", SendChatResultPayload{ + Ok: true, + MessageID: first.MessageID, + }) +} + // HandleTwitchConnect spawns a Twitch EventSub client for the broadcaster in // cmd if there isn't already one running. The client writes envelope bytes to // `out`, which the writer goroutine drains into the ring buffer. diff --git a/apps/desktop/src-sidecar/internal/twitch/helix.go b/apps/desktop/src-sidecar/internal/twitch/helix.go index 2a450ff..cdf5ed4 100644 --- a/apps/desktop/src-sidecar/internal/twitch/helix.go +++ b/apps/desktop/src-sidecar/internal/twitch/helix.go @@ -265,3 +265,55 @@ type AuthError struct { func (e *AuthError) Error() string { return fmt.Sprintf("twitch auth error (%d): %s", e.Status, e.Body) } + +// Helix limit on a single chat message body. Messages longer than this are +// rejected with HTTP 400 by Twitch, so we mirror it locally to fail fast +// without consuming a Helix call. +const MaxChatMessageBytes = 500 + +// SendChatMessageRequest is the body Twitch's POST /chat/messages expects. +// `BroadcasterID` is the channel; `SenderID` must match the authenticated +// user on the access token. `ReplyParentMessageID` is omitted for top-level +// sends — reply support lives behind a follow-up control field. +type SendChatMessageRequest struct { + BroadcasterID string `json:"broadcaster_id"` + SenderID string `json:"sender_id"` + Message string `json:"message"` +} + +// SendChatMessageResponse is the envelope Twitch returns on 200. We surface +// the per-send drop reason (e.g. AutoMod, channel followers-only) so the UI +// can tell the user why a message did not appear. +type SendChatMessageResponse struct { + Data []struct { + MessageID string `json:"message_id"` + IsSent bool `json:"is_sent"` + DropReason struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"drop_reason"` + } `json:"data"` +} + +// SendChatMessage posts a chat message via Helix and returns the parsed +// response. Caller is responsible for passing a non-empty message that fits +// in [MaxChatMessageBytes]; this method validates length up front to avoid +// a wasted round-trip. +func (c *HelixClient) SendChatMessage(ctx context.Context, broadcasterID, senderID, message string) (*SendChatMessageResponse, error) { + if message == "" { + return nil, errors.New("twitch helix: empty chat message") + } + if len(message) > MaxChatMessageBytes { + return nil, fmt.Errorf("twitch helix: chat message exceeds %d bytes", MaxChatMessageBytes) + } + req := SendChatMessageRequest{ + BroadcasterID: broadcasterID, + SenderID: senderID, + Message: message, + } + var resp SendChatMessageResponse + if err := c.Post(ctx, "/chat/messages", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} diff --git a/apps/desktop/src-sidecar/internal/twitch/helix_test.go b/apps/desktop/src-sidecar/internal/twitch/helix_test.go index 9eaaa0e..5b36324 100644 --- a/apps/desktop/src-sidecar/internal/twitch/helix_test.go +++ b/apps/desktop/src-sidecar/internal/twitch/helix_test.go @@ -83,3 +83,88 @@ func TestSubscribeServerError(t *testing.T) { t.Fatal("500 should not be an AuthError") } } + +func TestSendChatMessageSuccess(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + if r.URL.Path != "/chat/messages" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + var req SendChatMessageRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode body: %v", err) + } + if req.BroadcasterID != "b1" || req.SenderID != "u1" || req.Message != "hello" { + t.Fatalf("unexpected body: %+v", req) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[{"message_id":"abc","is_sent":true}]}`)) + })) + defer srv.Close() + + c := &HelixClient{BaseURL: srv.URL, ClientID: "cid", AccessToken: "tok"} + resp, err := c.SendChatMessage(context.Background(), "b1", "u1", "hello") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Data) != 1 || !resp.Data[0].IsSent || resp.Data[0].MessageID != "abc" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func TestSendChatMessageDropped(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[{"message_id":"","is_sent":false,"drop_reason":{"code":"msg_duplicate","message":"duplicate"}}]}`)) + })) + defer srv.Close() + + c := &HelixClient{BaseURL: srv.URL, ClientID: "cid", AccessToken: "tok"} + resp, err := c.SendChatMessage(context.Background(), "b1", "u1", "hello") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.Data[0].IsSent { + t.Fatal("expected dropped") + } + if resp.Data[0].DropReason.Code != "msg_duplicate" { + t.Fatalf("unexpected drop code: %q", resp.Data[0].DropReason.Code) + } +} + +func TestSendChatMessageEmpty(t *testing.T) { + c := &HelixClient{ClientID: "cid", AccessToken: "tok"} + if _, err := c.SendChatMessage(context.Background(), "b1", "u1", ""); err == nil { + t.Fatal("expected error for empty message") + } +} + +func TestSendChatMessageOversize(t *testing.T) { + c := &HelixClient{ClientID: "cid", AccessToken: "tok"} + big := make([]byte, MaxChatMessageBytes+1) + for i := range big { + big[i] = 'a' + } + if _, err := c.SendChatMessage(context.Background(), "b1", "u1", string(big)); err == nil { + t.Fatal("expected error for oversized message") + } +} + +func TestSendChatMessageUnauthorized(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"Unauthorized","status":401,"message":"Missing scope: user:write:chat"}`)) + })) + defer srv.Close() + + c := &HelixClient{BaseURL: srv.URL, ClientID: "cid", AccessToken: "tok"} + _, err := c.SendChatMessage(context.Background(), "b1", "u1", "hello") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, ErrUnauthorized) { + t.Fatalf("expected ErrUnauthorized, got %v", err) + } +} diff --git a/apps/desktop/src-tauri/src/host.rs b/apps/desktop/src-tauri/src/host.rs index e07f013..b84e3a6 100644 --- a/apps/desktop/src-tauri/src/host.rs +++ b/apps/desktop/src-tauri/src/host.rs @@ -184,6 +184,43 @@ pub fn build_kick_connect_line(chatroom_id: i64) -> serde_json::Result> Ok(bytes) } +/// Arguments for [`build_send_chat_message_line`]. All fields are +/// borrowed so the caller doesn't have to clone its credentials just to +/// build a control line. +pub struct SendChatMessageArgs<'a> { + pub client_id: &'a str, + pub access_token: &'a str, + pub broadcaster_id: &'a str, + pub user_id: &'a str, + pub message: &'a str, +} + +/// Serializes a `send_chat_message` control command line for the sidecar. +/// The Go side validates message length and emptiness against the same +/// 500-byte Helix cap; this builder is purely a transport encoder. +pub fn build_send_chat_message_line(args: SendChatMessageArgs<'_>) -> serde_json::Result> { + #[derive(Serialize)] + struct SendCmd<'a> { + cmd: &'a str, + client_id: &'a str, + token: &'a str, + broadcaster_id: &'a str, + user_id: &'a str, + message: &'a str, + } + let cmd = SendCmd { + cmd: "send_chat_message", + client_id: args.client_id, + token: args.access_token, + broadcaster_id: args.broadcaster_id, + user_id: args.user_id, + message: args.message, + }; + let mut bytes = serde_json::to_vec(&cmd)?; + bytes.push(b'\n'); + Ok(bytes) +} + /// Marks a shared memory HANDLE inheritable just before spawning a child /// process. See ADR 18 for why this is necessary. #[cfg(windows)] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 84c62b7..eb6f114 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod host; mod message; pub mod ringbuf; +mod sidecar_commands; mod sidecar_supervisor; pub mod twitch_auth; @@ -51,6 +52,7 @@ pub fn run() { twitch_auth::commands::twitch_complete_login, twitch_auth::commands::twitch_cancel_login, twitch_auth::commands::twitch_logout, + sidecar_commands::twitch_send_message, ]) .setup(setup) .run(tauri::generate_context!()) @@ -68,6 +70,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box(app: &mut tauri::App) -> Result<(), Box>>` +//! holding the live [`tauri_plugin_shell::process::CommandChild`]. The +//! supervisor publishes the child after a successful spawn + bootstrap +//! and clears it on termination (or never publishes it on platforms where +//! the sidecar isn't supported). Commands fail fast with a structured +//! error when no child is alive instead of blocking on a vanished pipe. + +use std::sync::{Arc, Mutex}; + +use serde::Serialize; +use tauri::State; + +#[cfg(windows)] +use tauri_plugin_shell::process::CommandChild; + +use crate::host::{build_send_chat_message_line, SendChatMessageArgs}; +use crate::twitch_auth::{AuthError, AuthState, TWITCH_CLIENT_ID}; + +/// Shared handle the supervisor uses to publish the live sidecar child +/// and that command handlers use to write control lines into its stdin. +/// On non-Windows builds the inner type degrades to `()` so the +/// supervisor's call sites compile without `#[cfg]` everywhere; commands +/// always return [`SendCommandError::SidecarNotRunning`] there. +#[derive(Default, Clone)] +pub struct SidecarCommandSender { + #[cfg(windows)] + inner: Arc>>, + #[cfg(not(windows))] + inner: Arc>, +} + +impl SidecarCommandSender { + /// Publishes the live child. Called by the supervisor right after + /// the bootstrap + initial connect lines have been written so the + /// child is fully ready to accept commands. Replaces any previous + /// child handle (e.g. carried over from a respawn) and drops it, + /// which closes the prior stdin pipe. + #[cfg(windows)] + pub fn publish(&self, child: CommandChild) { + *self.inner.lock().expect("sidecar sender mutex poisoned") = Some(child); + } + + /// Clears the child handle. Called by the supervisor when the child + /// terminates or when the heartbeat-timeout path needs to take + /// ownership for an explicit `kill`. + #[cfg(windows)] + pub fn clear(&self) -> Option { + self.inner + .lock() + .expect("sidecar sender mutex poisoned") + .take() + } + + /// Writes a single newline-terminated command line to the child's + /// stdin. Errors propagate from the underlying pipe write so callers + /// can map them to user-facing failures. + #[cfg(windows)] + fn write_line(&self, bytes: &[u8]) -> Result<(), SendCommandError> { + let mut guard = self.inner.lock().expect("sidecar sender mutex poisoned"); + let child = guard.as_mut().ok_or(SendCommandError::SidecarNotRunning)?; + child.write(bytes).map_err(|e| SendCommandError::Io { + message: e.to_string(), + }) + } + + #[cfg(not(windows))] + fn write_line(&self, _bytes: &[u8]) -> Result<(), SendCommandError> { + Err(SendCommandError::SidecarNotRunning) + } +} + +/// Frontend-facing error for `twitch_send_message` and any future +/// command. `kind` is a stable string the UI matches against; `message` +/// is a human-readable diagnostic. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SendCommandError { + NotLoggedIn { message: String }, + EmptyMessage, + MessageTooLong { max_bytes: usize }, + SidecarNotRunning, + Io { message: String }, + Auth { message: String }, + Json { message: String }, +} + +impl SendCommandError { + fn auth(err: AuthError) -> Self { + match err { + AuthError::NoTokens | AuthError::RefreshTokenInvalid => Self::NotLoggedIn { + message: err.to_string(), + }, + other => Self::Auth { + message: other.to_string(), + }, + } + } +} + +/// Maximum chat message length accepted by Twitch Helix POST +/// /chat/messages. Mirrored on the Rust side so we reject oversized +/// payloads before they cross the IPC boundary. +pub const MAX_CHAT_MESSAGE_BYTES: usize = 500; + +#[tauri::command] +pub async fn twitch_send_message( + auth: State<'_, AuthState>, + sender: State<'_, SidecarCommandSender>, + text: String, +) -> Result<(), SendCommandError> { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err(SendCommandError::EmptyMessage); + } + if trimmed.len() > MAX_CHAT_MESSAGE_BYTES { + return Err(SendCommandError::MessageTooLong { + max_bytes: MAX_CHAT_MESSAGE_BYTES, + }); + } + + let tokens = auth + .manager + .load_or_refresh() + .await + .map_err(SendCommandError::auth)?; + + let line = build_send_chat_message_line(SendChatMessageArgs { + client_id: TWITCH_CLIENT_ID, + access_token: &tokens.access_token, + broadcaster_id: &tokens.user_id, + user_id: &tokens.user_id, + message: trimmed, + }) + .map_err(|e| SendCommandError::Json { + message: e.to_string(), + })?; + + sender.write_line(&line) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_mapping_no_tokens_is_not_logged_in() { + let mapped = SendCommandError::auth(AuthError::NoTokens); + match mapped { + SendCommandError::NotLoggedIn { .. } => {} + other => panic!("expected NotLoggedIn, got {other:?}"), + } + } + + #[test] + fn auth_mapping_refresh_invalid_is_not_logged_in() { + let mapped = SendCommandError::auth(AuthError::RefreshTokenInvalid); + match mapped { + SendCommandError::NotLoggedIn { .. } => {} + other => panic!("expected NotLoggedIn, got {other:?}"), + } + } + + #[test] + fn auth_mapping_other_is_auth() { + let mapped = SendCommandError::auth(AuthError::OAuth("boom".into())); + match mapped { + SendCommandError::Auth { .. } => {} + other => panic!("expected Auth, got {other:?}"), + } + } + + #[tokio::test] + async fn write_without_child_returns_not_running() { + let sender = SidecarCommandSender::default(); + let err = sender.write_line(b"x\n").expect_err("must error"); + assert!(matches!(err, SendCommandError::SidecarNotRunning)); + } +} diff --git a/apps/desktop/src-tauri/src/sidecar_supervisor.rs b/apps/desktop/src-tauri/src/sidecar_supervisor.rs index e8a73e6..87f8fe5 100644 --- a/apps/desktop/src-tauri/src/sidecar_supervisor.rs +++ b/apps/desktop/src-tauri/src/sidecar_supervisor.rs @@ -44,6 +44,8 @@ use crate::message::UnifiedMessage; #[cfg(windows)] use crate::ringbuf::{RawHandle, RingBufReader, WaitOutcome, DEFAULT_CAPACITY}; #[cfg(windows)] +use crate::sidecar_commands::SidecarCommandSender; +#[cfg(windows)] use crate::twitch_auth::{AuthError, AuthManager, TWITCH_CLIENT_ID}; #[cfg(windows)] use tokio::sync::Notify; @@ -105,10 +107,15 @@ pub struct SidecarStatus { /// while idle in `waiting_for_auth` so a successful sign-in starts the /// sidecar within milliseconds instead of waiting out the 30 s poll. #[cfg(windows)] -pub fn spawn(app: AppHandle, auth: Arc, wakeup: Arc) { +pub fn spawn( + app: AppHandle, + auth: Arc, + wakeup: Arc, + sender: SidecarCommandSender, +) { let cfg = SupervisorConfig::default(); tauri::async_runtime::spawn(async move { - supervise(app, cfg, auth, wakeup).await; + supervise(app, cfg, auth, wakeup, sender).await; }); } @@ -118,6 +125,7 @@ async fn supervise( cfg: SupervisorConfig, auth: Arc, wakeup: Arc, + sender: SidecarCommandSender, ) { // `client_id` lives in the shared AuthManager; broadcaster/user // identifiers ride inside the persisted [`TwitchTokens`] itself @@ -171,10 +179,14 @@ async fn supervise( }; let started = Instant::now(); - match run_once(&app, &cfg, attempt, Some(&creds)).await { + match run_once(&app, &cfg, attempt, Some(&creds), &sender).await { Ok(()) => tracing::info!(attempt, "sidecar iteration ended"), Err(e) => tracing::error!(error = %e, attempt, "sidecar iteration failed"), } + // Always clear any lingering child handle once the iteration + // ends, even on error paths above. Drops the CommandChild and + // closes its stdin so the next iteration starts from a clean slate. + let _ = sender.clear(); if started.elapsed() >= cfg.healthy_threshold { backoff = cfg.initial_backoff; @@ -245,6 +257,7 @@ async fn run_once( cfg: &SupervisorConfig, attempt: u32, creds: Option<&TwitchCreds>, + sender: &SidecarCommandSender, ) -> Result<(), Box> { let reader = RingBufReader::create_owner(DEFAULT_CAPACITY)?; let handle = reader.raw_handle(); @@ -305,14 +318,13 @@ async fn run_once( } // Disarm the kill-on-drop: the CommandEvent stream now owns the - // child's lifecycle. Hold the released CommandChild in `child` for - // the rest of the function so its stdin stays open for the duration - // of the session (dropping it mid-session would close stdin and - // strand the control protocol). It also lets us force-kill on a - // heartbeat timeout without waiting for Tauri's Drop path. Wrapped - // in Option so the heartbeat-timeout branch can take ownership for - // the `kill(self)` call. - let mut child = Some(child.release()); + // child's lifecycle. Publish the released CommandChild into the + // shared sender so Tauri commands can write control lines into its + // stdin for the duration of the session. The heartbeat-timeout + // branch below pulls it back out via `sender.clear()` for an + // explicit kill, and the outer `supervise` loop also clears any + // lingering handle once the iteration ends. + sender.publish(child.release()); // EmoteIndex lives for the lifetime of this sidecar run; a fresh one is // built on every respawn. Shared by the control-plane reader (which @@ -336,7 +348,7 @@ async fn run_once( attempt, |bytes| handle_sidecar_stdout(bytes, app, &emote_index), || { - if let Some(c) = child.take() { + if let Some(c) = sender.clear() { if let Err(e) = c.kill() { tracing::error!(error = %e, "kill after heartbeat timeout failed"); } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 29d3477..1d757cb 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,7 @@ import { Component, Match, Switch, createSignal, onMount } from "solid-js"; import ChatFeed from "./components/ChatFeed"; import Header from "./components/Header"; +import MessageInput from "./components/MessageInput"; import SignIn from "./components/SignIn"; import { getAuthStatus, type AuthStatus } from "./lib/twitchAuth"; @@ -54,6 +55,7 @@ const App: Component = () => { <>
+ )} diff --git a/apps/desktop/src/components/MessageInput.tsx b/apps/desktop/src/components/MessageInput.tsx new file mode 100644 index 0000000..cf023c8 --- /dev/null +++ b/apps/desktop/src/components/MessageInput.tsx @@ -0,0 +1,135 @@ +// Chat send input. Single-line textarea-like input pinned below the +// message feed. Enter sends, Shift+Enter inserts a newline (Helix +// permits multi-line bodies), and the inline status row surfaces drop +// reasons or transport errors from the Tauri command. + +import { Component, Show, createSignal } from "solid-js"; +import { + MAX_CHAT_MESSAGE_BYTES, + sendMessage, + type SendMessageError, +} from "../lib/twitchAuth"; +import { + fitsLimit, + formatSendError, + normalizeOutgoing, + toSendError, +} from "../lib/messageInput"; + +const MessageInput: Component = () => { + const [text, setText] = createSignal(""); + const [status, setStatus] = createSignal(null); + const [pending, setPending] = createSignal(false); + let inputEl: HTMLInputElement | undefined; + + const submit = async () => { + if (pending()) return; + const payload = normalizeOutgoing(text()); + if (!payload) { + setStatus("Message is empty."); + return; + } + if (!fitsLimit(payload)) { + setStatus(`Message exceeds ${MAX_CHAT_MESSAGE_BYTES} bytes.`); + return; + } + setPending(true); + setStatus(null); + try { + await sendMessage(payload); + setText(""); + inputEl?.focus(); + } catch (raw) { + const err = toSendError(raw); + setStatus( + typeof err === "string" + ? err + : formatSendError(err as SendMessageError), + ); + } finally { + setPending(false); + } + }; + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + void submit(); + } + }; + + return ( +
+
+ (inputEl = el)} + type="text" + value={text()} + placeholder="Send a message" + disabled={pending()} + onInput={(e) => setText(e.currentTarget.value)} + onKeyDown={onKeyDown} + style={{ + flex: "1 1 auto", + "background-color": "#0e0e10", + color: "#efeff1", + border: "1px solid #2a2a2d", + "border-radius": "4px", + padding: "6px 10px", + "font-family": + 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif', + "font-size": "13px", + outline: "none", + }} + /> + +
+ +
+ {status()} +
+
+
+ ); +}; + +export default MessageInput; diff --git a/apps/desktop/src/lib/messageInput.test.ts b/apps/desktop/src/lib/messageInput.test.ts new file mode 100644 index 0000000..7b5b1ae --- /dev/null +++ b/apps/desktop/src/lib/messageInput.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + fitsLimit, + formatSendError, + normalizeOutgoing, + toSendError, +} from "./messageInput"; + +describe("normalizeOutgoing", () => { + it("returns null for empty/whitespace", () => { + expect(normalizeOutgoing("")).toBeNull(); + expect(normalizeOutgoing(" ")).toBeNull(); + expect(normalizeOutgoing("\n\t")).toBeNull(); + }); + + it("trims surrounding whitespace", () => { + expect(normalizeOutgoing(" hello ")).toBe("hello"); + }); +}); + +describe("fitsLimit", () => { + it("accepts short ascii", () => { + expect(fitsLimit("hello")).toBe(true); + }); + + it("rejects payloads larger than 500 bytes", () => { + expect(fitsLimit("a".repeat(501))).toBe(false); + }); + + it("counts utf-8 bytes, not code units", () => { + // Each emoji is 4 bytes in UTF-8; 126 of them = 504 bytes. + expect(fitsLimit("🔥".repeat(126))).toBe(false); + expect(fitsLimit("🔥".repeat(125))).toBe(true); + }); +}); + +describe("formatSendError", () => { + it("formats each variant", () => { + expect(formatSendError({ kind: "empty_message" })).toMatch(/empty/i); + expect( + formatSendError({ kind: "message_too_long", max_bytes: 500 }), + ).toContain("500"); + expect(formatSendError({ kind: "sidecar_not_running" })).toMatch(/ready/i); + expect(formatSendError({ kind: "not_logged_in", message: "x" })).toMatch( + /sign in/i, + ); + expect(formatSendError({ kind: "auth", message: "boom" })).toContain( + "boom", + ); + expect(formatSendError({ kind: "io", message: "pipe" })).toContain("pipe"); + expect(formatSendError({ kind: "json", message: "bad" })).toContain("bad"); + }); +}); + +describe("toSendError", () => { + it("passes through structured errors", () => { + const err = { kind: "empty_message" }; + expect(toSendError(err)).toBe(err); + }); + + it("stringifies unknown shapes", () => { + expect(toSendError("nope")).toBe("nope"); + expect(toSendError(null)).toBe("null"); + }); +}); diff --git a/apps/desktop/src/lib/messageInput.ts b/apps/desktop/src/lib/messageInput.ts new file mode 100644 index 0000000..1a99247 --- /dev/null +++ b/apps/desktop/src/lib/messageInput.ts @@ -0,0 +1,60 @@ +// Pure formatting + validation helpers for the chat send input. Kept +// out of the Solid component so they're testable in jsdom without +// pulling in @tauri-apps/api. + +import { MAX_CHAT_MESSAGE_BYTES, type SendMessageError } from "./twitchAuth"; + +// Trim whitespace and reject blank input. Returns either the trimmed +// payload or null. The Tauri command also rejects blank input, but +// catching it locally keeps the UI snappy and avoids a needless RPC. +export function normalizeOutgoing(raw: string): string | null { + const trimmed = raw.trim(); + if (trimmed.length === 0) return null; + return trimmed; +} + +// True if the encoded message fits inside Twitch's 500-byte cap. +// Counts UTF-8 bytes rather than JS string length so users typing in +// emoji, Cyrillic, etc. see the same limit Helix enforces. +export function fitsLimit(text: string): boolean { + return new TextEncoder().encode(text).byteLength <= MAX_CHAT_MESSAGE_BYTES; +} + +// Maps a structured Tauri command error into a short human message +// suitable for an inline status line under the input. Falls back to +// the raw `message` field for anything we don't have a tailored copy +// for, since the backend already includes a useful diagnostic. +export function formatSendError(err: SendMessageError): string { + switch (err.kind) { + case "not_logged_in": + return "Sign in again to send messages."; + case "empty_message": + return "Message is empty."; + case "message_too_long": + return `Message exceeds ${err.max_bytes} bytes.`; + case "sidecar_not_running": + return "Chat connection is not ready yet."; + case "auth": + return `Auth error: ${err.message}`; + case "io": + return `Connection error: ${err.message}`; + case "json": + return `Encoding error: ${err.message}`; + } +} + +// Best-effort guard for objects coming back from Tauri's invoke reject +// path. The Rust side serializes the discriminated union with a `kind` +// tag, so anything carrying a string `kind` we treat as the structured +// shape; everything else gets stringified. +export function toSendError(value: unknown): SendMessageError | string { + if ( + value && + typeof value === "object" && + "kind" in value && + typeof (value as { kind: unknown }).kind === "string" + ) { + return value as SendMessageError; + } + return String(value); +} diff --git a/apps/desktop/src/lib/twitchAuth.ts b/apps/desktop/src/lib/twitchAuth.ts index 673c034..c9368e1 100644 --- a/apps/desktop/src/lib/twitchAuth.ts +++ b/apps/desktop/src/lib/twitchAuth.ts @@ -57,3 +57,21 @@ export function logout(): Promise { export function openVerificationUri(uri: string): Promise { return open(uri); } + +// Frontend-facing error envelope from sidecar_commands::twitch_send_message. +// Mirrors the discriminated union the Rust side serializes via serde's +// internally-tagged representation. `kind` is stable and safe to switch on. +export type SendMessageError = + | { kind: "not_logged_in"; message: string } + | { kind: "empty_message" } + | { kind: "message_too_long"; max_bytes: number } + | { kind: "sidecar_not_running" } + | { kind: "io"; message: string } + | { kind: "auth"; message: string } + | { kind: "json"; message: string }; + +export const MAX_CHAT_MESSAGE_BYTES = 500; + +export function sendMessage(text: string): Promise { + return invoke("twitch_send_message", { text }); +} From 2667dc44d1bc926f4bda8eadee92f6a6d2d2e81c Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 19 Apr 2026 05:57:27 +0200 Subject: [PATCH 2/4] fix: address review feedback for twitch send-message - MessageInput: aria-label for a11y; treat input as single-line (no Shift+Enter handling) - messageInput: strict per-variant guards in toSendError; add helix variant formatter - sidecar (Go): add request_id to control.Command; echo it on send_chat_result - sidecar_commands: recover from poisoned mutex instead of panicking - correlate Helix drop_reason back to UI via request_id + oneshot completer map - expand test coverage on Go (httptest) and Rust (oneshot/HashMap correlation) --- .../src-sidecar/internal/control/control.go | 5 + .../src-sidecar/internal/sidecar/sidecar.go | 38 +- .../internal/sidecar/sidecar_test.go | 136 +++++++ apps/desktop/src-tauri/src/host.rs | 91 +++++ .../desktop/src-tauri/src/sidecar_commands.rs | 339 ++++++++++++++---- .../src-tauri/src/sidecar_supervisor.rs | 81 ++++- apps/desktop/src/components/MessageInput.tsx | 10 +- apps/desktop/src/lib/messageInput.test.ts | 27 +- apps/desktop/src/lib/messageInput.ts | 48 ++- apps/desktop/src/lib/twitchAuth.ts | 3 +- 10 files changed, 657 insertions(+), 121 deletions(-) diff --git a/apps/desktop/src-sidecar/internal/control/control.go b/apps/desktop/src-sidecar/internal/control/control.go index 2c3feed..16b81f2 100644 --- a/apps/desktop/src-sidecar/internal/control/control.go +++ b/apps/desktop/src-sidecar/internal/control/control.go @@ -60,6 +60,11 @@ type Command struct { // before issuing the request. Message string `json:"message,omitempty"` + // RequestID correlates a command with its response notification (e.g. + // `send_chat_result`). Opaque to the sidecar; the host sets it and + // matches it back when the result line arrives. + RequestID uint64 `json:"request_id,omitempty"` + // YouTube fields VideoID string `json:"video_id,omitempty"` LiveChatID string `json:"live_chat_id,omitempty"` diff --git a/apps/desktop/src-sidecar/internal/sidecar/sidecar.go b/apps/desktop/src-sidecar/internal/sidecar/sidecar.go index 705b302..90e635d 100644 --- a/apps/desktop/src-sidecar/internal/sidecar/sidecar.go +++ b/apps/desktop/src-sidecar/internal/sidecar/sidecar.go @@ -393,6 +393,9 @@ func HandleDeleteMessage(cmd control.Command, logger zerolog.Logger) { // this to surface failures (drop reasons, auth errors) without having to // poll any other state. type SendChatResultPayload struct { + // RequestID echoes back the command's request_id so the host can + // correlate this result with the awaiting Tauri invocation. + RequestID uint64 `json:"request_id,omitempty"` Ok bool `json:"ok"` MessageID string `json:"message_id,omitempty"` DropCode string `json:"drop_code,omitempty"` @@ -400,33 +403,36 @@ type SendChatResultPayload struct { ErrorMessage string `json:"error_message,omitempty"` } +// sendChatHelixBase overrides the Helix base URL used by HandleSendChatMessage +// in tests. Empty string falls through to the production Helix endpoint. +var sendChatHelixBase = "" + // HandleSendChatMessage posts the user's message to Twitch via Helix and // emits a `send_chat_result` notification with either the assigned // message_id or the drop reason / transport error. Validation mirrors the // Helix endpoint so obvious misuse (empty fields, oversized body) fails // without consuming a request. func HandleSendChatMessage(ctx context.Context, cmd control.Command, notify twitch.Notify, logger zerolog.Logger) { + reply := func(p SendChatResultPayload) { + p.RequestID = cmd.RequestID + notify("send_chat_result", p) + } if cmd.BroadcasterID == "" || cmd.UserID == "" || cmd.ClientID == "" || cmd.Token == "" { logger.Warn(). Str("broadcaster", cmd.BroadcasterID). Str("user", cmd.UserID). Msg("send_chat_message missing required field; ignoring") - notify("send_chat_result", SendChatResultPayload{ - Ok: false, + reply(SendChatResultPayload{ ErrorMessage: "missing broadcaster, user, client_id, or token", }) return } if cmd.Message == "" { - notify("send_chat_result", SendChatResultPayload{ - Ok: false, - ErrorMessage: "empty message", - }) + reply(SendChatResultPayload{ErrorMessage: "empty message"}) return } if len(cmd.Message) > twitch.MaxChatMessageBytes { - notify("send_chat_result", SendChatResultPayload{ - Ok: false, + reply(SendChatResultPayload{ ErrorMessage: fmt.Sprintf("message exceeds %d bytes", twitch.MaxChatMessageBytes), }) return @@ -434,33 +440,27 @@ func HandleSendChatMessage(ctx context.Context, cmd control.Command, notify twit client := &twitch.HelixClient{ ClientID: cmd.ClientID, AccessToken: cmd.Token, + BaseURL: sendChatHelixBase, } resp, err := client.SendChatMessage(ctx, cmd.BroadcasterID, cmd.UserID, cmd.Message) if err != nil { logger.Warn().Err(err).Str("broadcaster", cmd.BroadcasterID).Msg("send_chat_message failed") - notify("send_chat_result", SendChatResultPayload{ - Ok: false, - ErrorMessage: err.Error(), - }) + reply(SendChatResultPayload{ErrorMessage: err.Error()}) return } if len(resp.Data) == 0 { - notify("send_chat_result", SendChatResultPayload{ - Ok: false, - ErrorMessage: "empty response from helix", - }) + reply(SendChatResultPayload{ErrorMessage: "empty response from helix"}) return } first := resp.Data[0] if !first.IsSent { - notify("send_chat_result", SendChatResultPayload{ - Ok: false, + reply(SendChatResultPayload{ DropCode: first.DropReason.Code, DropMessage: first.DropReason.Message, }) return } - notify("send_chat_result", SendChatResultPayload{ + reply(SendChatResultPayload{ Ok: true, MessageID: first.MessageID, }) diff --git a/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go b/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go index 08d626e..8862f4f 100644 --- a/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go +++ b/apps/desktop/src-sidecar/internal/sidecar/sidecar_test.go @@ -1151,3 +1151,139 @@ func TestDispatchCommand_RoutesYouTubeDisconnect(t *testing.T) { t.Fatal("expected youtube_disconnect to cancel client") } } + +func TestHandleSendChatMessage_MissingFieldsRepliesWithRequestID(t *testing.T) { + cases := []struct { + name string + cmd control.Command + want string + }{ + { + "no broadcaster", + control.Command{Cmd: "send_chat_message", UserID: "u", ClientID: "c", Token: "t", Message: "hi", RequestID: 7}, + "missing broadcaster", + }, + { + "empty message", + control.Command{Cmd: "send_chat_message", BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t", RequestID: 8}, + "empty message", + }, + { + "oversize", + control.Command{Cmd: "send_chat_message", BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t", Message: strings.Repeat("a", twitch.MaxChatMessageBytes+1), RequestID: 9}, + "exceeds", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var got SendChatResultPayload + var typ string + notify := func(t string, p any) { + typ = t + got = p.(SendChatResultPayload) + } + HandleSendChatMessage(context.Background(), tc.cmd, notify, zerolog.Nop()) + if typ != "send_chat_result" { + t.Fatalf("expected send_chat_result, got %q", typ) + } + if got.RequestID != tc.cmd.RequestID { + t.Errorf("request_id not echoed: want %d got %d", tc.cmd.RequestID, got.RequestID) + } + if got.Ok { + t.Error("expected Ok=false") + } + if !strings.Contains(got.ErrorMessage, tc.want) { + t.Errorf("expected error containing %q, got %q", tc.want, got.ErrorMessage) + } + }) + } +} + +func TestDispatchCommand_RoutesSendChatMessage(t *testing.T) { + var typ string + notify := func(t string, p any) { + typ = t + _ = p + } + cmd := control.Command{Cmd: "send_chat_message", RequestID: 11} + DispatchCommand(context.Background(), cmd, map[string]context.CancelFunc{}, make(chan []byte, 1), notify, zerolog.Nop()) + if typ != "send_chat_result" { + t.Fatalf("expected dispatch to invoke HandleSendChatMessage, got notify type %q", typ) + } +} + +func TestHandleSendChatMessage_SuccessEchoesMessageID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/messages" || r.Method != http.MethodPost { + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"message_id":"abc-123","is_sent":true}]}`) + })) + defer srv.Close() + prev := sendChatHelixBase + sendChatHelixBase = srv.URL + defer func() { sendChatHelixBase = prev }() + + var got SendChatResultPayload + notify := func(_ string, p any) { got = p.(SendChatResultPayload) } + HandleSendChatMessage(context.Background(), control.Command{ + Cmd: "send_chat_message", + BroadcasterID: "b", + UserID: "u", + ClientID: "c", + Token: "t", + Message: "hello", + RequestID: 42, + }, notify, zerolog.Nop()) + + if !got.Ok || got.MessageID != "abc-123" || got.RequestID != 42 { + t.Fatalf("unexpected payload: %+v", got) + } +} + +func TestHandleSendChatMessage_DropReason(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"data":[{"is_sent":false,"drop_reason":{"code":"msg_duplicate","message":"duplicate"}}]}`) + })) + defer srv.Close() + prev := sendChatHelixBase + sendChatHelixBase = srv.URL + defer func() { sendChatHelixBase = prev }() + + var got SendChatResultPayload + notify := func(_ string, p any) { got = p.(SendChatResultPayload) } + HandleSendChatMessage(context.Background(), control.Command{ + Cmd: "send_chat_message", + BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t", + Message: "x", RequestID: 5, + }, notify, zerolog.Nop()) + + if got.Ok || got.DropCode != "msg_duplicate" || got.RequestID != 5 { + t.Fatalf("unexpected payload: %+v", got) + } +} + +func TestHandleSendChatMessage_HelixError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":"Unauthorized","status":401,"message":"oops"}`) + })) + defer srv.Close() + prev := sendChatHelixBase + sendChatHelixBase = srv.URL + defer func() { sendChatHelixBase = prev }() + + var got SendChatResultPayload + notify := func(_ string, p any) { got = p.(SendChatResultPayload) } + HandleSendChatMessage(context.Background(), control.Command{ + Cmd: "send_chat_message", + BroadcasterID: "b", UserID: "u", ClientID: "c", Token: "t", + Message: "x", RequestID: 99, + }, notify, zerolog.Nop()) + + if got.Ok || got.ErrorMessage == "" || got.RequestID != 99 { + t.Fatalf("unexpected payload: %+v", got) + } +} diff --git a/apps/desktop/src-tauri/src/host.rs b/apps/desktop/src-tauri/src/host.rs index b84e3a6..01f1b9e 100644 --- a/apps/desktop/src-tauri/src/host.rs +++ b/apps/desktop/src-tauri/src/host.rs @@ -193,6 +193,10 @@ pub struct SendChatMessageArgs<'a> { pub broadcaster_id: &'a str, pub user_id: &'a str, pub message: &'a str, + /// Opaque correlation id echoed back in the sidecar's + /// `send_chat_result` notification so the host can match the result + /// to the awaiting Tauri invocation. + pub request_id: u64, } /// Serializes a `send_chat_message` control command line for the sidecar. @@ -207,6 +211,7 @@ pub fn build_send_chat_message_line(args: SendChatMessageArgs<'_>) -> serde_json broadcaster_id: &'a str, user_id: &'a str, message: &'a str, + request_id: u64, } let cmd = SendCmd { cmd: "send_chat_message", @@ -215,6 +220,7 @@ pub fn build_send_chat_message_line(args: SendChatMessageArgs<'_>) -> serde_json broadcaster_id: args.broadcaster_id, user_id: args.user_id, message: args.message, + request_id: args.request_id, }; let mut bytes = serde_json::to_vec(&cmd)?; bytes.push(b'\n'); @@ -279,6 +285,10 @@ pub enum SidecarEvent { /// consumed by the host to rebuild its emote index. Boxed because the /// bundle is much larger than the other variants. EmoteBundle(Box), + /// `{"type":"send_chat_result","payload":SendChatResult}`. Routed + /// back to the awaiting `twitch_send_message` invocation via its + /// `request_id` correlation field. + SendChatResult(SendChatResult), /// A well-formed `{type, payload}` message the host does not yet /// recognize. The inner string is the type tag. Other(String), @@ -286,6 +296,30 @@ pub enum SidecarEvent { Invalid, } +/// Parsed payload of a `send_chat_result` notification. Mirrors the +/// Go-side `sidecar.SendChatResultPayload` shape. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct SendChatResult { + /// Echoed-back correlation id from the originating `send_chat_message` + /// command. The host uses this to find the awaiting completer. + #[serde(default)] + pub request_id: u64, + pub ok: bool, + /// Helix-assigned id for a successfully accepted message. Currently + /// surfaced into `send_chat_result` for future echo-suppression / + /// optimistic-render confirmation; not consumed by the dispatcher + /// itself. + #[allow(dead_code)] + #[serde(default)] + pub message_id: String, + #[serde(default)] + pub drop_code: String, + #[serde(default)] + pub drop_message: String, + #[serde(default)] + pub error_message: String, +} + /// Parses one line of sidecar stdout into a [`SidecarEvent`]. The sidecar /// writes one JSON object per line via `json.Encoder.Encode`, so `bytes` /// should be the full line without the trailing newline. Leading/trailing @@ -322,6 +356,16 @@ pub fn parse_sidecar_event(bytes: &[u8]) -> SidecarEvent { } } } + "send_chat_result" => { + let payload = env.payload.unwrap_or(serde_json::Value::Null); + match serde_json::from_value::(payload) { + Ok(r) => SidecarEvent::SendChatResult(r), + Err(e) => { + tracing::warn!(error = %e, "send_chat_result payload decode failed"); + SidecarEvent::Invalid + } + } + } "" => SidecarEvent::Invalid, other => SidecarEvent::Other(other.to_owned()), } @@ -608,4 +652,51 @@ mod tests { let line = br#"{"type":"emote_bundle","payload":{"twitch_global_emotes":"oops"}}"#; assert!(matches!(parse_sidecar_event(line), SidecarEvent::Invalid)); } + + #[test] + fn parse_sidecar_event_decodes_send_chat_result_success() { + let line = br#"{"type":"send_chat_result","payload":{"request_id":42,"ok":true,"message_id":"abc"}}"#; + match parse_sidecar_event(line) { + SidecarEvent::SendChatResult(r) => { + assert_eq!(r.request_id, 42); + assert!(r.ok); + assert_eq!(r.message_id, "abc"); + } + _ => panic!("expected SendChatResult"), + } + } + + #[test] + fn parse_sidecar_event_decodes_send_chat_result_drop() { + let line = br#"{"type":"send_chat_result","payload":{"request_id":7,"ok":false,"drop_code":"msg_duplicate","drop_message":"dup"}}"#; + match parse_sidecar_event(line) { + SidecarEvent::SendChatResult(r) => { + assert_eq!(r.request_id, 7); + assert!(!r.ok); + assert_eq!(r.drop_code, "msg_duplicate"); + } + _ => panic!("expected SendChatResult"), + } + } + + #[test] + fn build_send_chat_message_line_includes_request_id() { + let line = build_send_chat_message_line(SendChatMessageArgs { + client_id: "cid", + access_token: "tok", + broadcaster_id: "b", + user_id: "u", + message: "hi", + request_id: 99, + }) + .unwrap(); + assert_eq!(line.last(), Some(&b'\n')); + let body = &line[..line.len() - 1]; + let parsed: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(parsed["cmd"], "send_chat_message"); + assert_eq!(parsed["request_id"], 99); + assert_eq!(parsed["broadcaster_id"], "b"); + assert_eq!(parsed["message"], "hi"); + assert_eq!(parsed["token"], "tok"); + } } diff --git a/apps/desktop/src-tauri/src/sidecar_commands.rs b/apps/desktop/src-tauri/src/sidecar_commands.rs index 53b1dd4..3440d09 100644 --- a/apps/desktop/src-tauri/src/sidecar_commands.rs +++ b/apps/desktop/src-tauri/src/sidecar_commands.rs @@ -1,36 +1,73 @@ //! Tauri commands that send control-plane messages to the running Go -//! sidecar over its stdin. Decoupled from the supervisor so the command -//! handlers can be tested without spinning a real child process. +//! sidecar over its stdin and await structured responses on its stdout. //! -//! The sender is a clone-able handle around an `Arc>>` -//! holding the live [`tauri_plugin_shell::process::CommandChild`]. The -//! supervisor publishes the child after a successful spawn + bootstrap -//! and clears it on termination (or never publishes it on platforms where -//! the sidecar isn't supported). Commands fail fast with a structured -//! error when no child is alive instead of blocking on a vanished pipe. +//! The sender is a clone-able handle around an `Arc>` that +//! owns: +//! * the live [`tauri_plugin_shell::process::CommandChild`] (so commands +//! can write into its stdin pipe), and +//! * a map of in-flight request ids → oneshot completers (so the +//! supervisor can route a `send_chat_result` notification back to the +//! awaiting Tauri invocation). +//! +//! The supervisor publishes the child after a successful spawn + bootstrap +//! and clears it on termination. `clear` also drops every pending +//! completer, which fails the awaiting commands with +//! [`SendCommandError::SidecarNotRunning`] instead of leaving them +//! hanging forever. -use std::sync::{Arc, Mutex}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use serde::Serialize; use tauri::State; +use tokio::sync::oneshot; #[cfg(windows)] use tauri_plugin_shell::process::CommandChild; -use crate::host::{build_send_chat_message_line, SendChatMessageArgs}; +use crate::host::{build_send_chat_message_line, SendChatMessageArgs, SendChatResult}; use crate::twitch_auth::{AuthError, AuthState, TWITCH_CLIENT_ID}; +/// Inner state shared between the supervisor (publish/clear), the +/// command (write/register), and the stdout dispatcher (complete). +struct Inner { + #[cfg(windows)] + child: Option, + pending: HashMap>, + next_id: u64, +} + +impl Default for Inner { + fn default() -> Self { + Self { + #[cfg(windows)] + child: None, + pending: HashMap::new(), + // Start at 1 so a serialized 0 (which the Go side may omit + // because of `omitempty`) can never be confused with a real id. + next_id: 1, + } + } +} + /// Shared handle the supervisor uses to publish the live sidecar child /// and that command handlers use to write control lines into its stdin. -/// On non-Windows builds the inner type degrades to `()` so the -/// supervisor's call sites compile without `#[cfg]` everywhere; commands -/// always return [`SendCommandError::SidecarNotRunning`] there. #[derive(Default, Clone)] pub struct SidecarCommandSender { - #[cfg(windows)] - inner: Arc>>, - #[cfg(not(windows))] - inner: Arc>, + inner: Arc>, +} + +/// Recovers the inner state from a poisoned mutex. A poison just means a +/// previous holder panicked; the data is still consistent (we only ever +/// hold the lock for short, infallible operations) so taking it is safe +/// and lets us continue serving commands rather than crashing the app. +fn unpoison<'a, T>( + result: Result, PoisonError>>, +) -> MutexGuard<'a, T> { + result.unwrap_or_else(|e| { + tracing::warn!("sidecar sender mutex was poisoned; recovering"); + e.into_inner() + }) } impl SidecarCommandSender { @@ -41,51 +78,119 @@ impl SidecarCommandSender { /// which closes the prior stdin pipe. #[cfg(windows)] pub fn publish(&self, child: CommandChild) { - *self.inner.lock().expect("sidecar sender mutex poisoned") = Some(child); + let mut g = unpoison(self.inner.lock()); + g.child = Some(child); } - /// Clears the child handle. Called by the supervisor when the child - /// terminates or when the heartbeat-timeout path needs to take - /// ownership for an explicit `kill`. + /// Clears the child handle and drops every pending completer so the + /// awaiting commands resolve with [`SendCommandError::SidecarNotRunning`] + /// instead of waiting forever for a response that will never come. #[cfg(windows)] pub fn clear(&self) -> Option { - self.inner - .lock() - .expect("sidecar sender mutex poisoned") - .take() + let mut g = unpoison(self.inner.lock()); + g.pending.clear(); + g.child.take() + } + + /// On non-Windows builds clearing only drops pending completers; + /// there is no child handle to return. + #[cfg(not(windows))] + pub fn clear(&self) { + let mut g = unpoison(self.inner.lock()); + g.pending.clear(); + } + + /// Routes a `send_chat_result` notification from the sidecar's + /// stdout to the awaiting command. A no-op if no completer is + /// registered for the id (e.g. the awaiting future was dropped). + pub fn complete_send_chat(&self, result: SendChatResult) { + let mut g = unpoison(self.inner.lock()); + if let Some(tx) = g.pending.remove(&result.request_id) { + let _: Result<(), _> = tx.send(result); + } } - /// Writes a single newline-terminated command line to the child's - /// stdin. Errors propagate from the underlying pipe write so callers - /// can map them to user-facing failures. + /// Allocates a fresh request id, registers the oneshot sender under + /// it, and writes the given control line to the child's stdin in a + /// single locked section so the line and the registration can't race + /// against a concurrent `clear`. #[cfg(windows)] - fn write_line(&self, bytes: &[u8]) -> Result<(), SendCommandError> { - let mut guard = self.inner.lock().expect("sidecar sender mutex poisoned"); - let child = guard.as_mut().ok_or(SendCommandError::SidecarNotRunning)?; - child.write(bytes).map_err(|e| SendCommandError::Io { + fn send_with_pending( + &self, + tx: oneshot::Sender, + build_line: F, + ) -> Result<(), SendCommandError> + where + F: FnOnce(u64) -> Result, serde_json::Error>, + { + let mut g = unpoison(self.inner.lock()); + if g.child.is_none() { + return Err(SendCommandError::SidecarNotRunning); + } + let id = g.next_id; + let line = build_line(id).map_err(|e| SendCommandError::Json { message: e.to_string(), - }) + })?; + let child = g + .child + .as_mut() + .ok_or(SendCommandError::SidecarNotRunning)?; + child + .write(&line) + .map_err(|e: tauri_plugin_shell::Error| SendCommandError::Io { + message: e.to_string(), + })?; + // Bump and skip 0 on wraparound. + g.next_id = match g.next_id.wrapping_add(1) { + 0 => 1, + n => n, + }; + g.pending.insert(id, tx); + Ok(()) } #[cfg(not(windows))] - fn write_line(&self, _bytes: &[u8]) -> Result<(), SendCommandError> { + fn send_with_pending( + &self, + _tx: oneshot::Sender, + _build_line: F, + ) -> Result<(), SendCommandError> + where + F: FnOnce(u64) -> Result, serde_json::Error>, + { Err(SendCommandError::SidecarNotRunning) } } /// Frontend-facing error for `twitch_send_message` and any future -/// command. `kind` is a stable string the UI matches against; `message` -/// is a human-readable diagnostic. +/// command. `kind` is a stable string the UI matches against. #[derive(Debug, Clone, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum SendCommandError { - NotLoggedIn { message: String }, + NotLoggedIn { + message: String, + }, EmptyMessage, - MessageTooLong { max_bytes: usize }, + MessageTooLong { + max_bytes: usize, + }, SidecarNotRunning, - Io { message: String }, - Auth { message: String }, - Json { message: String }, + Io { + message: String, + }, + Auth { + message: String, + }, + Json { + message: String, + }, + /// Twitch accepted the request but rejected the message (drop reason) + /// or returned a non-2xx response. `code` is the Helix drop-reason + /// tag (empty for transport-level errors). + Helix { + code: String, + message: String, + }, } impl SendCommandError { @@ -99,6 +204,30 @@ impl SendCommandError { }, } } + + fn from_send_result(r: SendChatResult) -> Result<(), Self> { + if r.ok { + return Ok(()); + } + if !r.drop_code.is_empty() || !r.drop_message.is_empty() { + return Err(Self::Helix { + code: r.drop_code, + message: if r.drop_message.is_empty() { + "message rejected".to_string() + } else { + r.drop_message + }, + }); + } + Err(Self::Helix { + code: String::new(), + message: if r.error_message.is_empty() { + "send failed".to_string() + } else { + r.error_message + }, + }) + } } /// Maximum chat message length accepted by Twitch Helix POST @@ -128,18 +257,22 @@ pub async fn twitch_send_message( .await .map_err(SendCommandError::auth)?; - let line = build_send_chat_message_line(SendChatMessageArgs { - client_id: TWITCH_CLIENT_ID, - access_token: &tokens.access_token, - broadcaster_id: &tokens.user_id, - user_id: &tokens.user_id, - message: trimmed, - }) - .map_err(|e| SendCommandError::Json { - message: e.to_string(), + let (tx, rx) = oneshot::channel(); + sender.send_with_pending(tx, |request_id| { + build_send_chat_message_line(SendChatMessageArgs { + client_id: TWITCH_CLIENT_ID, + access_token: &tokens.access_token, + broadcaster_id: &tokens.user_id, + user_id: &tokens.user_id, + message: trimmed, + request_id, + }) })?; - sender.write_line(&line) + // Sender dropped (sidecar terminated, completer cleared) → treat as + // not-running rather than leaking the await. + let result = rx.await.map_err(|_| SendCommandError::SidecarNotRunning)?; + SendCommandError::from_send_result(result) } #[cfg(test)] @@ -149,34 +282,112 @@ mod tests { #[test] fn auth_mapping_no_tokens_is_not_logged_in() { let mapped = SendCommandError::auth(AuthError::NoTokens); - match mapped { - SendCommandError::NotLoggedIn { .. } => {} - other => panic!("expected NotLoggedIn, got {other:?}"), - } + assert!(matches!(mapped, SendCommandError::NotLoggedIn { .. })); } #[test] fn auth_mapping_refresh_invalid_is_not_logged_in() { let mapped = SendCommandError::auth(AuthError::RefreshTokenInvalid); - match mapped { - SendCommandError::NotLoggedIn { .. } => {} - other => panic!("expected NotLoggedIn, got {other:?}"), - } + assert!(matches!(mapped, SendCommandError::NotLoggedIn { .. })); } #[test] fn auth_mapping_other_is_auth() { let mapped = SendCommandError::auth(AuthError::OAuth("boom".into())); - match mapped { - SendCommandError::Auth { .. } => {} - other => panic!("expected Auth, got {other:?}"), + assert!(matches!(mapped, SendCommandError::Auth { .. })); + } + + fn make_result(ok: bool, drop_code: &str, drop_message: &str, error: &str) -> SendChatResult { + SendChatResult { + request_id: 1, + ok, + message_id: if ok { "abc".into() } else { String::new() }, + drop_code: drop_code.into(), + drop_message: drop_message.into(), + error_message: error.into(), + } + } + + #[test] + fn from_send_result_ok_is_ok() { + assert!(SendCommandError::from_send_result(make_result(true, "", "", "")).is_ok()); + } + + #[test] + fn from_send_result_drop_maps_to_helix() { + match SendCommandError::from_send_result(make_result( + false, + "msg_duplicate", + "duplicate", + "", + )) + .unwrap_err() + { + SendCommandError::Helix { code, message } => { + assert_eq!(code, "msg_duplicate"); + assert_eq!(message, "duplicate"); + } + other => panic!("expected Helix, got {other:?}"), + } + } + + #[test] + fn from_send_result_error_only_maps_to_helix() { + match SendCommandError::from_send_result(make_result(false, "", "", "401")).unwrap_err() { + SendCommandError::Helix { code, message } => { + assert!(code.is_empty()); + assert_eq!(message, "401"); + } + other => panic!("expected Helix, got {other:?}"), } } #[tokio::test] - async fn write_without_child_returns_not_running() { + async fn send_without_child_returns_not_running() { let sender = SidecarCommandSender::default(); - let err = sender.write_line(b"x\n").expect_err("must error"); + let (tx, _rx) = oneshot::channel(); + let err = sender + .send_with_pending(tx, |_id| Ok(b"x\n".to_vec())) + .expect_err("must error"); assert!(matches!(err, SendCommandError::SidecarNotRunning)); } + + #[test] + fn complete_send_chat_no_pending_is_noop() { + // No registration, no panic. Idempotent so a stray late + // notification can't blow up the supervisor's stdout loop. + let sender = SidecarCommandSender::default(); + sender.complete_send_chat(make_result(true, "", "", "")); + } + + #[test] + fn clear_drops_pending_completers() { + let sender = SidecarCommandSender::default(); + let (tx, mut rx) = oneshot::channel::(); + { + let mut g = unpoison(sender.inner.lock()); + g.pending.insert(7, tx); + } + let _ = sender.clear(); + match rx.try_recv() { + Err(oneshot::error::TryRecvError::Closed) => {} + other => panic!("expected Closed, got {other:?}"), + } + } + + #[test] + fn complete_routes_to_pending_completer() { + let sender = SidecarCommandSender::default(); + let (tx, mut rx) = oneshot::channel::(); + { + let mut g = unpoison(sender.inner.lock()); + g.pending.insert(42, tx); + } + let mut r = make_result(true, "", "", ""); + r.request_id = 42; + sender.complete_send_chat(r); + let got = rx.try_recv().expect("should have received"); + assert_eq!(got.request_id, 42); + assert!(got.ok); + } } diff --git a/apps/desktop/src-tauri/src/sidecar_supervisor.rs b/apps/desktop/src-tauri/src/sidecar_supervisor.rs index 87f8fe5..7f83835 100644 --- a/apps/desktop/src-tauri/src/sidecar_supervisor.rs +++ b/apps/desktop/src-tauri/src/sidecar_supervisor.rs @@ -346,7 +346,7 @@ async fn run_once( &mut rx, cfg.heartbeat_timeout, attempt, - |bytes| handle_sidecar_stdout(bytes, app, &emote_index), + |bytes| handle_sidecar_stdout(bytes, app, &emote_index, sender), || { if let Some(c) = sender.clear() { if let Err(e) = c.kill() { @@ -516,19 +516,25 @@ fn handle_sidecar_stdout( bytes: &[u8], app: &AppHandle, emote_index: &Arc, + sender: &SidecarCommandSender, ) -> bool { - scan_sidecar_stdout(bytes, |bundle| apply_emote_bundle(bundle, app, emote_index)) + scan_sidecar_stdout( + bytes, + |bundle| apply_emote_bundle(bundle, app, emote_index), + |result| sender.complete_send_chat(result), + ) } /// Pure scan-and-dispatch core of [`handle_sidecar_stdout`]. Splits the /// batch on newlines, parses each non-empty piece via /// [`parse_sidecar_event`], returns `true` if any line was a heartbeat, -/// and invokes `on_bundle` for each parsed [`EmoteBundle`]. Factored out -/// from `handle_sidecar_stdout` so it can be unit-tested without a Tauri -/// runtime (the AppHandle-dependent work happens inside the closure). -fn scan_sidecar_stdout(bytes: &[u8], mut on_bundle: F) -> bool +/// and invokes `on_bundle`/`on_send_result` for the corresponding +/// variants. Factored out so it can be unit-tested without a Tauri +/// runtime (the AppHandle-dependent work happens inside the closures). +fn scan_sidecar_stdout(bytes: &[u8], mut on_bundle: F, mut on_send_result: G) -> bool where F: FnMut(Box), + G: FnMut(crate::host::SendChatResult), { let mut saw_heartbeat = false; for line in bytes.split(|b| *b == b'\n') { @@ -538,6 +544,7 @@ where match parse_sidecar_event(line) { SidecarEvent::Heartbeat => saw_heartbeat = true, SidecarEvent::EmoteBundle(bundle) => on_bundle(bundle), + SidecarEvent::SendChatResult(r) => on_send_result(r), SidecarEvent::Other(t) => { tracing::debug!(msg_type = %t, "unhandled sidecar control message"); } @@ -657,14 +664,26 @@ mod tests { #[test] fn scan_sidecar_stdout_returns_false_on_empty_input() { - assert!(!scan_sidecar_stdout(b"", |_| panic!("no bundle"))); - assert!(!scan_sidecar_stdout(b"\n\n\n", |_| panic!("no bundle"))); + assert!(!scan_sidecar_stdout( + b"", + |_| panic!("no bundle"), + |_| panic!("no result") + )); + assert!(!scan_sidecar_stdout( + b"\n\n\n", + |_| panic!("no bundle"), + |_| panic!("no result") + )); } #[test] fn scan_sidecar_stdout_detects_single_heartbeat() { let line = br#"{"type":"heartbeat","payload":{"ts_ms":1,"counter":1}}"#; - assert!(scan_sidecar_stdout(line, |_| panic!("no bundle"))); + assert!(scan_sidecar_stdout( + line, + |_| panic!("no bundle"), + |_| panic!("no result") + )); } #[test] @@ -675,7 +694,11 @@ mod tests { batch.push(b'\n'); batch.extend_from_slice(br#"{"type":"heartbeat","payload":{"ts_ms":1,"counter":1}}"#); batch.push(b'\n'); - assert!(scan_sidecar_stdout(&batch, |_| panic!("no bundle"))); + assert!(scan_sidecar_stdout( + &batch, + |_| panic!("no bundle"), + |_| panic!("no result") + )); } #[test] @@ -683,7 +706,11 @@ mod tests { let mut batch = Vec::new(); batch.extend_from_slice(b"not json\n"); batch.extend_from_slice(br#"{"type":"future_thing","payload":{}}"#); - assert!(!scan_sidecar_stdout(&batch, |_| panic!("no bundle"))); + assert!(!scan_sidecar_stdout( + &batch, + |_| panic!("no bundle"), + |_| panic!("no result") + )); } #[test] @@ -694,10 +721,14 @@ mod tests { batch.extend_from_slice(br#"{"type":"heartbeat","payload":{"ts_ms":2,"counter":2}}"#); let mut bundles = 0_usize; - let saw_heartbeat = scan_sidecar_stdout(&batch, |bundle| { - assert_eq!(bundle.total_emotes(), 1); - bundles += 1; - }); + let saw_heartbeat = scan_sidecar_stdout( + &batch, + |bundle| { + assert_eq!(bundle.total_emotes(), 1); + bundles += 1; + }, + |_| panic!("no result"), + ); assert!(saw_heartbeat); assert_eq!(bundles, 1); @@ -709,7 +740,23 @@ mod tests { batch.extend_from_slice(b"\n\n"); batch.extend_from_slice(br#"{"type":"heartbeat","payload":{"ts_ms":3,"counter":3}}"#); batch.extend_from_slice(b"\n\n"); - assert!(scan_sidecar_stdout(&batch, |_| panic!("no bundle"))); + assert!(scan_sidecar_stdout( + &batch, + |_| panic!("no bundle"), + |_| panic!("no result") + )); + } + + #[test] + fn scan_sidecar_stdout_dispatches_send_chat_result() { + let line = br#"{"type":"send_chat_result","payload":{"request_id":7,"ok":true,"message_id":"m1"}}"#; + let mut got = None; + let saw_heartbeat = scan_sidecar_stdout(line, |_| panic!("no bundle"), |r| got = Some(r)); + assert!(!saw_heartbeat); + let r = got.expect("result dispatched"); + assert_eq!(r.request_id, 7); + assert!(r.ok); + assert_eq!(r.message_id, "m1"); } // -- run_event_loop tests -------------------------------------------- @@ -870,7 +917,7 @@ mod tests { |bytes| { stdout_calls.set(stdout_calls.get() + 1); // Forward to the pure scanner so heartbeat detection is real. - scan_sidecar_stdout(bytes, |_| {}) + scan_sidecar_stdout(bytes, |_| {}, |_| {}) }, || kill_calls.set(kill_calls.get() + 1), || {}, diff --git a/apps/desktop/src/components/MessageInput.tsx b/apps/desktop/src/components/MessageInput.tsx index cf023c8..c6a4e85 100644 --- a/apps/desktop/src/components/MessageInput.tsx +++ b/apps/desktop/src/components/MessageInput.tsx @@ -1,7 +1,6 @@ -// Chat send input. Single-line textarea-like input pinned below the -// message feed. Enter sends, Shift+Enter inserts a newline (Helix -// permits multi-line bodies), and the inline status row surfaces drop -// reasons or transport errors from the Tauri command. +// Chat send input pinned below the message feed. Single-line: Enter +// sends, and the inline status row surfaces drop reasons or transport +// errors from the Tauri command. import { Component, Show, createSignal } from "solid-js"; import { @@ -52,7 +51,7 @@ const MessageInput: Component = () => { }; const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Enter" && !e.shiftKey) { + if (e.key === "Enter") { e.preventDefault(); void submit(); } @@ -78,6 +77,7 @@ const MessageInput: Component = () => { (inputEl = el)} type="text" + aria-label="Send a chat message" value={text()} placeholder="Send a message" disabled={pending()} diff --git a/apps/desktop/src/lib/messageInput.test.ts b/apps/desktop/src/lib/messageInput.test.ts index 7b5b1ae..fc530e0 100644 --- a/apps/desktop/src/lib/messageInput.test.ts +++ b/apps/desktop/src/lib/messageInput.test.ts @@ -49,13 +49,38 @@ describe("formatSendError", () => { ); expect(formatSendError({ kind: "io", message: "pipe" })).toContain("pipe"); expect(formatSendError({ kind: "json", message: "bad" })).toContain("bad"); + expect( + formatSendError({ + kind: "helix", + code: "msg_duplicate", + message: "duplicate message", + }), + ).toContain("msg_duplicate"); + expect( + formatSendError({ kind: "helix", code: "", message: "blocked" }), + ).toContain("blocked"); }); }); describe("toSendError", () => { - it("passes through structured errors", () => { + it("passes through valid structured errors", () => { const err = { kind: "empty_message" }; expect(toSendError(err)).toBe(err); + const helix = { kind: "helix", code: "x", message: "y" }; + expect(toSendError(helix)).toBe(helix); + }); + + it("rejects look-alike objects with the wrong shape", () => { + // missing required `max_bytes` + expect(toSendError({ kind: "message_too_long" })).toBe("[object Object]"); + // wrong type for required field + expect(toSendError({ kind: "io", message: 5 })).toBe("[object Object]"); + // unknown variant + expect(toSendError({ kind: "made_up_kind" })).toBe("[object Object]"); + // arrays should not be accepted + expect(toSendError([{ kind: "empty_message" }])).not.toMatchObject({ + kind: "empty_message", + }); }); it("stringifies unknown shapes", () => { diff --git a/apps/desktop/src/lib/messageInput.ts b/apps/desktop/src/lib/messageInput.ts index 1a99247..ff55820 100644 --- a/apps/desktop/src/lib/messageInput.ts +++ b/apps/desktop/src/lib/messageInput.ts @@ -21,9 +21,7 @@ export function fitsLimit(text: string): boolean { } // Maps a structured Tauri command error into a short human message -// suitable for an inline status line under the input. Falls back to -// the raw `message` field for anything we don't have a tailored copy -// for, since the backend already includes a useful diagnostic. +// suitable for an inline status line under the input. export function formatSendError(err: SendMessageError): string { switch (err.kind) { case "not_logged_in": @@ -40,21 +38,43 @@ export function formatSendError(err: SendMessageError): string { return `Connection error: ${err.message}`; case "json": return `Encoding error: ${err.message}`; + case "helix": + return err.code + ? `Twitch rejected message (${err.code}): ${err.message}` + : `Twitch rejected message: ${err.message}`; } } -// Best-effort guard for objects coming back from Tauri's invoke reject -// path. The Rust side serializes the discriminated union with a `kind` -// tag, so anything carrying a string `kind` we treat as the structured -// shape; everything else gets stringified. +// Per-variant required-field validators. Used by toSendError to make +// sure formatSendError never reads a field that doesn't exist on a +// look-alike object the backend didn't actually send. +const VARIANT_GUARDS: Record< + SendMessageError["kind"], + (v: Record) => boolean +> = { + empty_message: () => true, + sidecar_not_running: () => true, + not_logged_in: (v) => typeof v.message === "string", + io: (v) => typeof v.message === "string", + auth: (v) => typeof v.message === "string", + json: (v) => typeof v.message === "string", + message_too_long: (v) => typeof v.max_bytes === "number", + helix: (v) => typeof v.code === "string" && typeof v.message === "string", +}; + +// Strict guard for objects coming back from Tauri's invoke reject path. +// Only objects matching a known SendMessageError variant (correct +// `kind` and required field types) pass through as the structured +// shape; anything else is stringified so formatSendError is never +// handed a value missing the fields it expects. export function toSendError(value: unknown): SendMessageError | string { - if ( - value && - typeof value === "object" && - "kind" in value && - typeof (value as { kind: unknown }).kind === "string" - ) { - return value as SendMessageError; + if (value && typeof value === "object" && !Array.isArray(value)) { + const obj = value as Record; + const kind = obj.kind; + if (typeof kind === "string" && kind in VARIANT_GUARDS) { + const guard = VARIANT_GUARDS[kind as SendMessageError["kind"]]; + if (guard(obj)) return obj as SendMessageError; + } } return String(value); } diff --git a/apps/desktop/src/lib/twitchAuth.ts b/apps/desktop/src/lib/twitchAuth.ts index c9368e1..7e9b02f 100644 --- a/apps/desktop/src/lib/twitchAuth.ts +++ b/apps/desktop/src/lib/twitchAuth.ts @@ -68,7 +68,8 @@ export type SendMessageError = | { kind: "sidecar_not_running" } | { kind: "io"; message: string } | { kind: "auth"; message: string } - | { kind: "json"; message: string }; + | { kind: "json"; message: string } + | { kind: "helix"; code: string; message: string }; export const MAX_CHAT_MESSAGE_BYTES = 500; From 5cc9248920e29a4f49eb53cdea2343c8860c8953 Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 19 Apr 2026 06:13:06 +0200 Subject: [PATCH 3/4] test: extract validate_message and add coverage for sender error variants - extract validate_message + advance_request_id helpers from twitch_send_message - cover SendCommandError serde tagging across all variants - add sendMessage frontend test mocking @tauri-apps/api/core --- .../desktop/src-tauri/src/sidecar_commands.rs | 128 ++++++++++++++++-- apps/desktop/src/lib/twitchAuth.test.ts | 37 +++++ 2 files changed, 154 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/lib/twitchAuth.test.ts diff --git a/apps/desktop/src-tauri/src/sidecar_commands.rs b/apps/desktop/src-tauri/src/sidecar_commands.rs index 3440d09..300affc 100644 --- a/apps/desktop/src-tauri/src/sidecar_commands.rs +++ b/apps/desktop/src-tauri/src/sidecar_commands.rs @@ -140,11 +140,7 @@ impl SidecarCommandSender { .map_err(|e: tauri_plugin_shell::Error| SendCommandError::Io { message: e.to_string(), })?; - // Bump and skip 0 on wraparound. - g.next_id = match g.next_id.wrapping_add(1) { - 0 => 1, - n => n, - }; + g.next_id = advance_request_id(g.next_id); g.pending.insert(id, tx); Ok(()) } @@ -235,12 +231,10 @@ impl SendCommandError { /// payloads before they cross the IPC boundary. pub const MAX_CHAT_MESSAGE_BYTES: usize = 500; -#[tauri::command] -pub async fn twitch_send_message( - auth: State<'_, AuthState>, - sender: State<'_, SidecarCommandSender>, - text: String, -) -> Result<(), SendCommandError> { +/// Trims `text` and rejects empty or oversize messages with the +/// matching frontend error variant. Extracted from the Tauri command +/// body so the validation rules are unit-testable without a runtime. +fn validate_message(text: &str) -> Result<&str, SendCommandError> { let trimmed = text.trim(); if trimmed.is_empty() { return Err(SendCommandError::EmptyMessage); @@ -250,6 +244,26 @@ pub async fn twitch_send_message( max_bytes: MAX_CHAT_MESSAGE_BYTES, }); } + Ok(trimmed) +} + +/// Returns the successor of `current` for the request-id allocator, +/// skipping zero on wraparound so a serialized 0 (which the Go side may +/// omit because of `omitempty`) can never be confused with a real id. +fn advance_request_id(current: u64) -> u64 { + match current.wrapping_add(1) { + 0 => 1, + n => n, + } +} + +#[tauri::command] +pub async fn twitch_send_message( + auth: State<'_, AuthState>, + sender: State<'_, SidecarCommandSender>, + text: String, +) -> Result<(), SendCommandError> { + let trimmed = validate_message(&text)?; let tokens = auth .manager @@ -390,4 +404,96 @@ mod tests { assert_eq!(got.request_id, 42); assert!(got.ok); } + + #[test] + fn validate_message_trims_and_returns_inner() { + assert_eq!(validate_message(" hi ").unwrap(), "hi"); + } + + #[test] + fn validate_message_rejects_empty() { + assert!(matches!( + validate_message(" ").unwrap_err(), + SendCommandError::EmptyMessage + )); + assert!(matches!( + validate_message("").unwrap_err(), + SendCommandError::EmptyMessage + )); + } + + #[test] + fn validate_message_rejects_oversize() { + let big = "a".repeat(MAX_CHAT_MESSAGE_BYTES + 1); + match validate_message(&big).unwrap_err() { + SendCommandError::MessageTooLong { max_bytes } => { + assert_eq!(max_bytes, MAX_CHAT_MESSAGE_BYTES); + } + other => panic!("expected MessageTooLong, got {other:?}"), + } + } + + #[test] + fn validate_message_accepts_exactly_max_bytes() { + let max = "a".repeat(MAX_CHAT_MESSAGE_BYTES); + assert!(validate_message(&max).is_ok()); + } + + #[test] + fn advance_request_id_increments() { + assert_eq!(advance_request_id(1), 2); + assert_eq!(advance_request_id(99), 100); + } + + #[test] + fn advance_request_id_skips_zero_on_wrap() { + assert_eq!(advance_request_id(u64::MAX), 1); + } + + #[test] + fn send_command_error_serializes_with_kind_tag() { + let v = serde_json::to_value(SendCommandError::EmptyMessage).unwrap(); + assert_eq!(v["kind"], "empty_message"); + + let v = serde_json::to_value(SendCommandError::MessageTooLong { max_bytes: 500 }).unwrap(); + assert_eq!(v["kind"], "message_too_long"); + assert_eq!(v["max_bytes"], 500); + + let v = serde_json::to_value(SendCommandError::SidecarNotRunning).unwrap(); + assert_eq!(v["kind"], "sidecar_not_running"); + + let v = serde_json::to_value(SendCommandError::NotLoggedIn { + message: "x".into(), + }) + .unwrap(); + assert_eq!(v["kind"], "not_logged_in"); + assert_eq!(v["message"], "x"); + + let v = serde_json::to_value(SendCommandError::Helix { + code: "msg_duplicate".into(), + message: "dup".into(), + }) + .unwrap(); + assert_eq!(v["kind"], "helix"); + assert_eq!(v["code"], "msg_duplicate"); + assert_eq!(v["message"], "dup"); + + let v = serde_json::to_value(SendCommandError::Io { + message: "pipe".into(), + }) + .unwrap(); + assert_eq!(v["kind"], "io"); + + let v = serde_json::to_value(SendCommandError::Json { + message: "bad".into(), + }) + .unwrap(); + assert_eq!(v["kind"], "json"); + + let v = serde_json::to_value(SendCommandError::Auth { + message: "boom".into(), + }) + .unwrap(); + assert_eq!(v["kind"], "auth"); + } } diff --git a/apps/desktop/src/lib/twitchAuth.test.ts b/apps/desktop/src/lib/twitchAuth.test.ts new file mode 100644 index 0000000..cef7c9f --- /dev/null +++ b/apps/desktop/src/lib/twitchAuth.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const invokeMock = vi.fn(); + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +vi.mock("@tauri-apps/plugin-shell", () => ({ + open: vi.fn(), +})); + +import { sendMessage, MAX_CHAT_MESSAGE_BYTES } from "./twitchAuth"; + +afterEach(() => { + invokeMock.mockReset(); +}); + +describe("sendMessage", () => { + it("invokes twitch_send_message with the text payload", async () => { + invokeMock.mockResolvedValueOnce(undefined); + await sendMessage("hello world"); + expect(invokeMock).toHaveBeenCalledWith("twitch_send_message", { + text: "hello world", + }); + }); + + it("propagates the structured error from the backend", async () => { + const err = { kind: "helix", code: "msg_duplicate", message: "dup" }; + invokeMock.mockRejectedValueOnce(err); + await expect(sendMessage("x")).rejects.toEqual(err); + }); + + it("exposes the byte cap matching the Rust constant", () => { + expect(MAX_CHAT_MESSAGE_BYTES).toBe(500); + }); +}); From a5297b0cbde8170e2beca99d341fed7e452f6f9d Mon Sep 17 00:00:00 2001 From: ImpulseB23 Date: Sun, 19 Apr 2026 06:20:02 +0200 Subject: [PATCH 4/4] test: extract Pending registry and roll back on failed sender write - carve out Pending struct (allocate/cancel/complete/clear) so id allocation and completer routing are testable without IPC - send_with_pending rolls back registration on json/io failure - exhaustively cover toSendError variants on the frontend --- .../desktop/src-tauri/src/sidecar_commands.rs | 186 +++++++++++++++--- apps/desktop/src/lib/messageInput.test.ts | 16 ++ 2 files changed, 180 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src-tauri/src/sidecar_commands.rs b/apps/desktop/src-tauri/src/sidecar_commands.rs index 300affc..f2ed482 100644 --- a/apps/desktop/src-tauri/src/sidecar_commands.rs +++ b/apps/desktop/src-tauri/src/sidecar_commands.rs @@ -28,13 +28,64 @@ use tauri_plugin_shell::process::CommandChild; use crate::host::{build_send_chat_message_line, SendChatMessageArgs, SendChatResult}; use crate::twitch_auth::{AuthError, AuthState, TWITCH_CLIENT_ID}; +/// Pure registry of in-flight `send_chat_message` requests. Carved out +/// of [`Inner`] so the id-allocation, completion-routing, and clear +/// semantics can be unit-tested without any IPC mockery. +#[derive(Default)] +struct Pending { + map: HashMap>, + next_id: u64, +} + +impl Pending { + fn new() -> Self { + // Start at 1 so a serialized 0 (which the Go side may omit + // because of `omitempty`) can never be confused with a real id. + Self { + map: HashMap::new(), + next_id: 1, + } + } + + /// Reserves the next id and registers `tx` under it. Returns the id + /// the caller must serialize into the outbound control line. + fn allocate(&mut self, tx: oneshot::Sender) -> u64 { + let id = self.next_id; + self.map.insert(id, tx); + self.next_id = advance_request_id(self.next_id); + id + } + + /// Removes the registration for `id`. Used to roll back when the + /// outbound write fails after `allocate` already inserted the + /// completer, so the caller's `Drop` of the oneshot Sender resolves + /// the awaiting future immediately instead of after the next clear. + fn cancel(&mut self, id: u64) { + self.map.remove(&id); + } + + /// Routes a `send_chat_result` notification to the awaiting + /// completer. A no-op if none is registered (e.g. the awaiting + /// future was dropped before the response landed). + fn complete(&mut self, result: SendChatResult) { + if let Some(tx) = self.map.remove(&result.request_id) { + let _: Result<(), _> = tx.send(result); + } + } + + /// Drops every registered completer so awaiting commands resolve + /// with [`SendCommandError::SidecarNotRunning`] instead of hanging. + fn clear(&mut self) { + self.map.clear(); + } +} + /// Inner state shared between the supervisor (publish/clear), the /// command (write/register), and the stdout dispatcher (complete). struct Inner { #[cfg(windows)] child: Option, - pending: HashMap>, - next_id: u64, + pending: Pending, } impl Default for Inner { @@ -42,10 +93,7 @@ impl Default for Inner { Self { #[cfg(windows)] child: None, - pending: HashMap::new(), - // Start at 1 so a serialized 0 (which the Go side may omit - // because of `omitempty`) can never be confused with a real id. - next_id: 1, + pending: Pending::new(), } } } @@ -105,15 +153,14 @@ impl SidecarCommandSender { /// registered for the id (e.g. the awaiting future was dropped). pub fn complete_send_chat(&self, result: SendChatResult) { let mut g = unpoison(self.inner.lock()); - if let Some(tx) = g.pending.remove(&result.request_id) { - let _: Result<(), _> = tx.send(result); - } + g.pending.complete(result); } /// Allocates a fresh request id, registers the oneshot sender under /// it, and writes the given control line to the child's stdin in a /// single locked section so the line and the registration can't race - /// against a concurrent `clear`. + /// against a concurrent `clear`. Rolls back the registration if the + /// write fails so the caller's awaiting future fails fast. #[cfg(windows)] fn send_with_pending( &self, @@ -127,21 +174,26 @@ impl SidecarCommandSender { if g.child.is_none() { return Err(SendCommandError::SidecarNotRunning); } - let id = g.next_id; - let line = build_line(id).map_err(|e| SendCommandError::Json { - message: e.to_string(), - })?; + let id = g.pending.allocate(tx); + let line = match build_line(id) { + Ok(line) => line, + Err(e) => { + g.pending.cancel(id); + return Err(SendCommandError::Json { + message: e.to_string(), + }); + } + }; let child = g .child .as_mut() .ok_or(SendCommandError::SidecarNotRunning)?; - child - .write(&line) - .map_err(|e: tauri_plugin_shell::Error| SendCommandError::Io { + if let Err(e) = child.write(&line) { + g.pending.cancel(id); + return Err(SendCommandError::Io { message: e.to_string(), - })?; - g.next_id = advance_request_id(g.next_id); - g.pending.insert(id, tx); + }); + } Ok(()) } @@ -380,7 +432,7 @@ mod tests { let (tx, mut rx) = oneshot::channel::(); { let mut g = unpoison(sender.inner.lock()); - g.pending.insert(7, tx); + g.pending.map.insert(7, tx); } let _ = sender.clear(); match rx.try_recv() { @@ -395,7 +447,7 @@ mod tests { let (tx, mut rx) = oneshot::channel::(); { let mut g = unpoison(sender.inner.lock()); - g.pending.insert(42, tx); + g.pending.map.insert(42, tx); } let mut r = make_result(true, "", "", ""); r.request_id = 42; @@ -450,6 +502,96 @@ mod tests { assert_eq!(advance_request_id(u64::MAX), 1); } + #[test] + fn pending_allocate_assigns_monotonic_ids_starting_at_one() { + let mut p = Pending::new(); + let (tx1, _rx1) = oneshot::channel::(); + let (tx2, _rx2) = oneshot::channel::(); + assert_eq!(p.allocate(tx1), 1); + assert_eq!(p.allocate(tx2), 2); + assert_eq!(p.next_id, 3); + assert_eq!(p.map.len(), 2); + } + + #[test] + fn pending_allocate_skips_zero_after_wrap() { + let mut p = Pending::new(); + p.next_id = u64::MAX; + let (tx, _rx) = oneshot::channel::(); + assert_eq!(p.allocate(tx), u64::MAX); + assert_eq!(p.next_id, 1); + } + + #[test] + fn pending_cancel_removes_completer() { + let mut p = Pending::new(); + let (tx, mut rx) = oneshot::channel::(); + let id = p.allocate(tx); + p.cancel(id); + assert!(p.map.is_empty()); + match rx.try_recv() { + Err(oneshot::error::TryRecvError::Closed) => {} + other => panic!("expected Closed, got {other:?}"), + } + } + + #[test] + fn pending_cancel_unknown_id_is_noop() { + let mut p = Pending::new(); + p.cancel(999); + assert!(p.map.is_empty()); + } + + #[test] + fn pending_complete_routes_to_registered_completer() { + let mut p = Pending::new(); + let (tx, mut rx) = oneshot::channel::(); + let id = p.allocate(tx); + let mut r = SendChatResult { + request_id: id, + ok: true, + message_id: "m".into(), + drop_code: String::new(), + drop_message: String::new(), + error_message: String::new(), + }; + r.request_id = id; + p.complete(r); + let got = rx.try_recv().expect("delivered"); + assert_eq!(got.request_id, id); + } + + #[test] + fn pending_complete_unknown_is_noop() { + let mut p = Pending::new(); + p.complete(SendChatResult { + request_id: 7, + ok: true, + message_id: String::new(), + drop_code: String::new(), + drop_message: String::new(), + error_message: String::new(), + }); + } + + #[test] + fn pending_clear_drops_all_completers() { + let mut p = Pending::new(); + let (tx_a, mut rx_a) = oneshot::channel::(); + let (tx_b, mut rx_b) = oneshot::channel::(); + p.allocate(tx_a); + p.allocate(tx_b); + p.clear(); + assert!(matches!( + rx_a.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + )); + assert!(matches!( + rx_b.try_recv(), + Err(oneshot::error::TryRecvError::Closed) + )); + } + #[test] fn send_command_error_serializes_with_kind_tag() { let v = serde_json::to_value(SendCommandError::EmptyMessage).unwrap(); diff --git a/apps/desktop/src/lib/messageInput.test.ts b/apps/desktop/src/lib/messageInput.test.ts index fc530e0..dd99309 100644 --- a/apps/desktop/src/lib/messageInput.test.ts +++ b/apps/desktop/src/lib/messageInput.test.ts @@ -70,6 +70,22 @@ describe("toSendError", () => { expect(toSendError(helix)).toBe(helix); }); + it("accepts every known variant when shape matches", () => { + const cases = [ + { kind: "empty_message" }, + { kind: "sidecar_not_running" }, + { kind: "not_logged_in", message: "x" }, + { kind: "io", message: "x" }, + { kind: "auth", message: "x" }, + { kind: "json", message: "x" }, + { kind: "message_too_long", max_bytes: 500 }, + { kind: "helix", code: "c", message: "m" }, + ]; + for (const c of cases) { + expect(toSendError(c)).toBe(c); + } + }); + it("rejects look-alike objects with the wrong shape", () => { // missing required `max_bytes` expect(toSendError({ kind: "message_too_long" })).toBe("[object Object]");