#![cfg(target_os = "windows")]
use std::sync::Arc;
use kanade_shared::subject;
use kanade_shared::wire::{RemoteCtrl, RemoteCtrlReply, gap_headers, resumed_headers};
use tokio::sync::{Mutex, mpsc};
use tracing::{info, warn};
use crate::capture_frame_io::{FrameHeader, read_frame};
use crate::process_as_user::{SessionAgentChild, spawn_session_child};
enum Outbound {
Tile {
header: Box<FrameHeader>,
payload: Vec<u8>,
},
Gap(String),
Resumed,
}
struct Live {
session_id: String,
child: SessionAgentChild,
}
impl Live {
fn stop(self) {
self.child.terminate();
}
}
pub async fn serve(client: async_nats::Client, pc_id: String, exe: std::path::PathBuf) {
let subj = subject::remote_ctrl(&pc_id);
let live: Arc<Mutex<Option<Live>>> = Arc::new(Mutex::new(None));
loop {
let mut sub = match client.subscribe(subj.clone()).await {
Ok(s) => s,
Err(e) => {
warn!(subject = %subj, error = %e, "remote ctrl subscribe failed; retrying");
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
continue;
}
};
info!(subject = %subj, "remote control responder ready");
use futures::StreamExt;
while let Some(msg) = sub.next().await {
let Some(reply) = msg.reply.clone() else {
warn!(subject = %subj, "remote ctrl without reply subject — skipping");
continue;
};
let ctrl: RemoteCtrl = match serde_json::from_slice(&msg.payload) {
Ok(c) => c,
Err(e) => {
warn!(error = %e, "remote ctrl: undecodable request");
let r = RemoteCtrlReply::refused(format!("undecodable request: {e}"));
respond(&client, reply, &r).await;
continue;
}
};
let response = handle(&client, &live, &exe, ctrl).await;
respond(&client, reply, &response).await;
}
warn!(subject = %subj, "remote ctrl subscription closed; reopening");
}
}
async fn respond(client: &async_nats::Client, reply: async_nats::Subject, r: &RemoteCtrlReply) {
match serde_json::to_vec(r) {
Ok(bytes) => {
if let Err(e) = client.publish(reply, bytes.into()).await {
warn!(error = %e, "publish remote ctrl reply");
}
}
Err(e) => warn!(error = %e, "serialize remote ctrl reply"),
}
}
async fn handle(
client: &async_nats::Client,
live: &Arc<Mutex<Option<Live>>>,
exe: &std::path::Path,
ctrl: RemoteCtrl,
) -> RemoteCtrlReply {
match ctrl {
RemoteCtrl::Start {
session_id,
output_index,
quality,
max_fps,
allow_input: _,
} => {
let mut guard = live.lock().await;
match decide_start(guard.as_ref().map(|l| l.session_id.as_str()), &session_id) {
StartOutcome::AlreadyRunning => {
return RemoteCtrlReply {
accepted: true,
..Default::default()
};
}
StartOutcome::HeldByOther(other) => {
return RemoteCtrlReply::refused(format!(
"session {other} already has this machine's display"
));
}
StartOutcome::Start => {}
}
match start(client, exe, &session_id, output_index, quality, max_fps).await {
Ok(l) => {
info!(session = %session_id, "remote session started");
*guard = Some(l);
RemoteCtrlReply {
accepted: true,
reason: None,
screen_w: None,
screen_h: None,
}
}
Err(e) => {
warn!(session = %session_id, error = %e, "remote session failed to start");
RemoteCtrlReply::refused(format!("could not start capture: {e}"))
}
}
}
RemoteCtrl::Stop { session_id } => {
let mut guard = live.lock().await;
let held = guard.as_ref().map(|l| l.session_id.as_str());
match decide_stop(held, &session_id) {
StopOutcome::Stop => {
if let Some(l) = guard.take() {
l.stop();
}
info!(session = %session_id, "remote session stopped");
RemoteCtrlReply {
accepted: true,
..Default::default()
}
}
StopOutcome::NothingToDo => RemoteCtrlReply {
accepted: true,
..Default::default()
},
StopOutcome::HeldByOther(other) => RemoteCtrlReply::refused(format!(
"this machine holds session {other}, not {session_id}"
)),
}
}
RemoteCtrl::Tune { .. } => {
RemoteCtrlReply::refused("tune is not implemented yet; stop and start instead")
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum StartOutcome {
Start,
AlreadyRunning,
HeldByOther(String),
}
fn decide_start(held: Option<&str>, requested: &str) -> StartOutcome {
match held {
Some(h) if h == requested => StartOutcome::AlreadyRunning,
Some(h) => StartOutcome::HeldByOther(h.to_string()),
None => StartOutcome::Start,
}
}
#[derive(Debug, PartialEq, Eq)]
enum StopOutcome {
Stop,
NothingToDo,
HeldByOther(String),
}
fn decide_stop(held: Option<&str>, requested: &str) -> StopOutcome {
match held {
Some(h) if h == requested => StopOutcome::Stop,
Some(h) => StopOutcome::HeldByOther(h.to_string()),
None => StopOutcome::NothingToDo,
}
}
async fn start(
client: &async_nats::Client,
exe: &std::path::Path,
session_id: &str,
output_index: u32,
quality: u8,
max_fps: u8,
) -> anyhow::Result<Live> {
let quality = quality.clamp(1, 100).to_string();
let max_fps = max_fps.max(1).to_string();
let output_index = output_index.to_string();
let exe = exe.to_path_buf();
let mut child = tokio::task::spawn_blocking(move || {
let args = [
"--session-capture",
"--session-capture-quality",
quality.as_str(),
"--session-capture-max-fps",
max_fps.as_str(),
"--session-capture-output",
output_index.as_str(),
];
spawn_session_child(&exe, &args)
})
.await
.map_err(|e| anyhow::anyhow!("spawn join failed: {e}"))??;
let stdout = child
.take_stdout()
.ok_or_else(|| anyhow::anyhow!("capture child has no stdout"))?;
let (tx, mut rx) = mpsc::channel::<Outbound>(8);
tokio::task::spawn_blocking(move || {
let mut reader = PipeReader { handle: stdout };
loop {
match read_frame(&mut reader) {
Ok(msg) => {
let out = if let Some(reason) = msg.as_gap() {
Outbound::Gap(reason)
} else if msg.is_resumed() {
Outbound::Resumed
} else {
Outbound::Tile {
header: Box::new(msg.header),
payload: msg.payload,
}
};
if tx.blocking_send(out).is_err() {
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(e) => {
warn!(error = %e, "capture pipe framing error; ending session");
break;
}
}
}
});
let client = client.clone();
let frame_subject = subject::remote_frame(session_id);
let sid = session_id.to_string();
tokio::spawn(async move {
while let Some(out) = rx.recv().await {
let publish = match out {
Outbound::Tile { header, payload } => match *header {
FrameHeader::Tile { meta, encoding } => {
client
.publish_with_headers(
frame_subject.clone(),
meta.to_headers(encoding),
payload.into(),
)
.await
}
FrameHeader::Gap | FrameHeader::Resumed => continue,
},
Outbound::Gap(reason) => {
client
.publish_with_headers(
frame_subject.clone(),
gap_headers(),
reason.into_bytes().into(),
)
.await
}
Outbound::Resumed => {
client
.publish_with_headers(
frame_subject.clone(),
resumed_headers(),
bytes::Bytes::new(),
)
.await
}
};
if let Err(e) = publish {
warn!(session = %sid, error = %e, "publish remote frame");
}
}
info!(session = %sid, "remote frame publisher ended");
});
Ok(Live {
session_id: session_id.to_string(),
child,
})
}
struct PipeReader {
handle: std::os::windows::io::OwnedHandle,
}
impl std::io::Read for PipeReader {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
use std::os::windows::io::AsRawHandle;
use windows::Win32::Foundation::{ERROR_BROKEN_PIPE, GetLastError, HANDLE};
use windows::Win32::Storage::FileSystem::ReadFile;
let raw = HANDLE(self.handle.as_raw_handle() as isize as *mut core::ffi::c_void);
let mut read: u32 = 0;
let ok = unsafe { ReadFile(raw, Some(buf), Some(&mut read), None) };
match ok {
Ok(()) => Ok(read as usize),
Err(e) => {
let code = unsafe { GetLastError() };
if code != ERROR_BROKEN_PIPE {
warn!(error = %e, ?code, "capture pipe read failed");
}
Ok(0)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn start_on_an_idle_machine_starts() {
assert_eq!(decide_start(None, "s1"), StartOutcome::Start);
}
#[test]
fn restarting_the_same_session_is_idempotent() {
assert_eq!(decide_start(Some("s1"), "s1"), StartOutcome::AlreadyRunning);
}
#[test]
fn start_while_another_session_holds_the_display_is_refused() {
assert_eq!(
decide_start(Some("s1"), "s2"),
StartOutcome::HeldByOther("s1".to_string())
);
}
#[test]
fn start_matches_session_ids_exactly() {
assert!(matches!(
decide_start(Some("session-1"), "session-10"),
StartOutcome::HeldByOther(_)
));
assert!(matches!(
decide_start(Some("S1"), "s1"),
StartOutcome::HeldByOther(_)
));
}
#[test]
fn start_and_stop_agree_on_what_counts_as_the_same_session() {
for (held, req) in [("s1", "s1"), ("s1", "s2"), ("session-1", "session-10")] {
let same_for_start =
matches!(decide_start(Some(held), req), StartOutcome::AlreadyRunning);
let same_for_stop = matches!(decide_stop(Some(held), req), StopOutcome::Stop);
assert_eq!(
same_for_start, same_for_stop,
"disagreed on {held} vs {req}"
);
}
}
#[test]
fn stop_tears_down_the_session_it_names() {
assert_eq!(decide_stop(Some("s1"), "s1"), StopOutcome::Stop);
}
#[test]
fn stop_for_an_unheld_session_never_touches_the_live_one() {
assert_eq!(
decide_stop(Some("s1"), "s2"),
StopOutcome::HeldByOther("s1".to_string())
);
}
#[test]
fn stop_with_nothing_running_succeeds() {
assert_eq!(decide_stop(None, "s1"), StopOutcome::NothingToDo);
}
#[test]
fn session_ids_are_matched_exactly() {
assert!(matches!(
decide_stop(Some("session-1"), "session-10"),
StopOutcome::HeldByOther(_)
));
assert!(matches!(
decide_stop(Some("S1"), "s1"),
StopOutcome::HeldByOther(_)
));
}
}