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
2 changes: 2 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ extraResources:
to: native/SwitchifyCursorOverlay.exe
- from: build/native/bluetooth-transport-helper/win-x64/SwitchifyBluetoothTransport.exe
to: native/SwitchifyBluetoothTransport.exe
- from: build/native/text-input-helper/win-x64/SwitchifyTextInput.exe
to: native/SwitchifyTextInput.exe

win:
icon: build/icon.ico
Expand Down
103 changes: 103 additions & 0 deletions native/text-input-helper/NativeMethods.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace Switchify.TextInput;

internal static partial class NativeMethods
{
private const uint INPUT_KEYBOARD = 1;
private const uint KEYEVENTF_KEYUP = 0x0002;
private const uint KEYEVENTF_UNICODE = 0x0004;
private const int MaxInputRecordsPerBatch = 128;

internal static int SendUnicodeText(string text)
{
if (text.Length == 0)
{
return 0;
}

int sentEvents = 0;
List<INPUT> batch = new(MaxInputRecordsPerBatch);

foreach (char codeUnit in text)
{
batch.Add(CreateUnicodeInput(codeUnit, keyUp: false));
batch.Add(CreateUnicodeInput(codeUnit, keyUp: true));

if (batch.Count >= MaxInputRecordsPerBatch)
{
sentEvents += SendBatch(batch);
batch.Clear();
}
}

if (batch.Count > 0)
{
sentEvents += SendBatch(batch);
}

return sentEvents;
}

private static int SendBatch(IReadOnlyList<INPUT> inputs)
{
INPUT[] batch = inputs.ToArray();
uint sent = SendInput((uint)batch.Length, batch, Marshal.SizeOf<INPUT>());
if (sent != batch.Length)
{
int errorCode = Marshal.GetLastPInvokeError();
throw new Win32Exception(
errorCode,
$"SendInput inserted {sent} of {batch.Length} events. Input may be blocked by Windows integrity level restrictions.");
}

return (int)sent;
}

private static INPUT CreateUnicodeInput(char codeUnit, bool keyUp)
{
return new INPUT
{
Type = INPUT_KEYBOARD,
InputUnion = new INPUTUNION
{
KeyboardInput = new KEYBDINPUT
{
VirtualKey = 0,
ScanCode = codeUnit,
Flags = KEYEVENTF_UNICODE | (keyUp ? KEYEVENTF_KEYUP : 0),
Time = 0,
ExtraInfo = UIntPtr.Zero
}
}
};
}

[LibraryImport("user32.dll", SetLastError = true)]
private static partial uint SendInput(uint inputCount, [In] INPUT[] inputs, int inputSize);

[StructLayout(LayoutKind.Sequential)]
private struct INPUT
{
internal uint Type;
internal INPUTUNION InputUnion;
}

[StructLayout(LayoutKind.Explicit)]
private struct INPUTUNION
{
[FieldOffset(0)]
internal KEYBDINPUT KeyboardInput;
}

[StructLayout(LayoutKind.Sequential)]
private struct KEYBDINPUT
{
internal ushort VirtualKey;
internal ushort ScanCode;
internal uint Flags;
internal uint Time;
internal UIntPtr ExtraInfo;
}
}
108 changes: 108 additions & 0 deletions native/text-input-helper/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Text.Json;

namespace Switchify.TextInput;

internal static class Program
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};

private static async Task Main()
{
Console.Out.WriteLine("""{"type":"ready"}""");
Console.Out.Flush();

while (true)
{
string? line = await Console.In.ReadLineAsync();
if (line is null)
{
return;
}

if (!HandleCommandLine(line))
{
return;
}
}
}

private static bool HandleCommandLine(string line)
{
TextInputCommand? command;
try
{
command = JsonSerializer.Deserialize<TextInputCommand>(line, JsonOptions);
}
catch (JsonException error)
{
WriteError(null, "invalid_command", error.Message);
return true;
}

if (command?.Type is null)
{
WriteError(command?.Id, "invalid_command", "Command type is required.");
return true;
}

switch (command.Type)
{
case "typeText":
HandleTypeText(command);
return true;
case "shutdown":
return false;
default:
WriteError(command.Id, "invalid_command", "Unsupported command type.");
return true;
}
}

private static void HandleTypeText(TextInputCommand command)
{
if (string.IsNullOrWhiteSpace(command.Id))
{
WriteError(null, "invalid_command", "Command id is required.");
return;
}

if (command.Text is null)
{
WriteError(command.Id, "invalid_command", "Command text is required.");
return;
}

try
{
int sentEvents = NativeMethods.SendUnicodeText(command.Text);
Console.Out.WriteLine(JsonSerializer.Serialize(new
{
type = "result",
id = command.Id,
ok = true,
sentEvents
}));
Console.Out.Flush();
}
catch (Exception error)
{
WriteError(command.Id, "send_input_failed", error.Message);
}
}

private static void WriteError(string? id, string code, string message)
{
Console.Out.WriteLine(JsonSerializer.Serialize(new
{
type = "error",
id,
ok = false,
code,
message
}));
Console.Out.Flush();
}
}
8 changes: 8 additions & 0 deletions native/text-input-helper/TextInputCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace Switchify.TextInput;

internal sealed class TextInputCommand
{
public string? Type { get; set; }
public string? Id { get; set; }
public string? Text { get; set; }
}
15 changes: 15 additions & 0 deletions native/text-input-helper/TextInputHelper.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AssemblyName>SwitchifyTextInput</AssemblyName>
<RootNamespace>Switchify.TextInput</RootNamespace>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"native:build": "node scripts/build-cursor-overlay-helper.cjs",
"native:build-overlay": "npm run native:build",
"native:smoke-overlay": "node scripts/smoke-cursor-overlay-helper.cjs",
"native:smoke-text": "node scripts/smoke-text-input-helper.cjs",
"package:win": "npm run build && npm run native:build && electron-builder --win --x64 --publish never && node scripts/sign-win-artifacts.cjs --update-latest-yml",
"package:win:verify-uiaccess": "node scripts/verify-win-uiaccess-package.cjs",
"signing:create-dev-cert": "node scripts/create-dev-signing-cert.cjs",
Expand Down
6 changes: 6 additions & 0 deletions scripts/build-cursor-overlay-helper.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ const helpers = [
projectPath: resolveProjectPath('native', 'bluetooth-transport-helper', 'SwitchifyBluetoothTransport.csproj'),
outputDir: resolveProjectPath('build', 'native', 'bluetooth-transport-helper', 'win-x64'),
outputExeName: 'SwitchifyBluetoothTransport.exe'
},
{
name: 'text input helper',
projectPath: resolveProjectPath('native', 'text-input-helper', 'TextInputHelper.csproj'),
outputDir: resolveProjectPath('build', 'native', 'text-input-helper', 'win-x64'),
outputExeName: 'SwitchifyTextInput.exe'
}
];

Expand Down
2 changes: 1 addition & 1 deletion scripts/package-win-after-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ function signWindowsExecutable(filePath) {
}

function signNativeHelpers(appOutDir) {
for (const helperName of ['SwitchifyCursorOverlay.exe', 'SwitchifyBluetoothTransport.exe']) {
for (const helperName of ['SwitchifyCursorOverlay.exe', 'SwitchifyBluetoothTransport.exe', 'SwitchifyTextInput.exe']) {
const helperPath = path.join(appOutDir, 'resources', 'native', helperName);
if (!fs.existsSync(helperPath)) {
throw new Error(`Native helper is missing from packaged resources: ${helperPath}`);
Expand Down
Loading