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; }