diff --git a/CHANGELOG.md b/CHANGELOG.md
index 91dfedd..15b5211 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,8 +12,8 @@
them while the call still reported success. Characters now go out 15 ms apart by default, timed
precisely, and typing pauses while the focused program is busy; `perCharMs` sets the pace. Text
that can't be typed within the request's time is refused before typing, and a program that stops
- responding, a slow one that runs out the request's time, or a stopping seat ends the call with the
- exact count typed.
+ responding, a slow one that runs out the request's time, or input Windows refuses partway ends the
+ call with the exact count typed.
- **The sign-in window explains itself.** `anode browser --sign-in` opened a blank window when given
no address, with its guidance in the terminal behind it. Its first tab now says what the window is
for and that it must be closed afterwards, and up to 8 addresses open in tabs of their own.
diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md
index 7565b52..95df730 100644
--- a/docs/PROTOCOL.md
+++ b/docs/PROTOCOL.md
@@ -252,9 +252,12 @@ schema, so its row lists every argument it accepts.
keyboard focus is busy, and answers with `typed`, `perCharMs` and `elapsedMs`. Text that can't be typed
in 45 seconds at that pace (three quarters of a shorter `timeoutMs`) is refused before anything is
typed. If the program stops responding for 5 seconds, typing would pass 52 seconds (seven eighths of a
-shorter `timeoutMs`), or the request is cancelled, it fails with `errorCode` `typing_stopped`, `typed`
-and `total` (a newline or a surrogate pair counts as one character) and `nextIndex`, the UTF-16 index
-where the untyped rest starts.
+shorter `timeoutMs`), the request is cancelled, or Windows refuses the input partway, it fails with
+`errorCode` `typing_stopped`, `typed` and `total` (a newline or a surrogate pair counts as one
+character) and `nextIndex`, the UTF-16 index where the untyped rest starts. `partlySent` is `true`
+when part of the character at `nextIndex` went out before a refusal, such as an Enter pressed but not
+released. Windows may not report every refusal: input an elevated window doesn't accept may be
+dropped without an error.
Buttons: `left`, `right`, `middle`, `x1`, `x2`. Key names: letters, digits, `f1`–`f24`, `num0`–`num9`,
`enter`, `esc`, `space`, `tab`, `backspace`, `del`, `ins`, `home`, `end`, `pgup`, `pgdn`, arrows,
diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md
index 2fe4ea6..5dddd5d 100644
--- a/docs/TROUBLESHOOTING.md
+++ b/docs/TROUBLESHOOTING.md
@@ -441,9 +441,16 @@ If text still arrives wrong:
- **Skip the keyboard.** For a form field, `seat_element` `set_value` on the observed control sets the
whole value at once.
- **`typing_stopped`.** The focused program stopped responding for 5 seconds, typing ran out of the
- call's time, or the seat stopped. The error gives `typed` and `total` (a newline or an emoji counts as one character)
- and `nextIndex`, the UTF-16 index where the untyped rest starts. Check the field before you send the
- rest; Anode never sends it again by itself.
+ call's time, or Windows refused the input partway. The error gives `typed` and `total` (a newline or an
+ emoji counts as one character) and `nextIndex`, the UTF-16 index where the untyped rest starts. When
+ `partlySent` is `true`, part of the character at `nextIndex` went out before the refusal: an Enter may
+ have been pressed and not released, so it may already have taken effect, and Anode releases the key
+ when the lease ends. Check the field before you send the rest; Anode never sends it again by itself.
+- **Windows may not report every refusal.** Input an elevated window doesn't accept may be dropped
+ without an error, so check the field after typing into one.
+- **The seat stopped during a call.** The call ends with a lost connection or `typing_stopped`. Either
+ way, stopping signs the seat out, which closes the program you were typing into, so don't send the
+ rest.
### A display change fails or looks wrong
diff --git a/src/Anode/Cli/InputChecks.cs b/src/Anode/Cli/InputChecks.cs
index f0a9190..ea0662c 100644
--- a/src/Anode/Cli/InputChecks.cs
+++ b/src/Anode/Cli/InputChecks.cs
@@ -149,10 +149,51 @@ public static string Pacing()
"typing went on after its request was cancelled: " + halted.ToJsonString());
}
+ // Windows refusing a character still ends the call with the exact count. partlySent says part of that character
+ // went out first, as when an Enter's press went through and its release was refused.
+ foreach (var (at, partly) in new[] { (0, false), (3, true), (4, false) })
+ {
+ var blocked = new Recorder();
+ var typist = blocked.Typist(_ => true);
+ var refusal = new TextTyping.Refused("stand-in refusal: SendInput accepted 0 of 2 events.", partly);
+ var partway = TextTyping.Type(Sample, null, new TextTyping.Typist
+ {
+ Send = unit => { if (blocked.Sent.Count == at) throw refusal; typist.Send(unit); },
+ Responsive = typist.Responsive, Wait = typist.Wait, Now = typist.Now
+ });
+ string why = partway.Str("error") ?? "";
+ int next = TextTyping.Units(Sample)[at].Index;
+ Require(partway.Bool("ok") == false && partway.Str("errorCode") == "typing_stopped" && partway.Int("typed") == at
+ && partway.Int("total") == 7 && partway.Int("nextIndex") == next && blocked.Sent.Count == at
+ && (partway["partlySent"] is null) != partly
+ && why.Contains("Windows refused the input (stand-in refusal: SendInput accepted 0 of 2 events)", StringComparison.Ordinal)
+ && why.Contains(partly ? $"Part of character {at + 1} went out first" : at == 0 ? "Nothing was sent." : $"Nothing after character {at} was sent.",
+ StringComparison.Ordinal)
+ && !why.Contains("ab\n", StringComparison.Ordinal) && !why.Contains("\U0001F600", StringComparison.Ordinal),
+ $"a refusal at character {at + 1} (partly sent: {partly}) did not end typing with the exact count: " + partway.ToJsonString());
+ }
+ Require(stopped["partlySent"] is null && late["partlySent"] is null,
+ "a stop before a character went out said part of it was sent: " + stopped.ToJsonString());
+ // Any other exception is a fault in Anode itself, and fails loudly instead of passing for a refusal.
+ foreach (Exception fault in new Exception[] { new InvalidOperationException("stand-in fault"), new ObjectDisposedException("stand-in") })
+ {
+ var faulty = new Recorder().Typist(_ => true);
+ bool surfaced = false;
+ try
+ {
+ TextTyping.Type("ab", null, new TextTyping.Typist
+ {
+ Send = _ => throw fault, Responsive = faulty.Responsive, Wait = faulty.Wait, Now = faulty.Now
+ });
+ }
+ catch (Exception caught) when (ReferenceEquals(caught, fault)) { surfaced = true; }
+ Require(surfaced, $"typing turned a fault in Anode's own code ({fault.GetType().Name}) into a typing_stopped reply");
+ }
+
var refused = new Recorder();
Require(TextTyping.Type(new string('x', 3002), null, refused.Typist(_ => true)).Bool("ok") == false && refused.Sent.Count == 0,
"text too long for one call was partly typed before being refused");
- return "15 ms apart by default, pauses for a busy program, stops with an exact count for a hung one, a deadline or a cancel, keeps Enter, Tab and emoji whole";
+ return "15 ms apart by default, pauses for a busy program, stops with an exact count for a hung one, a deadline, a cancel or input Windows refuses partway, keeps Enter, Tab and emoji whole";
}
public static string Limits()
diff --git a/src/Anode/Core/Input/InputInjector.cs b/src/Anode/Core/Input/InputInjector.cs
index db98f83..fec4f91 100644
--- a/src/Anode/Core/Input/InputInjector.cs
+++ b/src/Anode/Core/Input/InputInjector.cs
@@ -115,12 +115,33 @@ public static System.Drawing.Point CursorPosition() =>
private static void Send(params Input[] inputs)
{
- if (Desktop.WindowAccess.InputBlockReason() is { } blocked) throw new InvalidOperationException(blocked);
- uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf());
+ uint sent = SendCounted(inputs, out string? blocked);
+ if (blocked is not null) throw new InvalidOperationException(blocked);
if (sent != inputs.Length)
throw Native.Native.LastError($"SendInput accepted {sent} of {inputs.Length} events");
}
+ /// Sends the events unless the seat's input is blocked, and returns how many Windows accepted.
+ private static uint SendCounted(Input[] inputs, out string? blocked)
+ {
+ blocked = Desktop.WindowAccess.InputBlockReason();
+ return blocked is null ? SendInput((uint)inputs.Length, inputs, Marshal.SizeOf()) : 0;
+ }
+
+ ///
+ /// Sends one step of typing. A refusal says whether part of the step went out: an earlier part, or some of these
+ /// events. Windows may not report every refusal, though: input an elevated window doesn't take may be dropped
+ /// without an error.
+ ///
+ private static void TypingStep(Input[] events, bool earlierPartSent)
+ {
+ uint sent = SendCounted(events, out string? blocked);
+ if (blocked is not null) throw new TextTyping.Refused(blocked, earlierPartSent);
+ if (sent != events.Length)
+ throw new TextTyping.Refused(Native.Native.LastError($"SendInput accepted {sent} of {events.Length} events").Message,
+ earlierPartSent || sent > 0);
+ }
+
private static Input AbsoluteMove(int x, int y)
{
var size = ScreenSize();
@@ -312,10 +333,11 @@ private static void TypeUnit(TextTyping.Unit unit, Action wait)
// As Press does, with the hold timed precisely.
ushort vk = KeyCodes.Resolve(key);
HeldKeys.Add((vk, true));
- try { Send(KeyEvent(vk, false, true)); wait(TextTyping.KeyHoldMs); }
+ bool pressed = false;
+ try { TypingStep([KeyEvent(vk, false, true)], false); pressed = true; wait(TextTyping.KeyHoldMs); }
finally
{
- Send(KeyEvent(vk, true, true));
+ TypingStep([KeyEvent(vk, true, true)], pressed);
HeldKeys.Remove((vk, true));
}
return;
@@ -327,7 +349,7 @@ private static void TypeUnit(TextTyping.Unit unit, Action wait)
events.Add(new Input { Type = InputKeyboard, Union = { Keyboard = new KeyboardInput { Scan = character, Flags = KeyEventUnicode } } });
events.Add(new Input { Type = InputKeyboard, Union = { Keyboard = new KeyboardInput { Scan = character, Flags = KeyEventUnicode | KeyEventKeyUp } } });
}
- Send(events.ToArray());
+ TypingStep(events.ToArray(), false);
}
private static bool IsExtendedKey(ushort vk) => vk switch
diff --git a/src/Anode/Core/Input/TextTyping.cs b/src/Anode/Core/Input/TextTyping.cs
index 2301561..ce378f5 100644
--- a/src/Anode/Core/Input/TextTyping.cs
+++ b/src/Anode/Core/Input/TextTyping.cs
@@ -68,6 +68,15 @@ internal sealed class Typist
public CancellationToken Cancel { get; init; }
}
+ ///
+ /// Windows refused a step of typing. when part of the step went out before the refusal,
+ /// such as the press of an Enter whose release was refused.
+ ///
+ internal sealed class Refused(string message, bool partlySent) : Exception(message)
+ {
+ public bool PartlySent { get; } = partlySent;
+ }
+
internal static List Units(string text)
{
var units = new List(text.Length);
@@ -119,7 +128,7 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti
var units = Units(text);
double start = typist.Now(), previous = start;
string ranOut = string.Create(CultureInfo.InvariantCulture, $"typing would have run past {stop / 1000.0:0.#} seconds, the time this call has");
- const string cancelled = "the request was cancelled, because the seat is stopping or its time ran out";
+ const string cancelled = "the request was cancelled because its time ran out (time spent waiting for the desktop counts) or the seat is stopping";
for (int i = 0; i < units.Count; i++)
{
if (i > 0)
@@ -143,7 +152,12 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti
return Stopped(units, i, cancelled);
if (typist.Now() - start + (units[i].Key is null ? 0 : KeyHoldMs) > stop) return Stopped(units, i, ranOut);
previous = typist.Now();
- typist.Send(units[i]);
+ try { typist.Send(units[i]); }
+ catch (Refused refused)
+ {
+ // The count still matters: the caller mustn't send again what already arrived.
+ return Stopped(units, i, $"Windows refused the input ({refused.Message.TrimEnd('.')})", refused.PartlySent);
+ }
}
double elapsed = typist.Now() - start;
string how = pace > 0 ? $"{pace} ms apart" : "without a pause";
@@ -159,16 +173,22 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti
///
/// Ends a call partway, saying exactly how far it got. The reply gives counts only and never repeats the text,
- /// which may be a secret.
+ /// which may be a secret. means part of the next character, the one at index
+ /// of , went out before Windows refused the rest.
///
- private static JsonObject Stopped(List units, int typed, string reason)
+ private static JsonObject Stopped(List units, int typed, string reason, bool partlySent = false)
{
- var failure = JsonLine.Fail($"Typed the first {typed} of {units.Count} characters, then stopped: {reason}. Nothing after "
- + $"character {typed} was sent. Check what reached the field before you send the rest (from UTF-16 index {units[typed].Index}).");
+ string sent = partlySent
+ ? $"Part of character {typed + 1} went out first, so it may already have taken effect, as a pressed Enter does; "
+ + "nothing after it was sent."
+ : typed == 0 ? "Nothing was sent." : $"Nothing after character {typed} was sent.";
+ var failure = JsonLine.Fail($"Typed the first {typed} of {units.Count} characters, then stopped: {reason}. {sent} "
+ + $"Check what reached the field before you send the rest (from UTF-16 index {units[typed].Index}).");
failure["errorCode"] = "typing_stopped";
failure["typed"] = typed;
failure["total"] = units.Count;
failure["nextIndex"] = units[typed].Index;
+ if (partlySent) failure["partlySent"] = true;
return failure;
}
diff --git a/src/Anode/Seat/SeatHost.cs b/src/Anode/Seat/SeatHost.cs
index d751d01..9abcffd 100644
--- a/src/Anode/Seat/SeatHost.cs
+++ b/src/Anode/Seat/SeatHost.cs
@@ -177,7 +177,8 @@ private static async Task HandleOwnedAsync(JsonObject request, Cance
bool changesUi = op.StartsWith("input.", StringComparison.Ordinal) || op is "run" or "steam.launch" or "ps.kill" or "android.studio" or "browser.open";
if (!desktop && !changesUi) return Dispatch(op, request);
if (changesUi) Desktop.InvalidateObservations();
- // Typing can take most of a request's time, so it stops for the lease's deadline and for a stopping seat.
+ // Typing can take most of a request's time, so it stops at the request's deadline, or at a shutdown sent on
+ // another connection. The daemon's own shutdown waits behind it; the sign-out that follows ends it.
if (op == "input.text")
return InputInjector.TypeText(request.Str("text") ?? throw new ArgumentException("text needs 'text'."),
request.Int("perCharMs"), request.Int("timeoutMs") ?? 60_000, cancel);