Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 6 additions & 3 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 42 additions & 1 deletion src/Anode/Cli/InputChecks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
32 changes: 27 additions & 5 deletions src/Anode/Core/Input/InputInjector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Input>());
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");
}

/// <summary>Sends the events unless the seat's input is blocked, and returns how many Windows accepted.</summary>
private static uint SendCounted(Input[] inputs, out string? blocked)
{
blocked = Desktop.WindowAccess.InputBlockReason();
return blocked is null ? SendInput((uint)inputs.Length, inputs, Marshal.SizeOf<Input>()) : 0;
}

/// <summary>
/// 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.
/// </summary>
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();
Expand Down Expand Up @@ -312,10 +333,11 @@ private static void TypeUnit(TextTyping.Unit unit, Action<double> 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;
Expand All @@ -327,7 +349,7 @@ private static void TypeUnit(TextTyping.Unit unit, Action<double> 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
Expand Down
32 changes: 26 additions & 6 deletions src/Anode/Core/Input/TextTyping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ internal sealed class Typist
public CancellationToken Cancel { get; init; }
}

/// <summary>
/// Windows refused a step of typing. <see cref="PartlySent"/> when part of the step went out before the refusal,
/// such as the press of an Enter whose release was refused.
/// </summary>
internal sealed class Refused(string message, bool partlySent) : Exception(message)
{
public bool PartlySent { get; } = partlySent;
}

internal static List<Unit> Units(string text)
{
var units = new List<Unit>(text.Length);
Expand Down Expand Up @@ -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)
Expand All @@ -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";
Expand All @@ -159,16 +173,22 @@ public static JsonObject Type(string text, int? perCharMs, Typist typist, int ti

/// <summary>
/// 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. <paramref name="partlySent"/> means part of the next character, the one at index
/// <paramref name="typed"/> of <paramref name="units"/>, went out before Windows refused the rest.
/// </summary>
private static JsonObject Stopped(List<Unit> units, int typed, string reason)
private static JsonObject Stopped(List<Unit> 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;
}

Expand Down
3 changes: 2 additions & 1 deletion src/Anode/Seat/SeatHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ private static async Task<JsonObject> 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);
Expand Down
Loading