mirror of
https://github.com/AsamK/signal-cli.git
synced 2026-05-17 13:11:00 +00:00
Replace Unix socket with stdin/stdout for tunnel communication
Use the tunnel subprocess' stdin for sending control messages and stdout for receiving control events, instead of a separate Unix domain socket. This eliminates: - Temporary directory creation (/tmp/sc-<random>/) - Socket path and auth token in config JSON - Connection retry loop (50x at 200ms) - Auth message handshake - Socket cleanup on call end The tunnel's stderr is captured separately for logging. Config JSON is written as the first line on stdin, followed by control messages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8169c9031b
commit
dec5b03487
@ -4,24 +4,26 @@
|
|||||||
|
|
||||||
signal-cli supports voice calls by spawning a subprocess called
|
signal-cli supports voice calls by spawning a subprocess called
|
||||||
`signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and
|
`signal-call-tunnel` for each call. The tunnel handles WebRTC negotiation and
|
||||||
audio transport. signal-cli communicates with it over a Unix domain socket using
|
audio transport. signal-cli communicates with the tunnel over its stdin/stdout
|
||||||
newline-delimited JSON messages, relaying signaling between the tunnel and the
|
using newline-delimited JSON messages, relaying signaling between the tunnel
|
||||||
Signal protocol.
|
and the Signal protocol.
|
||||||
|
|
||||||
```
|
```
|
||||||
signal-cli signal-call-tunnel
|
signal-cli signal-call-tunnel
|
||||||
| |
|
| |
|
||||||
|-- spawn (config on stdin) --------->|
|
|-- spawn --------------------------->|
|
||||||
|
|-- config JSON on stdin ------------>|
|
||||||
| |
|
| |
|
||||||
|<======= ctrl.sock (JSON) ==========>|
|
|-- commands on stdin --------------->|
|
||||||
| signaling relay | WebRTC
|
|<-- events on stdout ----------------|
|
||||||
| | audio I/O
|
| | WebRTC
|
||||||
|
| signaling relay | audio I/O
|
||||||
| |
|
| |
|
||||||
|
| (stderr: tunnel logging) -------->| (captured by signal-cli)
|
||||||
```
|
```
|
||||||
|
|
||||||
Each call gets its own tunnel process and control socket inside a temporary
|
Each call gets its own tunnel process. When the call ends, signal-cli closes
|
||||||
directory (`/tmp/sc-<random>/`). When the call ends, signal-cli kills the
|
stdin and destroys the process.
|
||||||
process and deletes the directory.
|
|
||||||
|
|
||||||
Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings
|
Audio device names (`inputDeviceName`, `outputDeviceName`) are opaque strings
|
||||||
returned by the tunnel in its `ready` message. signal-cli passes them through
|
returned by the tunnel in its `ready` message. signal-cli passes them through
|
||||||
@ -33,11 +35,11 @@ to JSON-RPC clients, which use them to connect audio via platform APIs.
|
|||||||
|
|
||||||
For each call, signal-cli:
|
For each call, signal-cli:
|
||||||
|
|
||||||
1. Creates a temporary directory `/tmp/sc-<random>/` (mode `0700`)
|
1. Spawns `signal-call-tunnel`
|
||||||
2. Generates a random 32-byte auth token
|
2. Writes config JSON followed by a newline to stdin
|
||||||
3. Spawns `signal-call-tunnel` with config JSON on stdin
|
3. Keeps stdin open for subsequent control messages
|
||||||
4. Connects to the control socket (retries up to 50x at 200 ms intervals)
|
4. Reads control events from stdout
|
||||||
5. Authenticates with the auth token
|
5. Captures stderr for logging
|
||||||
|
|
||||||
The `signal-call-tunnel` binary is located by searching (in order):
|
The `signal-call-tunnel` binary is located by searching (in order):
|
||||||
|
|
||||||
@ -47,14 +49,12 @@ The `signal-call-tunnel` binary is located by searching (in order):
|
|||||||
|
|
||||||
### Config JSON
|
### Config JSON
|
||||||
|
|
||||||
Written to the tunnel's stdin before it starts:
|
The first line written to the tunnel's stdin:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"call_id": 12345,
|
"call_id": 12345,
|
||||||
"is_outgoing": true,
|
"is_outgoing": true,
|
||||||
"control_socket_path": "/tmp/sc-a1b2c3/ctrl.sock",
|
|
||||||
"control_token": "dG9rZW4...",
|
|
||||||
"local_device_id": 1,
|
"local_device_id": 1,
|
||||||
"input_device_name": "signal_input",
|
"input_device_name": "signal_input",
|
||||||
"output_device_name": "signal_output"
|
"output_device_name": "signal_output"
|
||||||
@ -65,8 +65,6 @@ Written to the tunnel's stdin before it starts:
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
|
| `call_id` | unsigned 64-bit integer | Call identifier (use unsigned representation) |
|
||||||
| `is_outgoing` | boolean | Whether this is an outgoing call |
|
| `is_outgoing` | boolean | Whether this is an outgoing call |
|
||||||
| `control_socket_path` | string | Path where the tunnel creates its control socket |
|
|
||||||
| `control_token` | string | Base64-encoded 32-byte auth token |
|
|
||||||
| `local_device_id` | integer | Signal device ID |
|
| `local_device_id` | integer | Signal device ID |
|
||||||
| `input_device_name` | string (optional) | Requested input audio device name |
|
| `input_device_name` | string (optional) | Requested input audio device name |
|
||||||
| `output_device_name` | string (optional) | Requested output audio device name |
|
| `output_device_name` | string (optional) | Requested output audio device name |
|
||||||
@ -78,25 +76,16 @@ and `signal_output`, which must match the pre-installed BlackHole drivers.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Control Socket Protocol
|
## Control Protocol
|
||||||
|
|
||||||
Unix SOCK_STREAM at `ctrl.sock`. Newline-delimited JSON messages.
|
Newline-delimited JSON messages over stdin (signal-cli to tunnel) and stdout
|
||||||
|
(tunnel to signal-cli). The first line on stdin is the config JSON. Subsequent
|
||||||
|
lines are control messages.
|
||||||
|
|
||||||
### Authentication
|
### signal-cli -> Tunnel (stdin)
|
||||||
|
|
||||||
The first message from signal-cli **must** be an auth message. The token is
|
|
||||||
a random 32-byte value generated per call and passed in the startup config.
|
|
||||||
The tunnel performs constant-time comparison.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"type":"auth","token":"<base64-encoded token>"}
|
|
||||||
```
|
|
||||||
|
|
||||||
### signal-cli -> Tunnel
|
|
||||||
|
|
||||||
| Type | When | Fields |
|
| Type | When | Fields |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
| `auth` | First message | `token` |
|
|
||||||
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
|
| `createOutgoingCall` | Outgoing call setup | `callId`, `peerId` |
|
||||||
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
|
| `proceed` | After offer/receivedOffer | `callId`, `hideIp`, `iceServers` |
|
||||||
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
|
| `receivedOffer` | Incoming call | `callId`, `peerId`, `opaque`, `age`, `senderDeviceId`, `senderIdentityKey`, `receiverIdentityKey` |
|
||||||
@ -105,7 +94,7 @@ The tunnel performs constant-time comparison.
|
|||||||
| `accept` | User accepts incoming call | *(none)* |
|
| `accept` | User accepts incoming call | *(none)* |
|
||||||
| `hangup` | End the call | *(none)* |
|
| `hangup` | End the call | *(none)* |
|
||||||
|
|
||||||
### Tunnel -> signal-cli
|
### Tunnel -> signal-cli (stdout)
|
||||||
|
|
||||||
| Type | When | Fields |
|
| Type | When | Fields |
|
||||||
|------|------|--------|
|
|------|------|--------|
|
||||||
@ -132,20 +121,17 @@ Opaque blobs and identity keys are base64-encoded. ICE servers use the format:
|
|||||||
signal-cli signal-call-tunnel
|
signal-cli signal-call-tunnel
|
||||||
| |
|
| |
|
||||||
|-- spawn process ------------------> |
|
|-- spawn process ------------------> |
|
||||||
| (config JSON on stdin) |
|
|-- config JSON + newline on stdin ---->|
|
||||||
| | initialize
|
| | parse config
|
||||||
| | bind ctrl.sock
|
| | initialize audio
|
||||||
| |
|
| |
|
||||||
|-- connect to ctrl.sock -------------->|
|
|<-------- ready (on stdout) -----------|
|
||||||
| (retries: 50x @ 200ms) |
|
|
||||||
|<-------- ready -----------------------|
|
|
||||||
| {"type":"ready", |
|
| {"type":"ready", |
|
||||||
| "inputDeviceName":"...", |
|
| "inputDeviceName":"...", |
|
||||||
| "outputDeviceName":"..."} |
|
| "outputDeviceName":"..."} |
|
||||||
|-- auth ------------------------------>|
|
|
||||||
| {"type":"auth","token":"<b64>"} |
|
|
||||||
| | constant-time token verify
|
|
||||||
| |
|
| |
|
||||||
|
|-- control messages on stdin --------->|
|
||||||
|
|<-- control events on stdout ----------|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -159,7 +145,6 @@ signal-cli signal-call-tunnel Remote Phone
|
|||||||
| | |
|
| | |
|
||||||
|-- spawn + config ------->| |
|
|-- spawn + config ------->| |
|
||||||
|<-- ready ----------------| |
|
|<-- ready ----------------| |
|
||||||
|-- auth ----------------->| |
|
|
||||||
|-- createOutgoingCall --->| |
|
|-- createOutgoingCall --->| |
|
||||||
|-- proceed (TURN) ------->| |
|
|-- proceed (TURN) ------->| |
|
||||||
| | create offer |
|
| | create offer |
|
||||||
@ -183,7 +168,6 @@ signal-cli signal-call-tunnel Remote Phone
|
|||||||
|<-- offer via Signal --------------------------------|
|
|<-- offer via Signal --------------------------------|
|
||||||
|-- spawn + config ------->| |
|
|-- spawn + config ------->| |
|
||||||
|<-- ready ----------------| |
|
|<-- ready ----------------| |
|
||||||
|-- auth ----------------->| |
|
|
||||||
|-- receivedOffer -------->| (+ identity keys) |
|
|-- receivedOffer -------->| (+ identity keys) |
|
||||||
|-- proceed (TURN) ------->| |
|
|-- proceed (TURN) ------->| |
|
||||||
| | process offer |
|
| | process offer |
|
||||||
@ -311,10 +295,9 @@ during ICE restarts (not during initial connection).
|
|||||||
|
|
||||||
Manages the call lifecycle from the Java side:
|
Manages the call lifecycle from the Java side:
|
||||||
|
|
||||||
1. Creates a temp directory and generates a random auth token
|
1. Spawns `signal-call-tunnel` and writes config JSON to stdin
|
||||||
2. Spawns `signal-call-tunnel` with config JSON on stdin
|
2. Keeps stdin open as the control write channel; reads stdout for control events
|
||||||
3. Connects to the control socket (retries up to 50x at 200 ms intervals),
|
3. Captures stderr for tunnel logging
|
||||||
authenticates, and relays signaling between the tunnel and the Signal protocol
|
|
||||||
4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready`
|
4. Parses `inputDeviceName` and `outputDeviceName` from the tunnel's `ready`
|
||||||
message and includes them in `CallInfo`
|
message and includes them in `CallInfo`
|
||||||
5. Translates tunnel state changes into `CallInfo.State` values and fires
|
5. Translates tunnel state changes into `CallInfo.State` values and fires
|
||||||
@ -322,7 +305,7 @@ Manages the call lifecycle from the Java side:
|
|||||||
6. Defers the `accept` message for incoming calls until the tunnel reports
|
6. Defers the `accept` message for incoming calls until the tunnel reports
|
||||||
`Ringing` state (sending earlier causes the tunnel to drop it)
|
`Ringing` state (sending earlier causes the tunnel to drop it)
|
||||||
7. Schedules a 60-second ring timeout for both incoming and outgoing calls
|
7. Schedules a 60-second ring timeout for both incoming and outgoing calls
|
||||||
8. On hangup: sends hangup message, kills the process, deletes the control socket
|
8. On hangup: sends hangup message, closes stdin, and destroys the process
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -369,14 +352,3 @@ Identity keys in `senderIdentityKey` and `receiverIdentityKey` must be **raw
|
|||||||
33-byte serialized form is used instead, SRTP key derivation produces different
|
33-byte serialized form is used instead, SRTP key derivation produces different
|
||||||
keys on each side, causing authentication failures.
|
keys on each side, causing authentication failures.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
/tmp/sc-<random>/
|
|
||||||
ctrl.sock control socket (signal-cli <-> tunnel)
|
|
||||||
```
|
|
||||||
|
|
||||||
The control socket is created with mode `0700` on the parent directory. The
|
|
||||||
directory and its contents are deleted when the call ends.
|
|
||||||
|
|||||||
@ -19,14 +19,9 @@ import java.io.IOException;
|
|||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.OutputStreamWriter;
|
import java.io.OutputStreamWriter;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
import java.net.StandardProtocolFamily;
|
|
||||||
import java.net.UnixDomainSocketAddress;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.nio.channels.SocketChannel;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.nio.file.attribute.PosixFilePermissions;
|
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@ -96,18 +91,11 @@ public class CallManager implements AutoCloseable {
|
|||||||
.resolveRecipientAddress(recipientId)
|
.resolveRecipientAddress(recipientId)
|
||||||
.toApiRecipientAddress();
|
.toApiRecipientAddress();
|
||||||
|
|
||||||
// Create per-call socket directory
|
|
||||||
var callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-");
|
|
||||||
Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------"));
|
|
||||||
var controlSocketPath = callDir.resolve("ctrl.sock").toString();
|
|
||||||
|
|
||||||
var state = new CallState(callId,
|
var state = new CallState(callId,
|
||||||
CallInfo.State.RINGING_OUTGOING,
|
CallInfo.State.RINGING_OUTGOING,
|
||||||
recipientApiAddress,
|
recipientApiAddress,
|
||||||
recipient,
|
recipient,
|
||||||
true,
|
true);
|
||||||
controlSocketPath,
|
|
||||||
callDir);
|
|
||||||
activeCalls.put(callId, state);
|
activeCalls.put(callId, state);
|
||||||
fireCallEvent(state, null);
|
fireCallEvent(state, null);
|
||||||
|
|
||||||
@ -238,23 +226,11 @@ public class CallManager implements AutoCloseable {
|
|||||||
|
|
||||||
logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length);
|
logger.debug("Incoming offer opaque ({} bytes)", opaque == null ? 0 : opaque.length);
|
||||||
|
|
||||||
Path callDir;
|
|
||||||
try {
|
|
||||||
callDir = Files.createTempDirectory(Path.of("/tmp"), "sc-");
|
|
||||||
Files.setPosixFilePermissions(callDir, PosixFilePermissions.fromString("rwx------"));
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.warn("Failed to create socket directory for incoming call {}", callId, e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var controlSocketPath = callDir.resolve("ctrl.sock").toString();
|
|
||||||
|
|
||||||
var state = new CallState(callId,
|
var state = new CallState(callId,
|
||||||
CallInfo.State.RINGING_INCOMING,
|
CallInfo.State.RINGING_INCOMING,
|
||||||
senderAddress,
|
senderAddress,
|
||||||
senderIdentifier,
|
senderIdentifier,
|
||||||
false,
|
false);
|
||||||
controlSocketPath,
|
|
||||||
callDir);
|
|
||||||
state.rawOfferOpaque = opaque;
|
state.rawOfferOpaque = opaque;
|
||||||
activeCalls.put(callId, state);
|
activeCalls.put(callId, state);
|
||||||
|
|
||||||
@ -387,25 +363,43 @@ public class CallManager implements AutoCloseable {
|
|||||||
private void spawnMediaTunnel(CallState state) {
|
private void spawnMediaTunnel(CallState state) {
|
||||||
try {
|
try {
|
||||||
var command = new ArrayList<>(List.of(findTunnelBinary()));
|
var command = new ArrayList<>(List.of(findTunnelBinary()));
|
||||||
// Config is sent via stdin; no --host-audio by default
|
|
||||||
|
|
||||||
var processBuilder = new ProcessBuilder(command);
|
var processBuilder = new ProcessBuilder(command);
|
||||||
processBuilder.redirectErrorStream(true);
|
// Keep stdout and stderr separate: stdout = control protocol, stderr = logging
|
||||||
|
processBuilder.redirectErrorStream(false);
|
||||||
var process = processBuilder.start();
|
var process = processBuilder.start();
|
||||||
|
|
||||||
// Write config JSON to stdin
|
|
||||||
var config = buildConfig(state);
|
|
||||||
try (var stdin = process.getOutputStream()) {
|
|
||||||
stdin.write(config.getBytes(StandardCharsets.UTF_8));
|
|
||||||
stdin.flush();
|
|
||||||
}
|
|
||||||
|
|
||||||
state.tunnelProcess = process;
|
state.tunnelProcess = process;
|
||||||
|
|
||||||
// Drain subprocess stdout/stderr to prevent pipe buffer deadlock
|
// Write config JSON to stdin, then keep stdin open for control messages
|
||||||
Thread.ofVirtual().name("tunnel-output-" + state.callId).start(() -> {
|
var config = buildConfig(state);
|
||||||
|
var stdinStream = process.getOutputStream();
|
||||||
|
stdinStream.write(config.getBytes(StandardCharsets.UTF_8));
|
||||||
|
stdinStream.write('\n');
|
||||||
|
stdinStream.flush();
|
||||||
|
|
||||||
|
// stdin is the control write channel
|
||||||
|
state.controlWriter = new PrintWriter(
|
||||||
|
new OutputStreamWriter(stdinStream, StandardCharsets.UTF_8), true);
|
||||||
|
|
||||||
|
// Flush any pending control messages
|
||||||
|
for (var msg : state.pendingControlMessages) {
|
||||||
|
state.controlWriter.println(msg);
|
||||||
|
}
|
||||||
|
state.pendingControlMessages.clear();
|
||||||
|
|
||||||
|
// If accept was deferred, send it now
|
||||||
|
sendAcceptIfReady(state);
|
||||||
|
|
||||||
|
// Read control events from subprocess stdout
|
||||||
|
Thread.ofVirtual().name("control-read-" + state.callId).start(() -> {
|
||||||
|
readControlEvents(state, process.getInputStream());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain subprocess stderr to prevent pipe buffer deadlock
|
||||||
|
Thread.ofVirtual().name("tunnel-stderr-" + state.callId).start(() -> {
|
||||||
try (var reader = new BufferedReader(
|
try (var reader = new BufferedReader(
|
||||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
|
new InputStreamReader(process.getErrorStream(), StandardCharsets.UTF_8))) {
|
||||||
String line;
|
String line;
|
||||||
while ((line = reader.readLine()) != null) {
|
while ((line = reader.readLine()) != null) {
|
||||||
logger.debug("[tunnel-{}] {}", state.callId, line);
|
logger.debug("[tunnel-{}] {}", state.callId, line);
|
||||||
@ -414,11 +408,6 @@ public class CallManager implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect to control socket in background
|
|
||||||
Thread.ofVirtual().name("control-connect-" + state.callId).start(() -> {
|
|
||||||
connectToControlSocket(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Monitor process exit
|
// Monitor process exit
|
||||||
process.onExit().thenAcceptAsync(p -> {
|
process.onExit().thenAcceptAsync(p -> {
|
||||||
logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue());
|
logger.info("Tunnel for call {} exited with code {}", state.callId, p.exitValue());
|
||||||
@ -471,66 +460,16 @@ public class CallManager implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String buildConfig(CallState state) {
|
private String buildConfig(CallState state) {
|
||||||
// Generate control channel authentication token
|
|
||||||
var tokenBytes = new byte[32];
|
|
||||||
new SecureRandom().nextBytes(tokenBytes);
|
|
||||||
state.controlToken = java.util.Base64.getEncoder().encodeToString(tokenBytes);
|
|
||||||
|
|
||||||
var config = mapper.createObjectNode();
|
var config = mapper.createObjectNode();
|
||||||
config.put("call_id", callIdUnsigned(state.callId));
|
config.put("call_id", callIdUnsigned(state.callId));
|
||||||
config.put("is_outgoing", state.isOutgoing);
|
config.put("is_outgoing", state.isOutgoing);
|
||||||
config.put("control_socket_path", state.controlSocketPath);
|
|
||||||
config.put("control_token", state.controlToken);
|
|
||||||
config.put("local_device_id", 1);
|
config.put("local_device_id", 1);
|
||||||
return writeJson(config);
|
return writeJson(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void connectToControlSocket(CallState state) {
|
private void readControlEvents(CallState state, java.io.InputStream inputStream) {
|
||||||
var socketPath = Path.of(state.controlSocketPath);
|
|
||||||
var addr = UnixDomainSocketAddress.of(socketPath);
|
|
||||||
|
|
||||||
for (int attempt = 0; attempt < 50; attempt++) {
|
|
||||||
try {
|
|
||||||
Thread.sleep(200);
|
|
||||||
if (!Files.exists(socketPath)) continue;
|
|
||||||
|
|
||||||
var channel = SocketChannel.open(StandardProtocolFamily.UNIX);
|
|
||||||
channel.connect(addr);
|
|
||||||
state.controlChannel = channel;
|
|
||||||
state.controlWriter = new PrintWriter(
|
|
||||||
new OutputStreamWriter(Channels.newOutputStream(channel), StandardCharsets.UTF_8), true);
|
|
||||||
|
|
||||||
// Send authentication token
|
|
||||||
var authMsg = mapper.createObjectNode();
|
|
||||||
authMsg.put("type", "auth");
|
|
||||||
authMsg.put("token", state.controlToken);
|
|
||||||
state.controlWriter.println(writeJson(authMsg));
|
|
||||||
logger.info("Connected to control socket for call {}", state.callId);
|
|
||||||
|
|
||||||
// Flush any pending control messages
|
|
||||||
for (var msg : state.pendingControlMessages) {
|
|
||||||
state.controlWriter.println(msg);
|
|
||||||
}
|
|
||||||
state.pendingControlMessages.clear();
|
|
||||||
|
|
||||||
// Start reading control events
|
|
||||||
Thread.ofVirtual().name("control-read-" + state.callId).start(() -> {
|
|
||||||
readControlEvents(state);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.debug("Control socket connect attempt {} failed: {}", attempt, e.getMessage());
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.warn("Failed to connect to control socket for call {} after retries", state.callId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void readControlEvents(CallState state) {
|
|
||||||
try (var reader = new BufferedReader(
|
try (var reader = new BufferedReader(
|
||||||
new InputStreamReader(Channels.newInputStream(state.controlChannel), StandardCharsets.UTF_8))) {
|
new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
|
||||||
String line;
|
String line;
|
||||||
while ((line = reader.readLine()) != null) {
|
while ((line = reader.readLine()) != null) {
|
||||||
line = line.trim();
|
line = line.trim();
|
||||||
@ -617,6 +556,7 @@ public class CallManager implements AutoCloseable {
|
|||||||
state.state = CallInfo.State.RINGING_OUTGOING;
|
state.state = CallInfo.State.RINGING_OUTGOING;
|
||||||
} else if ("Ringing".equals(ringrtcState)) {
|
} else if ("Ringing".equals(ringrtcState)) {
|
||||||
// Tunnel is now ready to accept — flush deferred accept if pending
|
// Tunnel is now ready to accept — flush deferred accept if pending
|
||||||
|
state.tunnelRinging = true;
|
||||||
sendAcceptIfReady(state);
|
sendAcceptIfReady(state);
|
||||||
return;
|
return;
|
||||||
} else if ("Connected".equals(ringrtcState)) {
|
} else if ("Connected".equals(ringrtcState)) {
|
||||||
@ -634,7 +574,7 @@ public class CallManager implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void sendAcceptIfReady(CallState state) {
|
private void sendAcceptIfReady(CallState state) {
|
||||||
if (state.acceptPending && state.controlWriter != null) {
|
if (state.acceptPending && state.tunnelRinging && state.controlWriter != null) {
|
||||||
state.acceptPending = false;
|
state.acceptPending = false;
|
||||||
logger.debug("Sending deferred accept for call {}", state.callId);
|
logger.debug("Sending deferred accept for call {}", state.callId);
|
||||||
var acceptMsg = mapper.createObjectNode();
|
var acceptMsg = mapper.createObjectNode();
|
||||||
@ -792,36 +732,22 @@ public class CallManager implements AutoCloseable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send hangup via control channel before killing process
|
// Send hangup via control channel (stdin) before killing process
|
||||||
if (state.controlWriter != null) {
|
if (state.controlWriter != null) {
|
||||||
try {
|
try {
|
||||||
state.controlWriter.println("{\"type\":\"hangup\"}");
|
var hangupMsg = mapper.createObjectNode();
|
||||||
|
hangupMsg.put("type", "hangup");
|
||||||
|
state.controlWriter.println(writeJson(hangupMsg));
|
||||||
|
state.controlWriter.close();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.debug("Failed to send hangup via control channel", e);
|
logger.debug("Failed to send hangup via control channel", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close control channel
|
|
||||||
if (state.controlChannel != null) {
|
|
||||||
try {
|
|
||||||
state.controlChannel.close();
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.debug("Failed to close control channel for call {}", callId, e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kill tunnel process
|
// Kill tunnel process
|
||||||
if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) {
|
if (state.tunnelProcess != null && state.tunnelProcess.isAlive()) {
|
||||||
state.tunnelProcess.destroy();
|
state.tunnelProcess.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up socket directory
|
|
||||||
try {
|
|
||||||
Files.deleteIfExists(Path.of(state.controlSocketPath));
|
|
||||||
Files.deleteIfExists(state.socketDir);
|
|
||||||
} catch (IOException e) {
|
|
||||||
logger.debug("Failed to clean up socket directory for call {}", callId, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handleRingTimeout(final long callId) {
|
private void handleRingTimeout(final long callId) {
|
||||||
@ -855,37 +781,31 @@ public class CallManager implements AutoCloseable {
|
|||||||
final org.asamk.signal.manager.api.RecipientAddress recipientAddress;
|
final org.asamk.signal.manager.api.RecipientAddress recipientAddress;
|
||||||
final RecipientIdentifier.Single recipientIdentifier;
|
final RecipientIdentifier.Single recipientIdentifier;
|
||||||
final boolean isOutgoing;
|
final boolean isOutgoing;
|
||||||
final String controlSocketPath;
|
|
||||||
final Path socketDir;
|
|
||||||
volatile String inputDeviceName;
|
volatile String inputDeviceName;
|
||||||
volatile String outputDeviceName;
|
volatile String outputDeviceName;
|
||||||
volatile Process tunnelProcess;
|
volatile Process tunnelProcess;
|
||||||
volatile SocketChannel controlChannel;
|
|
||||||
volatile PrintWriter controlWriter;
|
volatile PrintWriter controlWriter;
|
||||||
volatile String controlToken;
|
|
||||||
// Raw offer opaque for incoming calls (forwarded to subprocess)
|
// Raw offer opaque for incoming calls (forwarded to subprocess)
|
||||||
volatile byte[] rawOfferOpaque;
|
volatile byte[] rawOfferOpaque;
|
||||||
// Control messages queued before the control channel connects
|
// Control messages queued before the tunnel process starts
|
||||||
final List<String> pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>());
|
final List<String> pendingControlMessages = java.util.Collections.synchronizedList(new ArrayList<>());
|
||||||
// Accept deferred until tunnel reports Ringing state
|
// Accept deferred until tunnel reports Ringing state
|
||||||
volatile boolean acceptPending = false;
|
volatile boolean acceptPending = false;
|
||||||
|
// True once the tunnel has reported "Ringing" (ready to accept)
|
||||||
|
volatile boolean tunnelRinging = false;
|
||||||
|
|
||||||
CallState(
|
CallState(
|
||||||
long callId,
|
long callId,
|
||||||
CallInfo.State state,
|
CallInfo.State state,
|
||||||
org.asamk.signal.manager.api.RecipientAddress recipientAddress,
|
org.asamk.signal.manager.api.RecipientAddress recipientAddress,
|
||||||
RecipientIdentifier.Single recipientIdentifier,
|
RecipientIdentifier.Single recipientIdentifier,
|
||||||
boolean isOutgoing,
|
boolean isOutgoing
|
||||||
String controlSocketPath,
|
|
||||||
Path socketDir
|
|
||||||
) {
|
) {
|
||||||
this.callId = callId;
|
this.callId = callId;
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.recipientAddress = recipientAddress;
|
this.recipientAddress = recipientAddress;
|
||||||
this.recipientIdentifier = recipientIdentifier;
|
this.recipientIdentifier = recipientIdentifier;
|
||||||
this.isOutgoing = isOutgoing;
|
this.isOutgoing = isOutgoing;
|
||||||
this.controlSocketPath = controlSocketPath;
|
|
||||||
this.socketDir = socketDir;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
CallInfo toCallInfo() {
|
CallInfo toCallInfo() {
|
||||||
|
|||||||
@ -68,9 +68,7 @@ class CallManagerTest {
|
|||||||
initialState,
|
initialState,
|
||||||
address,
|
address,
|
||||||
new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"),
|
new org.asamk.signal.manager.api.RecipientIdentifier.Number("+15551234567"),
|
||||||
true,
|
true
|
||||||
"/tmp/sc-test/ctrl.sock",
|
|
||||||
Path.of("/tmp/sc-test")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user