use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use ashpd::Error as PortalFailure;
use ashpd::desktop::input_capture::{
Capabilities, ConnectToEISOptions as CaptureEisOptions, CreateSession2Options,
CreateSessionOptions as CreateCaptureOptions, InputCapture, StartOptions as CaptureStart,
};
use ashpd::desktop::remote_desktop::{
ConnectToEISOptions as DesktopEisOptions, DeviceType, RemoteDesktop, SelectDevicesOptions,
StartOptions as DesktopStart,
};
use ashpd::desktop::{CreateSessionOptions, PersistMode, ResponseError, Session};
use reis::ei::{self, handshake::ContextType};
use reis::event::Connection;
use reis::tokio::EiConvertEventStream;
use super::tokens::{TokenKind, TokenStore};
use crate::clipboard::wayland::portal as clipboard_portal;
use crate::input::InputError;
const PORTAL_APP_ID: &str = "io.github.PuppetMaster2k.Apeirogon";
const EI_CLIENT_NAME: &str = "apeirogon";
const CAPTURE_PERMISSION: &str = "Wayland input capture";
const INJECT_PERMISSION: &str = "Wayland input injection";
pub(super) struct Eis {
pub(super) connection: Connection,
pub(super) events: EiConvertEventStream,
}
pub(super) struct CaptureSession {
pub(super) portal: InputCapture,
pub(super) session: Session<InputCapture>,
pub(super) eis: Eis,
}
pub(super) struct DesktopSession {
pub(super) session: Arc<Session<RemoteDesktop>>,
pub(super) eis: Eis,
pub(super) clipboard: Option<clipboard_portal::Service>,
}
pub(super) async fn register_app() {
static REGISTERED: AtomicBool = AtomicBool::new(false);
if REGISTERED.swap(true, Ordering::SeqCst) {
return;
}
let Ok(app_id) = ashpd::AppID::try_from(PORTAL_APP_ID) else {
return;
};
if let Err(error) = ashpd::register_host_app(app_id).await {
eprintln!("kind=portal_app_registration_skipped reason={error}");
}
}
pub(super) async fn open_capture(
tokens: &mut Option<TokenStore>,
) -> Result<CaptureSession, InputError> {
let fail = |error| portal_error(CAPTURE_PERMISSION, error);
let portal = InputCapture::new().await.map_err(fail)?;
let capabilities = Capabilities::Keyboard | Capabilities::Pointer;
let session = match portal
.create_session2(CreateSession2Options::default())
.await
{
Ok(session) => {
let options = CaptureStart::default()
.set_capabilities(capabilities)
.set_persist_mode(PersistMode::ExplicitlyRevoked)
.set_restore_token(restore_token(tokens.as_ref(), TokenKind::InputCapture));
let response = portal
.start(&session, None, options)
.await
.and_then(|request| request.response())
.map_err(fail)?;
remember(tokens, TokenKind::InputCapture, response.restore_token());
session
}
Err(PortalFailure::RequiresVersion(..)) => {
let options = CreateCaptureOptions::default().set_capabilities(capabilities);
let (session, _granted) = portal.create_session(None, options).await.map_err(fail)?;
session
}
Err(error) => return Err(fail(error)),
};
let fd = portal
.connect_to_eis(&session, CaptureEisOptions::default())
.await
.map_err(fail)?;
let eis = handshake(fd, ContextType::Receiver).await?;
Ok(CaptureSession {
portal,
session,
eis,
})
}
pub(super) async fn open_desktop(
tokens: &mut Option<TokenStore>,
) -> Result<DesktopSession, InputError> {
let fail = |error| portal_error(INJECT_PERMISSION, error);
let portal = RemoteDesktop::new().await.map_err(fail)?;
let session = portal
.create_session(CreateSessionOptions::default())
.await
.map_err(fail)?;
let session = Arc::new(session);
let requested = clipboard_portal::request(&session).await;
let token = restore_token(tokens.as_ref(), TokenKind::RemoteDesktop);
let options = SelectDevicesOptions::default()
.set_devices(DeviceType::Keyboard | DeviceType::Pointer)
.set_persist_mode(PersistMode::ExplicitlyRevoked)
.set_restore_token(token.as_deref());
portal
.select_devices(&session, options)
.await
.and_then(|request| request.response())
.map_err(fail)?;
let granted = portal
.start(&session, None, DesktopStart::default())
.await
.and_then(|request| request.response())
.map_err(fail)?;
remember(tokens, TokenKind::RemoteDesktop, granted.restore_token());
let clipboard = connect_clipboard(requested, &session, granted.is_clipboard_enabled()).await;
if !granted
.devices()
.contains(DeviceType::Keyboard | DeviceType::Pointer)
{
return Err(InputError::MissingPermission(INJECT_PERMISSION));
}
let fd = portal
.connect_to_eis(&session, DesktopEisOptions::default())
.await
.map_err(fail)?;
let eis = handshake(fd, ContextType::Sender).await?;
Ok(DesktopSession {
session,
eis,
clipboard,
})
}
async fn connect_clipboard(
requested: Option<ashpd::desktop::clipboard::Clipboard>,
session: &Arc<Session<RemoteDesktop>>,
enabled: bool,
) -> Option<clipboard_portal::Service> {
let requested = requested.filter(|_| enabled)?;
match clipboard_portal::Service::connect(requested, Arc::clone(session)).await {
Ok(service) => Some(service),
Err(error) => {
eprintln!("kind=clipboard_wayland_portal_unavailable reason={error}");
None
}
}
}
async fn handshake(fd: std::os::fd::OwnedFd, context_type: ContextType) -> Result<Eis, InputError> {
let context = ei::Context::new(UnixStream::from(fd))
.map_err(|error| InputError::Portal(format!("cannot use the EI socket: {error}")))?;
let (connection, events) = context
.handshake_tokio(EI_CLIENT_NAME, context_type)
.await
.map_err(|error| InputError::Portal(format!("EI handshake failed: {error}")))?;
Ok(Eis { connection, events })
}
pub(super) fn portal_error(permission: &'static str, error: PortalFailure) -> InputError {
match error {
PortalFailure::Response(ResponseError::Cancelled) => {
InputError::MissingPermission(permission)
}
error => InputError::Portal(format!("{permission}: {error}")),
}
}
fn restore_token(tokens: Option<&TokenStore>, kind: TokenKind) -> Option<String> {
tokens?.get(kind).map(str::to_owned)
}
fn remember(tokens: &mut Option<TokenStore>, kind: TokenKind, token: Option<&str>) {
let Some(store) = tokens else {
return;
};
if store.set(kind, token).is_err() {
eprintln!("kind=input_wayland_token_save_failed");
}
}