Files
openless/openless-all/app/linux-egui/src/remote_input.rs
T
zhou zhichaoandChris233 882fac63f0 fix(remote-input): preserve phone trust across restarts and IP changes (#1037)
* fix(remote-input): preserve phone trust with a persistent local CA

* fix(remote-input): disclose LAN certificate MITM risk

---------

Co-authored-by: Chris233 <h-chris233@outlook.com>
2026-09-10 06:36:12 +08:00

931 lines
32 KiB
Rust

use std::path::PathBuf;
use std::sync::Arc;
use futures_util::future::BoxFuture;
use openless_core::{
BackendError, BackendErrorCode, CredentialKey, CredentialNamespace, CredentialStore,
DictationStartOptions, RemoteInputRuntimeAdapter, RemoteInputServerBinding,
RemoteInputServerConfig, SecretValue, SessionId,
};
use crate::qa::LinuxBackendSlot;
const PIN_ACCOUNT: &str = "remote_input_pin";
/// Map the process locale to one of the language bundles shipped by the
/// Remote Input page. Linux commonly reports values such as `en_US.UTF-8` or
/// `zh_HK`, while the Core contract deliberately accepts only canonical
/// bundle identifiers. Keeping this conversion in the Host prevents ambient
/// OS formatting from leaking into Core policy or making backend startup fail.
pub(crate) fn remote_input_locale(locale: &str) -> &'static str {
let locale = locale
.split(['.', '@'])
.next()
.unwrap_or(locale)
.replace('_', "-")
.to_ascii_lowercase();
if locale.starts_with("zh-tw") || locale.starts_with("zh-hk") || locale.starts_with("zh-mo") {
"zh-TW"
} else if locale.starts_with("zh") {
"zh-CN"
} else if locale.starts_with("ja") {
"ja"
} else if locale.starts_with("ko") {
"ko"
} else {
// English is the shipped fallback for unsupported or absent locales;
// it is preferable to refusing to start the whole Linux backend.
"en"
}
}
pub(crate) struct LinuxRemoteInputRuntime {
// See LinuxQaRuntime: the Weak slot avoids Backend -> Service -> Adapter ->
// Backend retention while exposing only Core's external-audio use-case.
backend: LinuxBackendSlot,
credentials: Arc<dyn CredentialStore>,
data_dir: PathBuf,
// This is transport ownership only. RemoteInputService serializes start,
// stop, authentication, connection and stream state before invoking us.
server: Arc<tokio::sync::Mutex<Option<LinuxRemoteServerHandle>>>,
}
impl LinuxRemoteInputRuntime {
pub(crate) fn new(
backend: LinuxBackendSlot,
credentials: Arc<dyn CredentialStore>,
data_dir: PathBuf,
) -> Self {
Self {
backend,
credentials,
data_dir,
server: Arc::new(tokio::sync::Mutex::new(None)),
}
}
fn backend(&self) -> Result<Arc<openless_core::OpenLessBackend>, BackendError> {
self.backend
.lock()
.expect("Linux backend slot lock poisoned")
.upgrade()
.ok_or_else(|| {
BackendError::new(
BackendErrorCode::InvalidState,
"Linux Core backend is not bound yet",
)
})
}
fn pin_key() -> CredentialKey {
CredentialKey::new(CredentialNamespace::Application, None, PIN_ACCOUNT)
.expect("built-in remote PIN credential key is valid")
}
}
impl RemoteInputRuntimeAdapter for LinuxRemoteInputRuntime {
fn load_pairing_pin(&self) -> BoxFuture<'static, Result<Option<SecretValue>, BackendError>> {
self.credentials.read(Self::pin_key())
}
fn persist_pairing_pin(
&self,
pin: SecretValue,
) -> BoxFuture<'static, Result<(), BackendError>> {
self.credentials.write(Self::pin_key(), pin)
}
fn start_server(
&self,
config: RemoteInputServerConfig,
) -> BoxFuture<'static, Result<RemoteInputServerBinding, BackendError>> {
let backend = self.backend();
let data_dir = self.data_dir.clone();
let server = Arc::clone(&self.server);
Box::pin(async move {
let handle = start_server(config.port, data_dir, backend?).await?;
let binding = RemoteInputServerBinding {
port: handle.bound_port,
urls: access_urls(handle.bound_port),
urls_stale: false,
};
*server.lock().await = Some(handle);
Ok(binding)
})
}
fn stop_server(&self) -> BoxFuture<'static, Result<(), BackendError>> {
let server = Arc::clone(&self.server);
Box::pin(async move {
if let Some(handle) = server.lock().await.take() {
handle.shutdown().await;
}
Ok(())
})
}
fn list_local_ips(&self) -> BoxFuture<'static, Result<Vec<String>, BackendError>> {
Box::pin(async { Ok(local_lan_ipv4s()) })
}
fn start_audio_session(
&self,
insert_text: bool,
) -> BoxFuture<'static, Result<SessionId, BackendError>> {
let backend = self.backend();
Box::pin(async move {
backend?
.start_external_dictation_with_options(DictationStartOptions {
insert_text,
..DictationStartOptions::default()
})
.await
})
}
fn feed_audio(
&self,
session_id: SessionId,
pcm_s16le: Vec<u8>,
) -> BoxFuture<'static, Result<(), BackendError>> {
let backend = self.backend();
Box::pin(async move { backend?.feed_external_pcm(session_id, &pcm_s16le) })
}
fn stop_audio_session(
&self,
session_id: SessionId,
) -> BoxFuture<'static, Result<(), BackendError>> {
let backend = self.backend();
Box::pin(async move {
backend?
.stop_dictation_session(session_id)
.await
.map(|_| ())
})
}
fn cancel_audio_session(
&self,
session_id: SessionId,
) -> BoxFuture<'static, Result<(), BackendError>> {
let backend = self.backend();
Box::pin(async move { backend?.cancel_dictation(Some(session_id)).await })
}
}
#[cfg(target_os = "linux")]
use axum::{
extract::ws::{Message, WebSocket, WebSocketUpgrade},
extract::State,
response::{Html, IntoResponse},
routing::get,
Router,
};
#[cfg(target_os = "linux")]
use hyper_util::rt::{TokioExecutor, TokioIo};
#[cfg(target_os = "linux")]
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
#[cfg(target_os = "linux")]
use std::time::{Duration, Instant};
#[cfg(target_os = "linux")]
use tokio::net::TcpListener;
#[cfg(target_os = "linux")]
use tokio_rustls::TlsAcceptor;
#[cfg(target_os = "linux")]
mod assets {
pub const INDEX_HTML: &str =
include_str!("../../src-tauri/src/remote_server/assets/index.html");
pub const APP_JS: &str = include_str!("../../src-tauri/src/remote_server/assets/app.js");
pub const STYLE_CSS: &str = include_str!("../../src-tauri/src/remote_server/assets/style.css");
pub const ICON_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/icon.png");
pub const MIC_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/mic.png");
pub const DONE_PNG: &[u8] = include_bytes!("../../src-tauri/src/remote_server/assets/done.png");
}
#[cfg(target_os = "linux")]
const KEEPALIVE_PING_SECS: u64 = 30;
#[cfg(target_os = "linux")]
const IDLE_TIMEOUT_SECS: u64 = 90;
#[cfg(target_os = "linux")]
struct LinuxRemoteServerHandle {
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
// Accepted WebSocket tasks outlive the accept loop, so they receive a
// separate broadcast and release their Core connection/session leases.
connections_shutdown: tokio::sync::watch::Sender<bool>,
join: tokio::task::JoinHandle<()>,
bound_port: u16,
}
#[cfg(target_os = "linux")]
impl LinuxRemoteServerHandle {
async fn shutdown(mut self) {
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
let _ = self.connections_shutdown.send(true);
let _ = self.join.await;
}
}
#[cfg(not(target_os = "linux"))]
struct LinuxRemoteServerHandle {
bound_port: u16,
}
#[cfg(not(target_os = "linux"))]
impl LinuxRemoteServerHandle {
async fn shutdown(self) {}
}
#[cfg(target_os = "linux")]
struct WebState {
backend: Arc<openless_core::OpenLessBackend>,
cert_der: Vec<u8>,
connections_shutdown: tokio::sync::watch::Receiver<bool>,
}
#[cfg(target_os = "linux")]
#[derive(Clone, Copy)]
struct PeerIp(IpAddr);
#[cfg(target_os = "linux")]
fn local_lan_ipv4s() -> Vec<String> {
let mut addresses = local_ip_address::list_afinet_netifas()
.unwrap_or_default()
.into_iter()
.filter_map(|(_, address)| match address {
IpAddr::V4(address) if is_private_lan(address) => Some(address.to_string()),
_ => None,
})
.collect::<Vec<_>>();
addresses.sort();
addresses.dedup();
addresses
}
#[cfg(not(target_os = "linux"))]
fn local_lan_ipv4s() -> Vec<String> {
Vec::new()
}
#[cfg(target_os = "linux")]
fn is_private_lan(address: Ipv4Addr) -> bool {
let octets = address.octets();
!address.is_loopback()
&& !address.is_link_local()
&& ((octets[0] == 10)
|| (octets[0] == 172 && (16..=31).contains(&octets[1]))
|| (octets[0] == 192 && octets[1] == 168))
}
fn access_urls(port: u16) -> Vec<String> {
local_lan_ipv4s()
.into_iter()
.map(|address| format!("https://{address}:{port}"))
.collect()
}
#[cfg(target_os = "linux")]
#[path = "../../src-tauri/src/remote_server/tls_identity.rs"]
mod tls_identity;
#[cfg(target_os = "linux")]
fn router(state: Arc<WebState>) -> Router {
Router::new()
.route("/", get(index))
.route(
"/app.js",
get(|| async {
(
[(
axum::http::header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
assets::APP_JS,
)
}),
)
.route(
"/style.css",
get(|| async {
(
[(axum::http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
assets::STYLE_CSS,
)
}),
)
.route("/icon.png", get(|| async { image(assets::ICON_PNG) }))
.route("/mic.png", get(|| async { image(assets::MIC_PNG) }))
.route("/done.png", get(|| async { image(assets::DONE_PNG) }))
.route(
"/cert.cer",
get(|State(state): State<Arc<WebState>>| async move {
(
[
(
axum::http::header::CONTENT_TYPE,
"application/x-x509-ca-cert",
),
(axum::http::header::CACHE_CONTROL, "no-store"),
],
state.cert_der.clone(),
)
}),
)
.route(
"/cert.mobileconfig",
get(|State(state): State<Arc<WebState>>| async move {
(
[
(
axum::http::header::CONTENT_TYPE,
"application/x-apple-aspen-config",
),
(axum::http::header::CACHE_CONTROL, "no-store"),
],
tls_identity::mobileconfig(&state.cert_der),
)
}),
)
.route("/ws", get(websocket_upgrade))
.with_state(state)
}
#[cfg(target_os = "linux")]
fn image(bytes: &'static [u8]) -> ([(axum::http::HeaderName, &'static str); 1], &'static [u8]) {
([(axum::http::header::CONTENT_TYPE, "image/png")], bytes)
}
#[cfg(target_os = "linux")]
async fn index(State(state): State<Arc<WebState>>) -> Html<String> {
let locale = state
.backend
.services()
.remote_input
.status()
.map(|status| status.locale)
.unwrap_or_else(|_| "zh-CN".to_string());
// Read the current PC default on each page load. The page retains an explicit
// phone choice; only fixed literals may enter its inline script, never raw prefs.
let default_mode = if state.backend.get_preferences().remote_input_default_mode == "hold" {
"hold"
} else {
"toggle"
};
Html(
assets::INDEX_HTML
.replace("%%OL_LANG%%", &locale)
.replace("%%OL_DEFAULT_MODE%%", default_mode),
)
}
#[cfg(target_os = "linux")]
async fn websocket_upgrade(
State(state): State<Arc<WebState>>,
axum::Extension(PeerIp(peer)): axum::Extension<PeerIp>,
websocket: WebSocketUpgrade,
) -> impl IntoResponse {
websocket.on_upgrade(move |socket| websocket_session(socket, state, peer))
}
#[cfg(target_os = "linux")]
async fn start_server(
port: u16,
data_dir: PathBuf,
backend: Arc<openless_core::OpenLessBackend>,
) -> Result<LinuxRemoteServerHandle, BackendError> {
let mut sans = vec!["localhost".to_string(), "127.0.0.1".to_string()];
sans.extend(local_lan_ipv4s());
let identity = tls_identity::load_or_create(&data_dir.join("remote-input"), &sans)
.map_err(remote_platform_error)?;
let cert_der = identity.trust_cert;
let acceptor = TlsAcceptor::from(identity.server_config);
let listener = TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port)))
.await
.map_err(|error| remote_platform_error(format!("remote input bind failed: {error}")))?;
let bound_port = listener
.local_addr()
.map(|address| address.port())
.unwrap_or(port);
let (connections_shutdown, receiver) = tokio::sync::watch::channel(false);
let state = Arc::new(WebState {
backend,
cert_der,
connections_shutdown: receiver,
});
let app = router(state);
let (shutdown, mut shutdown_rx) = tokio::sync::oneshot::channel();
let join = tokio::spawn(async move {
loop {
tokio::select! {
_ = &mut shutdown_rx => break,
accepted = listener.accept() => {
let Ok((tcp, peer)) = accepted else { continue };
let acceptor = acceptor.clone();
let service = app.clone().layer(axum::Extension(PeerIp(peer.ip())));
tokio::spawn(async move {
let Ok(tls) = acceptor.accept(tcp).await else { return };
let io = TokioIo::new(tls);
let service = hyper_util::service::TowerToHyperService::new(service);
let _ = hyper_util::server::conn::auto::Builder::new(TokioExecutor::new())
.serve_connection_with_upgrades(io, service)
.await;
});
}
}
}
});
Ok(LinuxRemoteServerHandle {
shutdown: Some(shutdown),
connections_shutdown,
join,
bound_port,
})
}
#[cfg(not(target_os = "linux"))]
async fn start_server(
_port: u16,
_data_dir: PathBuf,
_backend: Arc<openless_core::OpenLessBackend>,
) -> Result<LinuxRemoteServerHandle, BackendError> {
Err(BackendError::new(
BackendErrorCode::Unsupported,
"Linux remote input transport is only available on Linux",
))
}
#[cfg(target_os = "linux")]
async fn websocket_session(mut socket: WebSocket, state: Arc<WebState>, peer: IpAddr) {
let connection_id = SessionId::new();
let auth = match tokio::time::timeout(Duration::from_secs(15), socket.recv()).await {
Ok(Some(Ok(Message::Text(text)))) => {
state
.backend
.services()
.remote_input
.authenticate(connection_id, peer.to_string(), hello_pin(&text))
.await
}
_ => return,
};
let Ok(auth) = auth else { return };
let (ok, reason) = match auth {
openless_core::RemoteAuthResult::Ok => (true, None),
openless_core::RemoteAuthResult::BadPin => (false, Some("bad-pin")),
openless_core::RemoteAuthResult::Locked => (false, Some("locked")),
};
let _ = socket
.send(json_message(serde_json::json!({
"type": "auth",
"ok": ok,
"reason": reason,
})))
.await;
if !ok {
return;
}
let mut events = state.backend.subscribe();
let mut remote_session = None;
// Poll finalization beside socket input. Awaiting it in the receive arm
// would prevent the phone from cancelling a slow provider request.
let mut pending_stop: Option<(SessionId, BoxFuture<'static, Result<(), BackendError>>)> = None;
let mut shutdown = state.connections_shutdown.clone();
let mut keepalive = tokio::time::interval(Duration::from_secs(KEEPALIVE_PING_SECS));
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut last_received = Instant::now();
'connection: loop {
tokio::select! {
incoming = socket.recv() => {
last_received = Instant::now();
match incoming {
Some(Ok(Message::Binary(frame))) => {
if let Ok((session_id, sequence, pcm)) = openless_core::RemoteFrameCodec::decode(&frame) {
let _ = state.backend.services().remote_input
.feed_pcm(connection_id, session_id, sequence, pcm).await;
}
}
Some(Ok(Message::Text(text))) => {
if let Some(reply) = apply_control(
&text,
state.backend.services().remote_input.as_ref(),
connection_id,
&mut remote_session,
&mut pending_stop,
).await {
if socket.send(json_message(reply)).await.is_err() { break; }
}
}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
_ => {}
}
}
event = events.recv() => {
let Ok(event) = event else { continue };
if remote_session.is_none() || event.session_id != remote_session { continue; }
let terminal = matches!(&event.kind,
openless_core::BackendEventKind::DictationCompleted(_)
| openless_core::BackendEventKind::DictationStateChanged(openless_core::DictationStateSnapshot {
phase: openless_core::DictationPhase::Cancelled | openless_core::DictationPhase::Failed, ..
}));
for reply in remote_event(event.kind) {
// A failed outbound send means this socket no longer owns
// a usable transport. Leave the outer loop immediately so
// Core disconnect cancels any active external-audio lease.
if socket.send(json_message(reply)).await.is_err() {
break 'connection;
}
}
if terminal { remote_session = None; }
}
result = async { pending_stop.as_mut().expect("guarded pending stop").1.as_mut().await }, if pending_stop.is_some() => {
let (session_id, _) = pending_stop.take().expect("completed pending stop");
// Successful finalization queues done/result events. Keep their
// output owner until they are delivered, not merely until the
// future returns. Late errors after cancel must stay invisible.
if let Err(error) = result {
if remote_session == Some(session_id) {
remote_session = None;
if socket.send(json_message(serde_json::json!({"type":"status", "kind":"error", "message":error.to_string()}))).await.is_err() { break; }
}
}
}
_ = keepalive.tick() => {
if last_received.elapsed() > Duration::from_secs(IDLE_TIMEOUT_SECS)
|| socket.send(Message::Ping(Vec::new())).await.is_err()
{
break;
}
}
changed = shutdown.changed() => {
if changed.is_err() || *shutdown.borrow() { break; }
}
}
}
let _ = state
.backend
.services()
.remote_input
.disconnect(connection_id)
.await;
}
#[cfg(target_os = "linux")]
fn json_message(value: serde_json::Value) -> Message {
Message::Text(value.to_string())
}
#[cfg(target_os = "linux")]
fn hello_pin(text: &str) -> SecretValue {
let pin = serde_json::from_str::<serde_json::Value>(text)
.ok()
.filter(|value| value.get("type").and_then(serde_json::Value::as_str) == Some("hello"))
.and_then(|value| {
value
.get("pin")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
})
.unwrap_or_default();
SecretValue::new(pin)
}
#[cfg(target_os = "linux")]
async fn apply_control(
text: &str,
remote: &dyn openless_core::RemoteInputApi,
connection_id: SessionId,
remote_session: &mut Option<SessionId>,
pending_stop: &mut Option<(SessionId, BoxFuture<'static, Result<(), BackendError>>)>,
) -> Option<serde_json::Value> {
let value = serde_json::from_str::<serde_json::Value>(text).ok()?;
match value.get("type").and_then(serde_json::Value::as_str)? {
"start" if pending_stop.is_some() => {
Some(serde_json::json!({"type":"busy", "reason":"previous stream is still finishing"}))
}
"start" => match remote.start_stream(connection_id).await {
Ok(session_id) => {
*remote_session = Some(session_id);
Some(serde_json::json!({"type":"started", "sessionId":session_id.to_string()}))
}
Err(error) => Some(serde_json::json!({"type":"busy", "reason":error.to_string()})),
},
"stop" => {
if pending_stop.is_none() {
if let Some(session_id) = *remote_session {
*pending_stop =
Some((session_id, remote.stop_stream(connection_id, session_id)));
}
}
None
}
"cancel" => {
if let Some(session_id) = remote_session.take() {
let _ = remote.cancel_stream(connection_id, session_id).await;
// Core has now revoked the audio lease. A provider may leave
// its in-flight HTTP request pending until timeout; dropping
// that abandoned stop future lets the phone start immediately.
// If a terminal already cleared the owner, leave pending_stop
// alone so its remaining Core cleanup still gets polled.
*pending_stop = None;
return Some(serde_json::json!({"type":"status", "kind":"done"}));
}
None
}
"set_insert" => {
let insert = value
.get("value")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
remote
.set_insert(connection_id, insert)
.await
.err()
.map(|error| serde_json::json!({"type":"busy", "reason":error.to_string()}))
}
_ => None,
}
}
#[cfg(target_os = "linux")]
fn remote_event(kind: openless_core::BackendEventKind) -> Vec<serde_json::Value> {
match kind {
openless_core::BackendEventKind::DictationStateChanged(state) => {
let kind = match state.phase {
openless_core::DictationPhase::Starting
| openless_core::DictationPhase::Recording => "recording",
openless_core::DictationPhase::Transcribing => "transcribing",
openless_core::DictationPhase::Polishing
| openless_core::DictationPhase::Inserting => "polishing",
openless_core::DictationPhase::Cancelled => "done",
openless_core::DictationPhase::Failed => "error",
_ => return Vec::new(),
};
vec![serde_json::json!({
"type":"status",
"kind":kind,
"level":state.level,
"message":state.message,
})]
}
openless_core::BackendEventKind::DictationCompleted(result) => vec![
serde_json::json!({
"type":"status",
"kind":"done",
"insertedChars":result.polished_text.chars().count(),
}),
serde_json::json!({"type":"result", "text":result.polished_text}),
],
_ => Vec::new(),
}
}
#[cfg(target_os = "linux")]
fn remote_platform_error(message: String) -> BackendError {
BackendError::new(BackendErrorCode::Platform, message)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn access_urls_include_only_private_lan_addresses() {
assert!(access_urls(8443)
.iter()
.all(|url| url.starts_with("https://") && url.ends_with(":8443")));
}
#[test]
fn system_locales_map_to_shipped_remote_input_bundles() {
assert_eq!(remote_input_locale("en_US.UTF-8"), "en");
assert_eq!(remote_input_locale("zh_HK.UTF-8"), "zh-TW");
assert_eq!(remote_input_locale("zh_CN.UTF-8"), "zh-CN");
assert_eq!(remote_input_locale("ja_JP"), "ja");
assert_eq!(remote_input_locale("ko_KR"), "ko");
assert_eq!(remote_input_locale("fr_FR.UTF-8"), "en");
}
#[cfg(target_os = "linux")]
#[test]
fn hello_requires_the_typed_handshake() {
assert_eq!(
hello_pin(r#"{"type":"hello","pin":"123456"}"#).expose_secret(),
"123456"
);
assert!(hello_pin(r#"{"type":"other","pin":"123456"}"#)
.expose_secret()
.is_empty());
}
#[cfg(target_os = "linux")]
async fn connected_remote_fixture(
runtime: Arc<dyn RemoteInputRuntimeAdapter>,
) -> (openless_core::OpenLessBackend, SessionId, PathBuf) {
use openless_core::{
BackendConfig, BackendDependencies, RemoteInputConfig, RemoteInputService,
};
let data_dir =
std::env::temp_dir().join(format!("openless-remote-wire-{}", uuid::Uuid::new_v4()));
let mut dependencies = BackendDependencies::unsupported();
dependencies.services.remote_input =
Arc::new(RemoteInputService::new(runtime, 8443, "en").unwrap());
let backend = openless_core::OpenLessBackend::new(
BackendConfig {
data_dir: data_dir.clone(),
..Default::default()
},
dependencies,
)
.unwrap();
let remote = Arc::clone(&backend.services().remote_input);
remote
.configure(RemoteInputConfig {
enabled: true,
port: 8443,
})
.await
.unwrap();
let connection = SessionId::new();
remote
.authenticate(
connection,
"127.0.0.1".into(),
remote.read_pairing_pin().await.unwrap(),
)
.await
.unwrap();
(backend, connection, data_dir)
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn stop_keeps_the_session_owner_for_terminal_websocket_events() {
let (backend, connection, data_dir) = connected_remote_fixture(Arc::new(
openless_core::testing::RecordingRemoteInputRuntime::default(),
))
.await;
let remote = &backend.services().remote_input;
let mut owner = None;
let mut pending_stop = None;
apply_control(
r#"{"type":"start"}"#,
remote.as_ref(),
connection,
&mut owner,
&mut pending_stop,
)
.await
.unwrap();
let started = owner.unwrap();
apply_control(
r#"{"type":"stop"}"#,
remote.as_ref(),
connection,
&mut owner,
&mut pending_stop,
)
.await;
pending_stop.take().unwrap().1.await.unwrap();
assert_eq!(
owner,
Some(started),
"queued done/result must retain their outbound owner after stop"
);
remote.disconnect(connection).await.unwrap();
let _ = std::fs::remove_dir_all(data_dir);
}
#[cfg(target_os = "linux")]
#[derive(Default)]
struct NeverFinishingRuntime(openless_core::testing::RecordingRemoteInputRuntime);
// Replace only the external provider wait. Authentication, session ownership,
// finalization state and cancellation still execute the real Core service.
#[cfg(target_os = "linux")]
impl RemoteInputRuntimeAdapter for NeverFinishingRuntime {
fn load_pairing_pin(
&self,
) -> BoxFuture<'static, Result<Option<SecretValue>, BackendError>> {
self.0.load_pairing_pin()
}
fn persist_pairing_pin(
&self,
pin: SecretValue,
) -> BoxFuture<'static, Result<(), BackendError>> {
self.0.persist_pairing_pin(pin)
}
fn start_server(
&self,
config: RemoteInputServerConfig,
) -> BoxFuture<'static, Result<RemoteInputServerBinding, BackendError>> {
self.0.start_server(config)
}
fn stop_server(&self) -> BoxFuture<'static, Result<(), BackendError>> {
self.0.stop_server()
}
fn list_local_ips(&self) -> BoxFuture<'static, Result<Vec<String>, BackendError>> {
self.0.list_local_ips()
}
fn start_audio_session(
&self,
insert: bool,
) -> BoxFuture<'static, Result<SessionId, BackendError>> {
self.0.start_audio_session(insert)
}
fn feed_audio(
&self,
session: SessionId,
pcm: Vec<u8>,
) -> BoxFuture<'static, Result<(), BackendError>> {
self.0.feed_audio(session, pcm)
}
fn stop_audio_session(&self, _: SessionId) -> BoxFuture<'static, Result<(), BackendError>> {
Box::pin(std::future::pending())
}
fn cancel_audio_session(
&self,
session: SessionId,
) -> BoxFuture<'static, Result<(), BackendError>> {
self.0.cancel_audio_session(session)
}
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn cancel_releases_a_never_finishing_stop_before_the_next_start() {
let runtime = Arc::new(NeverFinishingRuntime::default());
let (backend, connection, data_dir) = connected_remote_fixture(runtime.clone()).await;
let remote = &backend.services().remote_input;
let mut owner = None;
let mut pending_stop = None;
apply_control(
r#"{"type":"start"}"#,
remote.as_ref(),
connection,
&mut owner,
&mut pending_stop,
)
.await;
let first = owner.unwrap();
apply_control(
r#"{"type":"stop"}"#,
remote.as_ref(),
connection,
&mut owner,
&mut pending_stop,
)
.await;
// Enter the actual Core finishing state and reach the never-ready Host
// wait, rather than merely testing a not-yet-polled stop future.
assert!(futures_util::poll!(pending_stop.as_mut().unwrap().1.as_mut()).is_pending());
assert_eq!(remote.status().unwrap().active_session_id, Some(first));
apply_control(
r#"{"type":"cancel"}"#,
remote.as_ref(),
connection,
&mut owner,
&mut pending_stop,
)
.await;
assert_eq!(runtime.0.audio_cancel_count(), 1);
let response = apply_control(
r#"{"type":"start"}"#,
remote.as_ref(),
connection,
&mut owner,
&mut pending_stop,
)
.await
.unwrap();
assert_eq!(
response["type"], "started",
"cancel must permit a new recording before the old HTTP timeout"
);
assert_ne!(owner, Some(first));
remote.disconnect(connection).await.unwrap();
owner = None;
pending_stop = Some((first, Box::pin(async { Ok(()) })));
apply_control(
r#"{"type":"cancel"}"#,
remote.as_ref(),
connection,
&mut owner,
&mut pending_stop,
)
.await;
pending_stop
.take()
.expect("a terminal already removed the owner; its cleanup must finish")
.1
.await
.unwrap();
let _ = std::fs::remove_dir_all(data_dir);
}
}