using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Hush.Config;
using Hush.Daemon;
namespace Hush.Cli;
public class SocketClient : IAsyncDisposable
{
private readonly UnixDomainSocketEndPoint _endPoint;
private readonly Socket _socket;
public SocketClient()
{
var runtimeDir = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
var baseDir = string.IsNullOrEmpty(runtimeDir) ? Path.GetTempPath() : runtimeDir;
var socketPath = Path.Combine(baseDir, "hush.sock");
_endPoint = new UnixDomainSocketEndPoint(socketPath);
_socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
}
public async Task ConnectAsync(TimeSpan timeout)
{
var cts = new CancellationTokenSource(timeout);
await _socket.ConnectAsync(_endPoint, cts.Token);
}
///
/// Sends a command with no config payload.
/// Action commands (START/STOP/TOGGLE/ABORT) always include a 4-byte zero length prefix
/// so the daemon can read the same framing unconditionally.
///
public async Task SendCommandAsync(byte command)
{
if (IsActionCommand(command))
{
// [cmd][4 zero bytes] — signals no config override
var frame = new byte[5];
frame[0] = command;
await _socket.SendAsync(frame, SocketFlags.None);
}
else
{
await _socket.SendAsync(new[] { command }, SocketFlags.None);
}
}
///
/// Sends an action command with a HushConfig override payload.
/// Format: [1 byte cmd][4-byte LE length][N bytes JSON]
///
public async Task SendCommandWithConfigAsync(byte command, HushConfig config)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(config, HushConfigContext.Default.HushConfig);
var lenBytes = BitConverter.GetBytes(jsonBytes.Length);
var frame = new byte[1 + 4 + jsonBytes.Length];
frame[0] = command;
lenBytes.CopyTo(frame, 1);
jsonBytes.CopyTo(frame, 5);
await _socket.SendAsync(frame, SocketFlags.None);
}
///
/// Sends a request with a typed JSON payload (e.g. GENERATE_PROFILE).
/// Format: [1 byte cmd][4-byte LE length][N bytes JSON]
///
public async Task SendRequestAsync(byte command, TRequest payload, JsonTypeInfo typeInfo)
{
var jsonBytes = JsonSerializer.SerializeToUtf8Bytes(payload, typeInfo);
var lenBytes = BitConverter.GetBytes(jsonBytes.Length);
var frame = new byte[1 + 4 + jsonBytes.Length];
frame[0] = command;
lenBytes.CopyTo(frame, 1);
jsonBytes.CopyTo(frame, 5);
await _socket.SendAsync(frame, SocketFlags.None);
}
public async Task ReceiveJsonAsync(TimeSpan timeout)
{
var cts = new CancellationTokenSource(timeout);
var buffer = new byte[4096];
var bytesRead = await _socket.ReceiveAsync(buffer, SocketFlags.None, cts.Token);
if (bytesRead == 0)
return default;
var json = Encoding.UTF8.GetString(buffer, 0, bytesRead);
return (T?)JsonSerializer.Deserialize(json, typeof(T), DaemonJsonContext.Default);
}
public async Task ReceiveRawJsonAsync(TimeSpan timeout)
{
var cts = new CancellationTokenSource(timeout);
var buffer = new byte[4096];
var bytesRead = await _socket.ReceiveAsync(buffer, SocketFlags.None, cts.Token);
if (bytesRead == 0)
return null;
return Encoding.UTF8.GetString(buffer, 0, bytesRead);
}
public async ValueTask DisposeAsync()
{
_socket.Dispose();
await ValueTask.CompletedTask;
}
private static bool IsActionCommand(byte command) =>
command is DaemonProtocol.START or DaemonProtocol.STOP
or DaemonProtocol.ABORT or DaemonProtocol.TOGGLE;
}