blob: 99c37e6f3e96ec886d1624e3d611a3e5858e0c39 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Select the desktop independently of the OS running the Agent.
#[cfg(feature = "native-desktop")]
use crate::session;
use crate::{Driver, protocol::Command};
use maka_agent_facts::{language::Language, tools::ToolError};
use maka_capabilities::computer::BrowserConnection;
use maka_capabilities::computer::Desktop;
use serde_json::Value;
use std::sync::Arc;
use std::{path::PathBuf, sync::OnceLock};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
pub(crate) mod worker;
pub use worker::bootstrap;
/// The embedding application owns distribution and version selection. Neither
/// the model nor plugin configuration can provide a downloader or executable.
pub type WindowsHelperResolver = fn() -> maka_agent_facts::tools::ToolFuture<PathBuf>;
static WINDOWS_HELPER: OnceLock<WindowsHelperResolver> = OnceLock::new();
pub fn register_windows_helper(resolver: WindowsHelperResolver) {
assert!(
WINDOWS_HELPER.set(resolver).is_ok(),
"Windows helper resolver is configured once at application startup"
);
}
pub(crate) fn local_platform() -> &'static str {
match std::env::consts::OS {
"macos" => "mac",
other => other,
}
}
fn is_wsl() -> bool {
cfg!(target_os = "linux")
&& std::fs::read_to_string("/proc/sys/kernel/osrelease")
.is_ok_and(|release| release.to_ascii_lowercase().contains("microsoft"))
}
fn use_windows(desktop: Desktop, platform: &str, wsl: bool) -> Result<bool, ToolError> {
match desktop {
Desktop::Local => Ok(false),
Desktop::Auto => Ok(platform == "linux" && wsl),
Desktop::Windows if platform == "windows" => Ok(false),
Desktop::Windows if platform == "linux" && wsl => Ok(true),
Desktop::Windows => Err(failed("Windows desktop requires Windows or WSL")),
}
}
/// The selected desktop owns all targets, including browser connections. The
/// embedding Host still admits and journals every operation before invoking it.
pub struct Session {
driver: Arc<Mutex<Driver>>,
backend: Option<Backend>,
closed: bool,
}
enum Backend {
#[cfg(feature = "native-desktop")]
Local(session::Session),
Windows {
id: Option<String>,
browsers: Vec<BrowserConnection>,
},
}
impl Session {
pub fn new(driver: Arc<Mutex<Driver>>) -> Self {
Self {
driver,
backend: None,
closed: false,
}
}
/// Snapshot display metadata from the same target owner used for execution.
/// Remote/unknown bindings use a generic action, never a guessed window name.
pub fn activity(&self, command: &Command) -> maka_agent_facts::display::Text {
let target = match (&self.backend, command) {
#[cfg(feature = "native-desktop")]
(
Some(Backend::Local(local)),
Command::Observe { handle, .. } | Command::Action { handle, .. },
) => local.target_label(handle),
_ => None,
};
crate::activity::command(command, target)
}
pub fn configure(
&mut self,
desktop: Desktop,
browsers: Vec<BrowserConnection>,
language: Language,
) -> Result<(), ToolError> {
if self.closed {
return Err(failed("Computer Use Session is closed"));
}
validate_connections(&browsers)?;
let windows = use_windows(desktop, local_platform(), is_wsl())?;
if let Some(backend) = &self.backend
&& windows != matches!(backend, Backend::Windows { .. })
{
return Err(failed(match language {
Language::En => {
"desktop configuration changed; reset Cua before switching desktops"
}
Language::ZhCn => "桌面配置已改变;请先重置 Cua,再切换桌面",
Language::ZhTw => "桌面設定已變更;請先重設 Cua,再切換桌面",
}));
}
if self.backend.is_none() {
self.backend = Some(if windows {
Backend::Windows {
id: None,
browsers: Vec::new(),
}
} else {
#[cfg(feature = "native-desktop")]
{
Backend::Local(session::Session::new(self.driver.clone()))
}
#[cfg(not(feature = "native-desktop"))]
return Err(native_unavailable());
});
}
match self.backend.as_mut().unwrap() {
#[cfg(feature = "native-desktop")]
Backend::Local(local) => local.configure_browsers(browsers, language),
Backend::Windows {
browsers: current, ..
} => {
*current = browsers;
Ok(())
}
}
}
pub fn configure_browsers(
&mut self,
browsers: Vec<BrowserConnection>,
language: Language,
) -> Result<(), ToolError> {
self.configure(Desktop::Auto, browsers, language)
}
pub fn platform(&self) -> &'static str {
if matches!(self.backend, Some(Backend::Windows { .. })) {
"windows"
} else {
local_platform()
}
}
/// Prepare dependencies and the private channel outside the REPL deadline.
/// This does not create a native Cua runtime or dispatch any input.
pub async fn prepare(
&mut self,
cancellation: &CancellationToken,
language: Language,
) -> Result<(), ToolError> {
if cancellation.is_cancelled() {
return Err(failed("Computer Use preparation cancelled"));
}
if self.closed {
return Err(failed("Computer Use Session is closed"));
}
if self.backend.is_none() {
self.configure(Desktop::Auto, Vec::new(), language)?;
}
if matches!(self.backend, Some(Backend::Windows { .. })) {
let mut driver = tokio::select! {
biased;
_ = cancellation.cancelled() => return Err(failed("Computer Use preparation cancelled")),
driver = self.driver.lock() => driver,
};
driver.windows.prepare(cancellation).await?;
}
Ok(())
}
pub async fn invoke(
&mut self,
command: Command,
session: &str,
cancellation: &CancellationToken,
language: Language,
) -> Result<Value, ToolError> {
if cancellation.is_cancelled() {
return Err(failed("Computer Use cancelled before dispatch"));
}
if self.closed {
return Err(failed("Computer Use Session is closed"));
}
if self.backend.is_none() {
self.configure(Desktop::Auto, Vec::new(), language)?;
}
match self.backend.as_mut().unwrap() {
#[cfg(feature = "native-desktop")]
Backend::Local(local) => local.invoke(command, session, cancellation, language).await,
Backend::Windows { id, browsers } => {
if id.as_deref().is_some_and(|id| id != session) {
return Err(failed("Computer Use Session identity mismatch"));
}
id.get_or_insert_with(|| session.into());
let mut driver = self.driver.lock().await;
if cancellation.is_cancelled() {
return Err(failed("Computer Use cancelled before dispatch"));
}
driver
.windows
.call(
worker::Request::Invoke {
session: session.into(),
browsers: browsers.clone(),
command,
language,
},
cancellation,
)
.await
}
}
}
pub async fn close(&mut self) -> Result<(), ToolError> {
self.closed = true;
match &mut self.backend {
#[cfg(feature = "native-desktop")]
Some(Backend::Local(local)) => local.close().await,
Some(Backend::Windows { id: Some(id), .. }) => {
self.driver.lock().await.windows.release_session(id).await?;
self.backend = None;
Ok(())
}
_ => Ok(()),
}
}
}
pub(crate) fn validate_connections(connections: &[BrowserConnection]) -> Result<(), ToolError> {
let mut ids = std::collections::HashSet::new();
for connection in connections {
let url = reqwest::Url::parse(&connection.endpoint).map_err(failed)?;
let loopback = url.host_str().is_some_and(|host| {
host == "localhost"
|| host
.trim_matches(['[', ']'])
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
});
if !loopback
|| url.scheme() != "http"
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| url.path() != "/"
{
return Err(failed(
"browser endpoint must be a loopback HTTP origin, without credentials, path or query",
));
}
if connection.id.is_empty() || !ids.insert(&connection.id) {
return Err(failed("browser IDs must be nonempty and unique"));
}
}
Ok(())
}
#[cfg(not(feature = "native-desktop"))]
fn native_unavailable() -> ToolError {
failed(
"Local desktop support is not included in this build; build Maka with the native-desktop feature, or use the Windows desktop from WSL",
)
}
fn failed(error: impl std::fmt::Display) -> ToolError {
ToolError::Failed(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_selection_never_falls_back_from_windows() {
for platform in ["linux", "mac", "windows"] {
for wsl in [false, true] {
assert!(!use_windows(Desktop::Local, platform, wsl).unwrap());
assert_eq!(
use_windows(Desktop::Auto, platform, wsl).unwrap(),
platform == "linux" && wsl
);
let result = use_windows(Desktop::Windows, platform, wsl);
match platform {
"windows" => assert!(!result.unwrap()),
"linux" if wsl => assert!(result.unwrap()),
_ => assert!(
matches!(result, Err(ToolError::Failed(message)) if message == "Windows desktop requires Windows or WSL")
),
}
}
}
}
#[cfg(not(feature = "native-desktop"))]
#[tokio::test]
async fn bridge_build_rejects_local_configuration_and_keeps_windows_available() {
let driver = Arc::new(Mutex::new(Driver::default()));
let mut session = Session::new(driver.clone());
let error = session
.configure(Desktop::Local, Vec::new(), Language::En)
.unwrap_err();
assert_eq!(
error.to_string(),
"tool failed: Local desktop support is not included in this build; build Maka with the native-desktop feature, or use the Windows desktop from WSL"
);
// Auto may select the Windows worker only in WSL. Elsewhere it must
// report the missing native capability during configuration too.
if !is_wsl() {
assert_eq!(
session
.configure(Desktop::Auto, Vec::new(), Language::En)
.unwrap_err()
.to_string(),
error.to_string()
);
} else {
session
.configure(Desktop::Auto, Vec::new(), Language::En)
.unwrap();
assert_eq!(session.platform(), "windows");
}
session.close().await.unwrap();
driver.lock().await.close().await.unwrap();
}
}