mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3738 from dotCooCoo/feat/windows-pen-barrel-button
Windows: a pen's barrel button is the right mouse button with Windows Ink
This commit is contained in:
@@ -51,8 +51,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o
|
||||
|
||||
# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as
|
||||
# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit.
|
||||
# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
|
||||
# read a pen's barrel button from its pointer messages.
|
||||
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
|
||||
|
||||
@@ -53,6 +53,7 @@ mod printing;
|
||||
mod system_fonts;
|
||||
mod window;
|
||||
mod window_frame;
|
||||
mod windows_stylus;
|
||||
mod xwayland;
|
||||
|
||||
use vectorcraft_engine::Session;
|
||||
@@ -97,6 +98,9 @@ impl eframe::App for App {
|
||||
if self.app.services.native_menu.is_some() {
|
||||
mac_menu::raw_input_hook(raw);
|
||||
}
|
||||
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
windows_stylus::raw_input_hook(raw);
|
||||
file_access::unconfined(|| self.app.raw_input_hook(raw));
|
||||
}
|
||||
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
|
||||
@@ -910,6 +914,18 @@ fn main() -> std::process::ExitCode {
|
||||
}
|
||||
options
|
||||
};
|
||||
// With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message
|
||||
// hook notes each pointer contact and whether it starts with the barrel button held
|
||||
// (`windows_stylus`).
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
let options = {
|
||||
use winit::platform::windows::EventLoopBuilderExtWindows as _;
|
||||
let mut options = options;
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
builder.with_msg_hook(windows_stylus::msg_hook);
|
||||
}));
|
||||
options
|
||||
};
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Windows pen tablets: with Windows Ink, winit reports a pen in contact as a touch and drops its
|
||||
//! barrel button, and egui-winit presses the primary button for the touch it makes the pointer,
|
||||
//! so a pen pressed with its barrel button held clicks and drags with the left button. The
|
||||
//! `WM_POINTERDOWN` message winit is about to handle still has the barrel button's state in its
|
||||
//! second-button flag ("analogous to a mouse right button down",
|
||||
//! <https://learn.microsoft.com/en-us/windows/win32/inputmsg/wm-pointerdown>): [`msg_hook`] notes
|
||||
//! it, and [`raw_input_hook`] makes the primary button of that contact the secondary one.
|
||||
#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))]
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};
|
||||
|
||||
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's
|
||||
/// `wParam`, a pen in contact with its barrel button pressed.
|
||||
const POINTER_MESSAGE_FLAG_SECONDBUTTON: usize = 0x0020;
|
||||
/// At most this many pointer contacts wait for their touch event.
|
||||
const MAX_PENDING: usize = 16;
|
||||
|
||||
thread_local! {
|
||||
static STROKES: RefCell<BarrelStrokes> = RefCell::new(BarrelStrokes::default());
|
||||
}
|
||||
|
||||
/// winit's message hook (`EventLoopBuilderExtWindows::with_msg_hook`), which winit calls for each
|
||||
/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id
|
||||
/// and whether the barrel button was held.
|
||||
/// It never consumes the message.
|
||||
#[cfg(all(windows, not(target_vendor = "win7")))]
|
||||
#[allow(unsafe_code)]
|
||||
pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
|
||||
use windows::Win32::UI::WindowsAndMessaging::{MSG, WM_POINTERDOWN};
|
||||
// SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
|
||||
// call; `as_ref` gives `None` for a null pointer.
|
||||
let Some(msg) = (unsafe { msg.cast::<MSG>().as_ref() }) else {
|
||||
return false;
|
||||
};
|
||||
if msg.message == WM_POINTERDOWN {
|
||||
STROKES.with(|s| {
|
||||
if let Ok(mut s) = s.try_borrow_mut() {
|
||||
s.pointer_down(msg.wParam.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Before egui reads a frame's input: the primary button of a contact that started with the
|
||||
/// barrel button held becomes the secondary button.
|
||||
pub fn raw_input_hook(raw: &mut egui::RawInput) {
|
||||
STROKES.with(|s| {
|
||||
if let Ok(mut s) = s.try_borrow_mut() {
|
||||
s.rewrite(&mut raw.events);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The pen contacts that press the secondary button.
|
||||
#[derive(Default)]
|
||||
struct BarrelStrokes {
|
||||
/// Each `WM_POINTERDOWN`'s pointer id and whether the barrel button was held, oldest first,
|
||||
/// until the touch of that contact starts.
|
||||
pending: Vec<(u64, bool)>,
|
||||
/// The contact whose touch started with the barrel button held, until it ends, the same pointer
|
||||
/// starts another contact, or another contact starts with the barrel button held.
|
||||
right: Option<Right>,
|
||||
}
|
||||
|
||||
/// A contact that started with the barrel button held.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Right {
|
||||
id: TouchId,
|
||||
/// Its secondary button is down: the press went to egui and the release has not.
|
||||
down: bool,
|
||||
}
|
||||
|
||||
impl BarrelStrokes {
|
||||
/// A `WM_POINTERDOWN` with this `wParam`, whose low word is the pointer id and whose high word
|
||||
/// holds the pointer flags.
|
||||
fn pointer_down(&mut self, wparam: usize) {
|
||||
if self.pending.len() >= MAX_PENDING {
|
||||
self.pending.remove(0);
|
||||
}
|
||||
self.pending.push(((wparam & 0xffff) as u64, (wparam >> 16) & POINTER_MESSAGE_FLAG_SECONDBUTTON != 0));
|
||||
}
|
||||
|
||||
/// Makes the primary button of the right contact the secondary one. egui-winit sends a touch's
|
||||
/// pointer button with the touch event it comes from: the press after the touch's start and
|
||||
/// the pointer's move to it, the release right after its end. A canceled contact, or one
|
||||
/// whose end never came (Windows does not promise one), gets a secondary release of its own.
|
||||
fn rewrite(&mut self, events: &mut Vec<Event>) {
|
||||
let release = |pos: Pos2| Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE };
|
||||
let mut out = Vec::with_capacity(events.len() + 1);
|
||||
// The button event that comes next for the right contact: `Some(true)` for its press,
|
||||
// `Some(false)` for its release.
|
||||
let mut next = None;
|
||||
for mut event in events.drain(..) {
|
||||
let mut after = None;
|
||||
match &mut event {
|
||||
Event::Touch { id, phase, pos, .. } => {
|
||||
let (id, phase, pos) = (*id, *phase, *pos);
|
||||
if phase == TouchPhase::Start {
|
||||
// Each start decides afresh, from the oldest contact of its pointer still waiting.
|
||||
let barrel = match self.pending.iter().position(|&(p, _)| p == id.0) {
|
||||
Some(i) => self.pending.remove(i).1,
|
||||
None => false,
|
||||
};
|
||||
if let Some(r) = self.right
|
||||
&& (barrel || r.id == id)
|
||||
{
|
||||
if r.down {
|
||||
out.push(release(pos));
|
||||
}
|
||||
self.right = None;
|
||||
}
|
||||
if barrel {
|
||||
self.right = Some(Right { id, down: false });
|
||||
}
|
||||
}
|
||||
next = None;
|
||||
if let Some(r) = self.right.filter(|r| r.id == id) {
|
||||
match phase {
|
||||
TouchPhase::Start => next = Some(true),
|
||||
TouchPhase::Move => {}
|
||||
TouchPhase::End => {
|
||||
self.right = None;
|
||||
next = Some(false);
|
||||
}
|
||||
TouchPhase::Cancel => {
|
||||
self.right = None;
|
||||
if r.down {
|
||||
after = Some(release(pos));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::PointerMoved(_) if next == Some(true) => {}
|
||||
Event::PointerButton { button, pressed, .. } if next == Some(*pressed) && *button == PointerButton::Primary => {
|
||||
*button = PointerButton::Secondary;
|
||||
if let Some(r) = self.right.as_mut() {
|
||||
r.down = *pressed;
|
||||
}
|
||||
next = None;
|
||||
}
|
||||
_ => next = None,
|
||||
}
|
||||
out.push(event);
|
||||
out.extend(after);
|
||||
}
|
||||
*events = out;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use egui::{TouchDeviceId, pos2};
|
||||
|
||||
/// `POINTER_MESSAGE_FLAG_INRANGE`, `_INCONTACT` and `_FIRSTBUTTON` (`winuser.h`): a pen tip
|
||||
/// touching down.
|
||||
const TIP: usize = 0x0002 | 0x0004 | 0x0010;
|
||||
/// `POINTER_MESSAGE_FLAG_INRANGE` and `_INCONTACT` with the barrel button.
|
||||
const BARREL: usize = 0x0002 | 0x0004 | POINTER_MESSAGE_FLAG_SECONDBUTTON;
|
||||
|
||||
fn down(strokes: &mut BarrelStrokes, id: usize, flags: usize) {
|
||||
strokes.pointer_down((flags << 16) | id);
|
||||
}
|
||||
|
||||
/// The events egui reads after the hook.
|
||||
fn rewrite(strokes: &mut BarrelStrokes, events: &[Event]) -> Vec<Event> {
|
||||
let mut events = events.to_vec();
|
||||
strokes.rewrite(&mut events);
|
||||
events
|
||||
}
|
||||
|
||||
fn touch(id: u64, phase: TouchPhase) -> Event {
|
||||
Event::Touch { device_id: TouchDeviceId(1), id: TouchId(id), phase, pos: pos2(10.0, 20.0), force: Some(0.5) }
|
||||
}
|
||||
|
||||
fn button(button: PointerButton, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: pos2(10.0, 20.0), button, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
/// What egui-winit sends for the touch that drives the pointer.
|
||||
fn start(id: u64) -> Vec<Event> {
|
||||
vec![touch(id, TouchPhase::Start), Event::PointerMoved(pos2(10.0, 20.0)), button(PointerButton::Primary, true)]
|
||||
}
|
||||
|
||||
fn end(id: u64) -> Vec<Event> {
|
||||
vec![touch(id, TouchPhase::End), button(PointerButton::Primary, false), Event::PointerGone]
|
||||
}
|
||||
|
||||
fn buttons(events: &[Event]) -> Vec<(PointerButton, bool)> {
|
||||
events.iter().filter_map(|e| if let Event::PointerButton { button, pressed, .. } = e { Some((*button, *pressed)) } else { None }).collect()
|
||||
}
|
||||
|
||||
const SECONDARY_DOWN: (PointerButton, bool) = (PointerButton::Secondary, true);
|
||||
const SECONDARY_UP: (PointerButton, bool) = (PointerButton::Secondary, false);
|
||||
const PRIMARY_DOWN: (PointerButton, bool) = (PointerButton::Primary, true);
|
||||
const PRIMARY_UP: (PointerButton, bool) = (PointerButton::Primary, false);
|
||||
|
||||
#[test]
|
||||
fn a_pen_contact_with_the_barrel_button_presses_the_secondary_button() {
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, BARREL);
|
||||
assert_eq!(buttons(&rewrite(&mut strokes, &start(7))), [SECONDARY_DOWN]);
|
||||
let moved = [touch(7, TouchPhase::Move), Event::PointerMoved(pos2(15.0, 25.0))];
|
||||
assert_eq!(rewrite(&mut strokes, &moved), moved);
|
||||
assert_eq!(buttons(&rewrite(&mut strokes, &end(7))), [SECONDARY_UP]);
|
||||
assert_eq!(strokes.right, None, "the next contact starts afresh");
|
||||
assert!(strokes.pending.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pen_contact_with_the_tip_alone_keeps_the_primary_button() {
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, TIP);
|
||||
let events = [start(7), end(7)].concat();
|
||||
assert_eq!(rewrite(&mut strokes, &events), events);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_of_a_pen_within_one_frame_keep_their_order() {
|
||||
// A tap with the barrel button held, then one with the tip alone, before the next frame.
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, BARREL);
|
||||
down(&mut strokes, 7, TIP);
|
||||
let events = rewrite(&mut strokes, &[start(7), end(7), start(7), end(7)].concat());
|
||||
assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mouse_button_right_after_a_barrel_contact_keeps_its_button() {
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, BARREL);
|
||||
let events = [start(7), vec![button(PointerButton::Primary, true)], end(7), vec![button(PointerButton::Primary, false)]].concat();
|
||||
assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, SECONDARY_UP, PRIMARY_UP]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_touches_and_the_mouse_keep_the_primary_button_during_a_barrel_contact() {
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, BARREL);
|
||||
down(&mut strokes, 9, TIP);
|
||||
let events = [start(7), start(9), vec![button(PointerButton::Primary, true)], end(9)].concat();
|
||||
assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, PRIMARY_DOWN, PRIMARY_UP]);
|
||||
assert_eq!(strokes.right, Some(Right { id: TouchId(7), down: true }), "the barrel contact goes on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_contact_after_a_lost_end_releases_the_secondary_button_first() {
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, BARREL);
|
||||
rewrite(&mut strokes, &start(7));
|
||||
// No end arrives for that contact; the pen touches down again with the tip alone.
|
||||
down(&mut strokes, 7, TIP);
|
||||
let events = rewrite(&mut strokes, &[start(7), end(7)].concat());
|
||||
assert_eq!(buttons(&events), [SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]);
|
||||
assert_eq!(strokes.right, None);
|
||||
// Another pen's barrel contact takes over from one whose end was lost.
|
||||
down(&mut strokes, 7, BARREL);
|
||||
rewrite(&mut strokes, &start(7));
|
||||
down(&mut strokes, 8, BARREL);
|
||||
let events = rewrite(&mut strokes, &[start(8), end(8)].concat());
|
||||
assert_eq!(buttons(&events), [SECONDARY_UP, SECONDARY_DOWN, SECONDARY_UP]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_canceled_barrel_contact_releases_the_secondary_button() {
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, BARREL);
|
||||
let events = rewrite(&mut strokes, &[start(7), vec![touch(7, TouchPhase::Cancel), Event::PointerGone]].concat());
|
||||
assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP]);
|
||||
assert_eq!(strokes.right, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_barrel_contact_that_never_pressed_releases_nothing() {
|
||||
// A finger drives the pointer, so egui-winit sends no button for the pen.
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
down(&mut strokes, 7, BARREL);
|
||||
let events = [vec![touch(7, TouchPhase::Start)], vec![touch(7, TouchPhase::Cancel)]].concat();
|
||||
assert_eq!(rewrite(&mut strokes, &events), events);
|
||||
down(&mut strokes, 7, BARREL);
|
||||
down(&mut strokes, 7, TIP);
|
||||
let events = [vec![touch(7, TouchPhase::Start)], start(7)].concat();
|
||||
assert_eq!(buttons(&rewrite(&mut strokes, &events)), [PRIMARY_DOWN]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_whose_touch_never_arrives_are_bounded() {
|
||||
let mut strokes = BarrelStrokes::default();
|
||||
for id in 0..40 {
|
||||
down(&mut strokes, id, BARREL);
|
||||
}
|
||||
assert_eq!(strokes.pending.len(), MAX_PENDING);
|
||||
assert_eq!(strokes.pending.last(), Some(&(39, true)));
|
||||
}
|
||||
}
|
||||
@@ -944,6 +944,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way.
|
||||
- **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Liquify tools' Use Pressure Pen on Windows, while winit 0.30 reports none on macOS or Linux and egui none on the web. On Linux, see `docs/development.md` for pens under Wayland.
|
||||
- **Linux display server for pens (part of #2028):** Preferences › Performance › Linux Display Server has X11 (XWayland) When a Pen Is Attached (`x11WithPen`, off by default), which opens the window through XWayland on a Wayland session only when a pen is attached as the app starts. Under XWayland a drawing tablet's pen draws as a mouse. Below the choice, the Performance page tells users whose tablet pen doesn't draw to choose an X11 (XWayland) option.
|
||||
- **Pen barrel button on Windows:** with Windows Ink, a pen that touches down with its barrel button held acts as the right mouse button until it lifts, so a tap opens the canvas context menu; on Linux the barrel button is the right button (#3659). A touch with the tip alone is the left button, with pressure, as before.
|
||||
- **Control transport:** idle sockets expire after 5 minutes, partial lines after an absolute 30 seconds from their first byte, and reply writes after 60 seconds (#1726). Real TCP regressions cover stalled peers and slot recovery; command execution has its separate existing timeout. MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739).
|
||||
- **MCP vertical type:** `add_text {vertical: true}` forwards to `text.create` or `text.createInPath`. Regression coverage checks point type, rectangular area type, type in a path and type on a path with true, false and omitted direction. This exposes an existing engine option; it does not change glyph orientation, CJK composition or the type/automation scores.
|
||||
- **Malformed MCP replies:** invalid JSON from the app drops the connection, reports that the operation may have completed and leaves the next call to reconnect; it never replays the edit (#1761). TCP regressions cover malformed and uncorrelated replies, connection closure and a subsequent inspection. Envelope regressions also verify zero backend calls and unchanged initialization state for invalid versions.
|
||||
|
||||
Reference in New Issue
Block a user