From 0769229419d002ad7fcb976dd4f6283767f54642 Mon Sep 17 00:00:00 2001 From: Adrian Gunnar Lauterer Date: Fri, 24 Jan 2025 21:42:31 +0100 Subject: [PATCH 1/6] added api for swaymsg to greg-ng (untested) --- Cargo.lock | 23 +++++++++++++++++++++++ Cargo.toml | 1 + module.nix | 11 +++++++++++ src/api/base.rs | 12 ++++++++++++ src/api/rest_wrapper_v1.rs | 21 +++++++++++++++++++++ src/api/websocket_v1.rs | 7 +++++++ 6 files changed, 75 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 4a01d38..3a12ab6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -625,6 +625,7 @@ dependencies = [ "sd-notify", "serde", "serde_json", + "swayipc", "systemd-journal-logger", "tempfile", "tokio", @@ -1501,6 +1502,28 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "swayipc" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b8c50cb2e98e88b52066a35ef791fffd8f6fa631c3a4983de18ba41f718c736" +dependencies = [ + "serde", + "serde_json", + "swayipc-types", +] + +[[package]] +name = "swayipc-types" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551233c60323e87cfb8194c21cc44577ab848d00bb7fa2d324a2c7f52609eaff" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "syn" version = "2.0.90" diff --git a/Cargo.toml b/Cargo.toml index 5760878..7ae2593 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +swayipc = "3.0" anyhow = "1.0.82" axum = { version = "0.7.7", features = ["macros", "ws"] } clap = { version = "4.4.1", features = ["derive"] } diff --git a/module.nix b/module.nix index e03bc3c..f2e8c33 100644 --- a/module.nix +++ b/module.nix @@ -11,6 +11,8 @@ in mpvPackage = lib.mkPackageOption pkgs "mpv" { }; enableSway = lib.mkEnableOption "sway as the main window manager"; + + enableFirefox = lib.mkEnableOption "include fiorefox browser for external urls"; enablePipewire = lib.mkEnableOption "pipewire" // { default = true; }; @@ -166,6 +168,14 @@ in wrapperFeatures.gtk = true; }; + (lib.mkIf (cfg.enable && cfg.enableFirefox) { + programs.firefox = { + enable = true; + preferences = { + media.autoplay.default = "0"; + }; + }; + xdg.portal = { enable = true; wlr.enable = true; @@ -176,6 +186,7 @@ in users.greg = { isNormalUser = true; group = "greg"; + extraGroups [ "audio" "video" "input" ]; uid = 2000; description = "loud gym bro"; }; diff --git a/src/api/base.rs b/src/api/base.rs index 8f0833e..2b36dd4 100644 --- a/src/api/base.rs +++ b/src/api/base.rs @@ -170,3 +170,15 @@ pub async fn playlist_set_looping(mpv: Mpv, r#loop: bool) -> anyhow::Result<()> .await .map_err(|e| e.into()) } + +use swayipc::{Connection, Fallible}; + +pub async fn run_sway_command(command: String) -> Fallible<()> { + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + connection.run_command(&command)?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} \ No newline at end of file diff --git a/src/api/rest_wrapper_v1.rs b/src/api/rest_wrapper_v1.rs index db639b5..edb5ef1 100644 --- a/src/api/rest_wrapper_v1.rs +++ b/src/api/rest_wrapper_v1.rs @@ -32,6 +32,7 @@ pub fn rest_api_routes(mpv: Mpv) -> Router { .route("/playlist/shuffle", post(shuffle)) .route("/playlist/loop", get(playlist_get_looping)) .route("/playlist/loop", post(playlist_set_looping)) + .route("/sway/command", post(sway_command)) .with_state(mpv) } @@ -401,3 +402,23 @@ async fn playlist_set_looping( ) -> RestResponse { base::playlist_set_looping(mpv, query.r#loop).await.into() } + + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct SwayCommandArgs { + command: String, +} + +/// Execute a sway command +#[utoipa::path( + post, + path = "/sway/command", + params(SwayCommandArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_command(Query(query): Query) -> RestResponse { + base::run_sway_command(query.command).await.map_err(anyhow::Error::new).into() +} diff --git a/src/api/websocket_v1.rs b/src/api/websocket_v1.rs index 8436e4f..721d263 100644 --- a/src/api/websocket_v1.rs +++ b/src/api/websocket_v1.rs @@ -25,6 +25,8 @@ use tokio::{select, sync::watch}; use crate::util::IdPool; +use super::base; + #[derive(Debug, Clone)] struct WebsocketState { mpv: Mpv, @@ -355,6 +357,7 @@ pub enum WSCommand { Shuffle, SetSubtitleTrack { track: Option }, SetLooping { value: bool }, + SwayCommand { command: String }, } async fn handle_message( @@ -445,5 +448,9 @@ async fn handle_message( .await?; Ok(None) } + WSCommand::SwayCommand { command } => { + base::run_sway_command(command).await?; + Ok(None) + } } } -- 2.47.2 From 4465502ede3ddb44eb99b6fea45446a69c762492 Mon Sep 17 00:00:00 2001 From: Adrian Gunnar Lauterer Date: Sat, 25 Jan 2025 20:50:17 +0100 Subject: [PATCH 2/6] api v2 start --- Cargo.lock | 1 + Cargo.toml | 1 + module.nix | 14 +- src/api.rs | 4 +- src/api/base.rs | 99 +++++++- src/api/rest_wrapper_v1.rs | 23 +- src/api/rest_wrapper_v2.rs | 493 +++++++++++++++++++++++++++++++++++++ src/api/websocket_v1.rs | 2 +- src/main.rs | 6 +- 9 files changed, 609 insertions(+), 34 deletions(-) create mode 100644 src/api/rest_wrapper_v2.rs diff --git a/Cargo.lock b/Cargo.lock index 3a12ab6..fa9284c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -631,6 +631,7 @@ dependencies = [ "tokio", "tower 0.4.13", "tower-http", + "url", "utoipa", "utoipa-axum", "utoipa-swagger-ui", diff --git a/Cargo.toml b/Cargo.toml index 7ae2593..a99ff8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +url = "2.2.2" swayipc = "3.0" anyhow = "1.0.82" axum = { version = "0.7.7", features = ["macros", "ws"] } diff --git a/module.nix b/module.nix index f2e8c33..6718389 100644 --- a/module.nix +++ b/module.nix @@ -168,13 +168,15 @@ in wrapperFeatures.gtk = true; }; - (lib.mkIf (cfg.enable && cfg.enableFirefox) { - programs.firefox = { - enable = true; - preferences = { - media.autoplay.default = "0"; + (lib.mkIf (cfg.enableFirefox) { + programs.firefox = { + enable = true; + preferences = { + media.autoplay.default = "0"; + }; }; - }; + environment.sessionVariables.DEFAULT_BROWSER = "${pkgs.firefox}/bin/firefox"; + }) xdg.portal = { enable = true; diff --git a/src/api.rs b/src/api.rs index 604e99e..145e5ae 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,8 @@ mod base; mod rest_wrapper_v1; +mod rest_wrapper_v2; mod websocket_v1; -pub use rest_wrapper_v1::{rest_api_docs, rest_api_routes}; +pub use rest_wrapper_v1::{rest_api_docs as rest_api_docs_v1, rest_api_routes as rest_api_routes_v1}; +pub use rest_wrapper_v2::{rest_api_docs as rest_api_docs_v2, rest_api_routes as rest_api_routes_v2}; pub use websocket_v1::websocket_api; diff --git a/src/api/base.rs b/src/api/base.rs index 2b36dd4..4c05cf8 100644 --- a/src/api/base.rs +++ b/src/api/base.rs @@ -173,7 +173,7 @@ pub async fn playlist_set_looping(mpv: Mpv, r#loop: bool) -> anyhow::Result<()> use swayipc::{Connection, Fallible}; -pub async fn run_sway_command(command: String) -> Fallible<()> { +pub async fn sway_run_command(command: String) -> Fallible<()> { tokio::task::spawn_blocking(move || -> Fallible<()> { let mut connection = Connection::new()?; connection.run_command(&command)?; @@ -181,4 +181,99 @@ pub async fn run_sway_command(command: String) -> Fallible<()> { }) .await .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? -} \ No newline at end of file +} + + +//only to check if workspace exists. +fn get_workspace_names(connection: &mut Connection) -> Fallible> { + let workspaces = connection.get_workspaces()?; + Ok(workspaces.iter().map(|w| w.name.clone()).collect()) +} + +pub async fn sway_get_workspace_names() -> Fallible> { + tokio::task::spawn_blocking(|| -> Fallible> { + let mut connection = Connection::new()?; + get_workspace_names(&mut connection) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +fn is_valid_workspace(workspace: &str, connection: &mut Connection) -> Fallible { + let workspace_names = get_workspace_names(connection)?; + Ok(workspace_names.contains(&workspace.to_string()) || + workspace.parse::() + .map(|num| num >= 1 && num <= 10) + .unwrap_or(false)) +} + +pub async fn sway_change_workspace(workspace: String) -> Fallible<()> { + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + + if !is_valid_workspace(&workspace, &mut connection)? { + return Err(swayipc::Error::CommandFailed( + "Invalid workspace name. Must be existing workspace or number 1-10".to_string() + )); + } + + connection.run_command(&format!("workspace {}", workspace))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +use url::Url; +pub async fn sway_launch_browser(url: &str) -> Fallible<()> { + // Validate URL + let url = Url::parse(url) + .map_err(|e| swayipc::Error::CommandFailed(format!("Invalid URL: {}", e)))?; + + // Ensure URL scheme is http or https + if url.scheme() != "http" && url.scheme() != "https" { + return Err(swayipc::Error::CommandFailed("URL must use http or https protocol".into())); + } + + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + connection.run_command(&format!("exec xdg-open {}", url))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +pub async fn sway_close_workspace(workspace: String) -> Fallible<()> { + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + + // Validate workspace exists + if !is_valid_workspace(&workspace, &mut connection)? { + return Err(swayipc::Error::CommandFailed( + "Invalid workspace name".to_string() + )); + } + + // Get workspace tree and find all nodes in target workspace + let tree = connection.get_tree()?; + let workspace_nodes = tree + .nodes + .iter() + .flat_map(|output| &output.nodes) // Get workspaces + .find(|ws| ws.name.as_ref().map_or(false, |name| name == &workspace)); + + // Kill all nodes in workspace if found + if let Some(ws) = workspace_nodes { + for container in ws.nodes.iter() { + // Close each container in the workspace + connection.run_command(&format!("[con_id={}] kill", container.id))?; + } + } + + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + diff --git a/src/api/rest_wrapper_v1.rs b/src/api/rest_wrapper_v1.rs index edb5ef1..8ad4a58 100644 --- a/src/api/rest_wrapper_v1.rs +++ b/src/api/rest_wrapper_v1.rs @@ -32,7 +32,6 @@ pub fn rest_api_routes(mpv: Mpv) -> Router { .route("/playlist/shuffle", post(shuffle)) .route("/playlist/loop", get(playlist_get_looping)) .route("/playlist/loop", post(playlist_set_looping)) - .route("/sway/command", post(sway_command)) .with_state(mpv) } @@ -52,7 +51,7 @@ pub fn rest_api_docs(mpv: Mpv) -> Router { .with_state(mpv) .split_for_parts(); - router.merge(SwaggerUi::new("/docs").url("/docs/openapi.json", api)) + router.merge(SwaggerUi::new("/docs/v1").url("/docs/v1/openapi.json", api)) } // NOTE: the openapi stuff is very heavily duplicated and introduces @@ -402,23 +401,3 @@ async fn playlist_set_looping( ) -> RestResponse { base::playlist_set_looping(mpv, query.r#loop).await.into() } - - -#[derive(serde::Deserialize, utoipa::IntoParams)] -struct SwayCommandArgs { - command: String, -} - -/// Execute a sway command -#[utoipa::path( - post, - path = "/sway/command", - params(SwayCommandArgs), - responses( - (status = 200, description = "Success", body = EmptySuccessResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), - ) -)] -async fn sway_command(Query(query): Query) -> RestResponse { - base::run_sway_command(query.command).await.map_err(anyhow::Error::new).into() -} diff --git a/src/api/rest_wrapper_v2.rs b/src/api/rest_wrapper_v2.rs new file mode 100644 index 0000000..795992f --- /dev/null +++ b/src/api/rest_wrapper_v2.rs @@ -0,0 +1,493 @@ +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{delete, get, post}, + Json, Router, +}; +use mpvipc_async::Mpv; +use serde_json::{json, Value}; + +use utoipa::OpenApi; +use utoipa_axum::{router::OpenApiRouter, routes}; +use utoipa_swagger_ui::SwaggerUi; + +use super::base; + +pub fn rest_api_routes(mpv: Mpv) -> Router { + Router::new() + .route("/load", post(loadfile)) + .route("/play", get(play_get)) + .route("/play", post(play_set)) + .route("/volume", get(volume_get)) + .route("/volume", post(volume_set)) + .route("/time", get(time_get)) + .route("/time", post(time_set)) + .route("/playlist", get(playlist_get)) + .route("/playlist/next", post(playlist_next)) + .route("/playlist/previous", post(playlist_previous)) + .route("/playlist/goto", post(playlist_goto)) + .route("/playlist", delete(playlist_remove_or_clear)) + .route("/playlist/move", post(playlist_move)) + .route("/playlist/shuffle", post(shuffle)) + .route("/playlist/loop", get(playlist_get_looping)) + .route("/playlist/loop", post(playlist_set_looping)) + .route("/sway/command", post(sway_run_command)) + .route("/sway/workspace/close", post(sway_close_workspace_handler)) + .route("/sway/workspace/change", post(sway_change_workspace_handler)) + .route("/sway/workspace/list", get(sway_get_workspace_names_handler)) + .route("/sway/browser/launch", post(sway_launch_browser_handler)) + .with_state(mpv) +} + +pub fn rest_api_docs(mpv: Mpv) -> Router { + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .routes(routes!(loadfile)) + .routes(routes!(play_get, play_set)) + .routes(routes!(volume_get, volume_set)) + .routes(routes!(time_get, time_set)) + .routes(routes!(playlist_get, playlist_remove_or_clear)) + .routes(routes!(playlist_next)) + .routes(routes!(playlist_previous)) + .routes(routes!(playlist_goto)) + .routes(routes!(playlist_move)) + .routes(routes!(playlist_get_looping, playlist_set_looping)) + .routes(routes!(shuffle)) + .with_state(mpv) + .split_for_parts(); + + router.merge(SwaggerUi::new("/docs/v2").url("/docs/v2/openapi.json", api)) +} + +// NOTE: the openapi stuff is very heavily duplicated and introduces +// a lot of maintenance overhead and boilerplate. It should theoretically +// be possible to infer a lot of this from axum, but I haven't found a +// good library that does this and works properly yet (I have tried some +// but they all had issues). Feel free to replace this with a better solution. + +#[derive(OpenApi)] +#[openapi(info( + description = "The legacy Grzegorz Brzeczyszczykiewicz API, used to control a running mpv instance", + version = "1.0.0", +))] +struct ApiDoc; + +#[derive(serde::Serialize, utoipa::ToSchema)] +struct EmptySuccessResponse { + success: bool, + error: bool, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +struct SuccessResponse { + #[schema(example = true)] + success: bool, + #[schema(example = false)] + error: bool, + #[schema(example = json!({ some: "arbitrary json value" }))] + value: Value, +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +struct ErrorResponse { + #[schema(example = "error....")] + error: String, + #[schema(example = "error....")] + errortext: String, + #[schema(example = false)] + success: bool, +} + +pub struct RestResponse(anyhow::Result); + +impl From> for RestResponse { + fn from(result: anyhow::Result) -> Self { + Self(result.map(|value| json!({ "success": true, "error": false, "value": value }))) + } +} + +impl From> for RestResponse { + fn from(result: anyhow::Result<()>) -> Self { + Self(result.map(|_| json!({ "success": true, "error": false }))) + } +} + +impl IntoResponse for RestResponse { + fn into_response(self) -> Response { + match self.0 { + Ok(value) => (StatusCode::OK, Json(value)).into_response(), + Err(err) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": err.to_string(), "errortext": err.to_string(), "success": false })), + ) + .into_response(), + } + } +} + +// -------------------// +// Boilerplate galore // +// -------------------// + +// TODO: These could possibly be generated with a proc macro + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct LoadFileArgs { + path: String, +} + +/// Add item to playlist +#[utoipa::path( + post, + path = "/load", + params(LoadFileArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn loadfile(State(mpv): State, Query(query): Query) -> RestResponse { + base::loadfile(mpv, &query.path).await.into() +} + +/// Check whether the player is paused or playing +#[utoipa::path( + get, + path = "/play", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn play_get(State(mpv): State) -> RestResponse { + base::play_get(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaySetArgs { + play: String, +} + +/// Set whether the player is paused or playing +#[utoipa::path( + post, + path = "/play", + params(PlaySetArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn play_set(State(mpv): State, Query(query): Query) -> RestResponse { + let play = query.play.to_lowercase() == "true"; + base::play_set(mpv, play).await.into() +} + +/// Get the current player volume +#[utoipa::path( + get, + path = "/volume", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn volume_get(State(mpv): State) -> RestResponse { + base::volume_get(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct VolumeSetArgs { + volume: f64, +} + +/// Set the player volume +#[utoipa::path( + post, + path = "/volume", + params(VolumeSetArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn volume_set(State(mpv): State, Query(query): Query) -> RestResponse { + base::volume_set(mpv, query.volume).await.into() +} + +/// Get current playback position +#[utoipa::path( + get, + path = "/time", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn time_get(State(mpv): State) -> RestResponse { + base::time_get(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct TimeSetArgs { + pos: Option, + percent: Option, +} + +/// Set playback position +#[utoipa::path( + post, + path = "/time", + params(TimeSetArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn time_set(State(mpv): State, Query(query): Query) -> RestResponse { + base::time_set(mpv, query.pos, query.percent).await.into() +} + +/// Get the current playlist +#[utoipa::path( + get, + path = "/playlist", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_get(State(mpv): State) -> RestResponse { + base::playlist_get(mpv).await.into() +} + +/// Go to the next item in the playlist +#[utoipa::path( + post, + path = "/playlist/next", + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_next(State(mpv): State) -> RestResponse { + base::playlist_next(mpv).await.into() +} + +/// Go back to the previous item in the playlist +#[utoipa::path( + post, + path = "/playlist/previous", + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_previous(State(mpv): State) -> RestResponse { + base::playlist_previous(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaylistGotoArgs { + index: usize, +} + +/// Go to a specific item in the playlist +#[utoipa::path( + post, + path = "/playlist/goto", + params(PlaylistGotoArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_goto( + State(mpv): State, + Query(query): Query, +) -> RestResponse { + base::playlist_goto(mpv, query.index).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaylistRemoveOrClearArgs { + index: Option, +} + +/// Clears a single item or the entire playlist +#[utoipa::path( + delete, + path = "/playlist", + params(PlaylistRemoveOrClearArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_remove_or_clear( + State(mpv): State, + Query(query): Query, +) -> RestResponse { + match query.index { + Some(index) => base::playlist_remove(mpv, index).await.into(), + None => base::playlist_clear(mpv).await.into(), + } +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaylistMoveArgs { + index1: usize, + index2: usize, +} + +/// Move a playlist item to a different position +#[utoipa::path( + post, + path = "/playlist/move", + params(PlaylistMoveArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_move( + State(mpv): State, + Query(query): Query, +) -> RestResponse { + base::playlist_move(mpv, query.index1, query.index2) + .await + .into() +} + +/// Shuffle the playlist +#[utoipa::path( + post, + path = "/playlist/shuffle", + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn shuffle(State(mpv): State) -> RestResponse { + base::shuffle(mpv).await.into() +} + +/// Check whether the playlist is looping +#[utoipa::path( + get, + path = "/playlist/loop", + responses( + (status = 200, description = "Success", body = SuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_get_looping(State(mpv): State) -> RestResponse { + base::playlist_get_looping(mpv).await.into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct PlaylistSetLoopingArgs { + r#loop: bool, +} + +/// Set whether the playlist should loop +#[utoipa::path( + post, + path = "/playlist/loop", + params(PlaylistSetLoopingArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn playlist_set_looping( + State(mpv): State, + Query(query): Query, +) -> RestResponse { + base::playlist_set_looping(mpv, query.r#loop).await.into() +} + + +/// Execute a sway command TODO: restrict to switching workspace, etc instead of arbitrary commands +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct SwayCommandArgs { + command: String, +} +#[utoipa::path( + post, + path = "/sway/command", + params(SwayCommandArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_run_command(Query(query): Query) -> RestResponse { + base::sway_run_command(query.command).await.map_err(anyhow::Error::new).into() +} + + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct SwayBrowserArgs { + url: String, +} +#[utoipa::path( + post, + path = "/sway/browser/launch", + params(SwayBrowserArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_launch_browser_handler(Query(query): Query) -> RestResponse { + base::sway_launch_browser(&query.url).await.map_err(anyhow::Error::new).into() +} + +#[derive(serde::Deserialize, utoipa::IntoParams)] +struct SwayWorkspaceArgs { + workspace: String, +} + + +#[utoipa::path( + post, + path = "/sway/workspace/close", + params(SwayWorkspaceArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_close_workspace_handler(Query(query): Query) -> RestResponse { + base::sway_close_workspace(query.workspace).await.map_err(anyhow::Error::new).into() +} + +#[utoipa::path( + post, + path = "/sway/workspace/change", + params(SwayWorkspaceArgs), + responses( + (status = 200, description = "Success", body = EmptySuccessResponse), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_change_workspace_handler(Query(query): Query) -> RestResponse { + base::sway_change_workspace(query.workspace).await.map_err(anyhow::Error::new).into() +} + +#[utoipa::path( + get, + path = "/sway/workspace/list", + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_get_workspace_names_handler() -> RestResponse { + base::sway_get_workspace_names().await + .map(|workspaces| json!(workspaces)) + .map_err(anyhow::Error::new) + .into() +} + diff --git a/src/api/websocket_v1.rs b/src/api/websocket_v1.rs index 721d263..1ee2ff2 100644 --- a/src/api/websocket_v1.rs +++ b/src/api/websocket_v1.rs @@ -449,7 +449,7 @@ async fn handle_message( Ok(None) } WSCommand::SwayCommand { command } => { - base::run_sway_command(command).await?; + base::sway_run_command(command).await?; Ok(None) } } diff --git a/src/main.rs b/src/main.rs index bd9d29a..8e60342 100644 --- a/src/main.rs +++ b/src/main.rs @@ -231,9 +231,11 @@ async fn main() -> anyhow::Result<()> { let id_pool = Arc::new(Mutex::new(IdPool::new_with_max_limit(1024))); let app = Router::new() - .nest("/api", api::rest_api_routes(mpv.clone())) + // .nest("/api/v1", api::rest_api_routes_v1(mpv.clone())) + .nest("/api/v2", api::rest_api_routes_v2(mpv.clone())) .nest("/ws", api::websocket_api(mpv.clone(), id_pool.clone())) - .merge(api::rest_api_docs(mpv.clone())) + // .merge(api::rest_api_docs_v1(mpv.clone())) + .merge(api::rest_api_docs_v2(mpv.clone())) .into_make_service_with_connect_info::(); let listener = match tokio::net::TcpListener::bind(&socket_addr) -- 2.47.2 From 20c53ed153fd7243c595f2adaed66a9057c3dc07 Mon Sep 17 00:00:00 2001 From: Adrian Gunnar Lauterer Date: Fri, 31 Jan 2025 13:01:41 +0100 Subject: [PATCH 3/6] mouse? --- Cargo.lock | 10 +- Cargo.toml | 2 + module.nix | 9 +- src/api.rs | 4 +- src/api/base.rs | 198 ++++++++++++++++++++++++++++++++----- src/api/rest_wrapper_v2.rs | 142 ++++++++++++++++++++------ src/api/websocket_v1.rs | 26 ++++- 7 files changed, 327 insertions(+), 64 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa9284c..0124bcd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -620,8 +620,10 @@ dependencies = [ "clap-verbosity-flag", "env_logger", "futures", + "lazy_static", "log", "mpvipc-async", + "regex", "sd-notify", "serde", "serde_json", @@ -988,6 +990,12 @@ dependencies = [ "libc", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.168" diff --git a/Cargo.toml b/Cargo.toml index a99ff8c..1b054ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,8 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +regex = "1.5.4" +lazy_static = "1.4.0" url = "2.2.2" swayipc = "3.0" anyhow = "1.0.82" diff --git a/module.nix b/module.nix index 6718389..b7e5a45 100644 --- a/module.nix +++ b/module.nix @@ -13,6 +13,8 @@ in enableSway = lib.mkEnableOption "sway as the main window manager"; enableFirefox = lib.mkEnableOption "include fiorefox browser for external urls"; + + enableInput = lib.mkEnableOption "enable input maipulation with ydotool,"; enablePipewire = lib.mkEnableOption "pipewire" // { default = true; }; @@ -162,6 +164,11 @@ in pulse.enable = true; }; }) + (lib.mkIf (cfg.enable && cfg.enableInput) { + programs.ydotool = { + enable = true; + }; + }) (lib.mkIf (cfg.enable && cfg.enableSway) { programs.sway = { enable = true; @@ -188,7 +195,7 @@ in users.greg = { isNormalUser = true; group = "greg"; - extraGroups [ "audio" "video" "input" ]; + extraGroups [ "audio" "video" "input" "ydotool" ]; uid = 2000; description = "loud gym bro"; }; diff --git a/src/api.rs b/src/api.rs index 145e5ae..dc667c0 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,8 +1,8 @@ mod base; -mod rest_wrapper_v1; +// mod rest_wrapper_v1; mod rest_wrapper_v2; mod websocket_v1; -pub use rest_wrapper_v1::{rest_api_docs as rest_api_docs_v1, rest_api_routes as rest_api_routes_v1}; +// pub use rest_wrapper_v1::{rest_api_docs as rest_api_docs_v1, rest_api_routes as rest_api_routes_v1}; pub use rest_wrapper_v2::{rest_api_docs as rest_api_docs_v2, rest_api_routes as rest_api_routes_v2}; pub use websocket_v1::websocket_api; diff --git a/src/api/base.rs b/src/api/base.rs index 4c05cf8..c67a38b 100644 --- a/src/api/base.rs +++ b/src/api/base.rs @@ -173,16 +173,15 @@ pub async fn playlist_set_looping(mpv: Mpv, r#loop: bool) -> anyhow::Result<()> use swayipc::{Connection, Fallible}; -pub async fn sway_run_command(command: String) -> Fallible<()> { - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; - connection.run_command(&command)?; - Ok(()) - }) - .await - .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? -} - +// pub async fn sway_run_command(command: String) -> Fallible<()> { +// tokio::task::spawn_blocking(move || -> Fallible<()> { +// let mut connection = Connection::new()?; +// connection.run_command(&command)?; +// Ok(()) +// }) +// .await +// .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +// } //only to check if workspace exists. fn get_workspace_names(connection: &mut Connection) -> Fallible> { @@ -225,6 +224,7 @@ pub async fn sway_change_workspace(workspace: String) -> Fallible<()> { } use url::Url; + pub async fn sway_launch_browser(url: &str) -> Fallible<()> { // Validate URL let url = Url::parse(url) @@ -237,7 +237,19 @@ pub async fn sway_launch_browser(url: &str) -> Fallible<()> { tokio::task::spawn_blocking(move || -> Fallible<()> { let mut connection = Connection::new()?; - connection.run_command(&format!("exec xdg-open {}", url))?; + // connection.run_command(&format!("exec xdg-open {}", url))?; + connection.run_command(&format!("exec firefox --kiosk {}", url))?; //moved to firefox to pin in kiosk mode. potentially add --new-window + + // let browser_output = std::process::Command::new("xdg-settings") + // .arg("get") + // .arg("default-web-browser") + // .output()?; + // let default_browser = String::from_utf8(browser_output.stdout)? + // .trim() + // .trim_end_matches(".desktop") + // .to_string(); + // connection.run_command(&format!("exec {} --kiosk {}", default_browser, url))?; // set default browser in kiosk mode + Ok(()) }) .await @@ -255,21 +267,45 @@ pub async fn sway_close_workspace(workspace: String) -> Fallible<()> { )); } - // Get workspace tree and find all nodes in target workspace - let tree = connection.get_tree()?; - let workspace_nodes = tree - .nodes - .iter() - .flat_map(|output| &output.nodes) // Get workspaces - .find(|ws| ws.name.as_ref().map_or(false, |name| name == &workspace)); + // // Get workspace tree and find all nodes in target workspace + // let tree = connection.get_tree()?; + // let workspace_nodes = tree + // .nodes + // .iter() + // .flat_map(|output| &output.nodes) // Get workspaces + // .find(|ws| ws.name.as_ref().map_or(false, |name| name == &workspace)); - // Kill all nodes in workspace if found - if let Some(ws) = workspace_nodes { - for container in ws.nodes.iter() { - // Close each container in the workspace - connection.run_command(&format!("[con_id={}] kill", container.id))?; - } - } + // // Kill all nodes in workspace if found + // if let Some(ws) = workspace_nodes { + // for container in ws.nodes.iter() { + // // Close each container in the workspace + // connection.run_command(&format!("[con_id={}] kill", container.id))?; + // } + // } + + // Get workspace tree and find all nodes in target workspace + let tree = connection.get_tree()?; + let workspace_nodes = tree + .nodes + .iter() + .flat_map(|output| &output.nodes) // Get workspaces + .find(|ws| ws.name.as_ref().map_or(false, |name| name == &workspace)); + + // Kill all non-MPV nodes in workspace if found + if let Some(ws) = workspace_nodes { + for container in ws.nodes.iter() { + // Check if window is MPV + let is_mpv = container.window_properties.as_ref() + .map(|props| props.class.as_ref() + .map_or(false, |class| class == "mpv")) + .unwrap_or(false); + + if !is_mpv { + // Close container only if not MPV + connection.run_command(&format!("[con_id={}] kill", container.id))?; + } + } + } Ok(()) }) @@ -277,3 +313,115 @@ pub async fn sway_close_workspace(workspace: String) -> Fallible<()> { .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? } + +use regex::Regex; +use lazy_static::lazy_static; + +lazy_static! { + static ref KEYPRESS_PATTERN: Regex = Regex::new(r"^(\d+:[01]\s*)+$").unwrap(); + static ref CLEANUP_PATTERN: Regex = Regex::new(r"[^0-9: \t]").unwrap(); +} + +fn validate_keypress_string(input: &str) -> Fallible { + let cleaned = CLEANUP_PATTERN.replace_all(input, "").to_string(); + let cleaned = cleaned.trim(); + + if !KEYPRESS_PATTERN.is_match(cleaned) { + return Err(swayipc::Error::CommandFailed( + "Invalid keypress format. Expected 'number:1 number:0'".into() + )); + } + Ok(cleaned.to_string()) +} + +//to simulate keypresses 42:1 38:1 38:0 24:1 24:0 38:1 38:0 42:0 -> LOL +pub async fn sway_input(keys: String) -> Fallible<()> { + let validated_input = validate_keypress_string(&keys)?; + + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + connection.run_command(&format!("exec ydotool key {}", validated_input))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +// simulate mouse movement +pub async fn sway_mouse_move(x: i32, y: i32) -> Fallible<()> { + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + connection.run_command(&format!("exec ydotool mousemove -x {} -y {}", x, y))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + + +//simulate scroll +pub async fn sway_mouse_scroll(x: i32, y: i32) -> Fallible<()> { + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + connection.run_command(&format!("exec ydotool mousemove -w -x {} -y {}", x, y))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} + +#[derive(Debug)] +pub enum MouseButton { + Left, + Right, + Middle, + Side, + Extra, + Forward, + Back, + Task, +} + +impl MouseButton { + fn to_base_value(&self) -> u8 { + match self { + MouseButton::Left => 0x00, + MouseButton::Right => 0x01, + MouseButton::Middle => 0x02, + MouseButton::Side => 0x03, + MouseButton::Extra => 0x04, + MouseButton::Forward => 0x05, + MouseButton::Back => 0x06, + MouseButton::Task => 0x07, + } + } + + fn from_str(s: &str) -> Fallible { + match s.to_uppercase().as_str() { + "MIDDLE" => Ok(MouseButton::Middle), + "SIDE" => Ok(MouseButton::Side), + "EXTRA" => Ok(MouseButton::Extra), + "FORWARD" => Ok(MouseButton::Forward), + "BACK" => Ok(MouseButton::Back), + "TASK" => Ok(MouseButton::Task), + _ => Err(swayipc::Error::CommandFailed(format!("Invalid mouse button: {}", s))), + } + } + + fn click_value(&self) -> u8 { + self.to_base_value() | 0xC0 // Combines DOWN (0x40) and UP (0x80) + } +} + +pub async fn sway_mouse_click(button: String) -> Fallible<()> { + let mouse_button = MouseButton::from_str(&button)?; + let click_value = mouse_button.click_value(); + + tokio::task::spawn_blocking(move || -> Fallible<()> { + let mut connection = Connection::new()?; + connection.run_command(&format!("exec ydotool click {:#04x}", click_value))?; + Ok(()) + }) + .await + .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? +} \ No newline at end of file diff --git a/src/api/rest_wrapper_v2.rs b/src/api/rest_wrapper_v2.rs index 795992f..f44870a 100644 --- a/src/api/rest_wrapper_v2.rs +++ b/src/api/rest_wrapper_v2.rs @@ -11,6 +11,7 @@ use serde_json::{json, Value}; use utoipa::OpenApi; use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_swagger_ui::SwaggerUi; +use futures::FutureExt; use super::base; @@ -32,11 +33,14 @@ pub fn rest_api_routes(mpv: Mpv) -> Router { .route("/playlist/shuffle", post(shuffle)) .route("/playlist/loop", get(playlist_get_looping)) .route("/playlist/loop", post(playlist_set_looping)) - .route("/sway/command", post(sway_run_command)) + // .route("/sway/command", post(sway_run_command)) .route("/sway/workspace/close", post(sway_close_workspace_handler)) .route("/sway/workspace/change", post(sway_change_workspace_handler)) .route("/sway/workspace/list", get(sway_get_workspace_names_handler)) .route("/sway/browser/launch", post(sway_launch_browser_handler)) + .route("/sway/input/keys", post(sway_input_handler)) + .route("/sway/input/mouse", post(sway_mouse_move_handler)) + .route("/sway/input/scroll", post(sway_mouse_scroll_handler)) .with_state(mpv) } @@ -408,72 +412,71 @@ async fn playlist_set_looping( } -/// Execute a sway command TODO: restrict to switching workspace, etc instead of arbitrary commands -#[derive(serde::Deserialize, utoipa::IntoParams)] -struct SwayCommandArgs { +#[derive(serde::Deserialize, utoipa::ToSchema)] +struct SwayCommandBody { command: String, } -#[utoipa::path( - post, - path = "/sway/command", - params(SwayCommandArgs), - responses( - (status = 200, description = "Success", body = EmptySuccessResponse), - (status = 500, description = "Internal server error", body = ErrorResponse), - ) -)] -async fn sway_run_command(Query(query): Query) -> RestResponse { - base::sway_run_command(query.command).await.map_err(anyhow::Error::new).into() -} +// #[utoipa::path( +// post, +// path = "/sway/command", +// request_body = SwayCommandBody, +// responses( +// (status = 200, description = "Success", body = EmptySuccessResponse), +// (status = 500, description = "Internal server error", body = ErrorResponse), +// ) +// )] +// async fn sway_run_command(Json(body): Json) -> RestResponse { +// base::sway_run_command(body.command).await.map_err(anyhow::Error::new).into() +// } -#[derive(serde::Deserialize, utoipa::IntoParams)] -struct SwayBrowserArgs { +#[derive(serde::Deserialize, utoipa::ToSchema)] +struct SwayBrowserBody { url: String, } + #[utoipa::path( post, - path = "/sway/browser/launch", - params(SwayBrowserArgs), + path = "/sway/browser/launch", + request_body = SwayBrowserBody, responses( (status = 200, description = "Success", body = EmptySuccessResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ) )] -async fn sway_launch_browser_handler(Query(query): Query) -> RestResponse { - base::sway_launch_browser(&query.url).await.map_err(anyhow::Error::new).into() +async fn sway_launch_browser_handler(Json(body): Json) -> RestResponse { + base::sway_launch_browser(&body.url).await.map_err(anyhow::Error::new).into() } -#[derive(serde::Deserialize, utoipa::IntoParams)] -struct SwayWorkspaceArgs { +#[derive(serde::Deserialize, utoipa::ToSchema)] +struct SwayWorkspaceBody { workspace: String, } - #[utoipa::path( post, path = "/sway/workspace/close", - params(SwayWorkspaceArgs), + request_body = SwayWorkspaceBody, responses( (status = 200, description = "Success", body = EmptySuccessResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ) )] -async fn sway_close_workspace_handler(Query(query): Query) -> RestResponse { - base::sway_close_workspace(query.workspace).await.map_err(anyhow::Error::new).into() +async fn sway_close_workspace_handler(Json(body): Json) -> RestResponse { + base::sway_close_workspace(body.workspace).await.map_err(anyhow::Error::new).into() } #[utoipa::path( post, - path = "/sway/workspace/change", - params(SwayWorkspaceArgs), + path = "/sway/workspace/change", + request_body = SwayWorkspaceBody, responses( (status = 200, description = "Success", body = EmptySuccessResponse), (status = 500, description = "Internal server error", body = ErrorResponse), ) )] -async fn sway_change_workspace_handler(Query(query): Query) -> RestResponse { - base::sway_change_workspace(query.workspace).await.map_err(anyhow::Error::new).into() +async fn sway_change_workspace_handler(Json(body): Json) -> RestResponse { + base::sway_change_workspace(body.workspace).await.map_err(anyhow::Error::new).into() } #[utoipa::path( @@ -491,3 +494,78 @@ async fn sway_get_workspace_names_handler() -> RestResponse { .into() } + + + + + + +#[derive(serde::Deserialize, utoipa::ToSchema)] +struct KeyboardInput { + keys: String, +} + + +#[utoipa::path( + post, + path = "/sway/input/keys", + request_body = KeyboardInput, + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_input_handler( + Json(payload): Json +) -> RestResponse { + base::sway_input(payload.keys) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new) + .into() +} + +#[utoipa::path( + post, + path = "/sway/input/mouse", + request_body = MouseMove, + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_mouse_move_handler( + Json(payload): Json +) -> RestResponse { + base::sway_mouse_move(payload.x, payload.y) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new) + .into() +} + +#[utoipa::path( + post, + path = "/sway/input/scroll", + request_body = MouseMove, + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn sway_mouse_scroll_handler( + Json(payload): Json +) -> RestResponse { + base::sway_mouse_scroll(payload.x, payload.y) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new) + .into() +} + + +#[derive(serde::Deserialize, utoipa::ToSchema)] +struct MouseMove { + x: i32, + y: i32, +} \ No newline at end of file diff --git a/src/api/websocket_v1.rs b/src/api/websocket_v1.rs index 1ee2ff2..30c1066 100644 --- a/src/api/websocket_v1.rs +++ b/src/api/websocket_v1.rs @@ -357,7 +357,11 @@ pub enum WSCommand { Shuffle, SetSubtitleTrack { track: Option }, SetLooping { value: bool }, - SwayCommand { command: String }, + // SwayCommand { command: String }, + SwayLaunchBrowser { url: String }, + SwayCloseWorkspace { workspace: String }, + SwayChangeWorkspace { workspace: String }, + SwayGetWorkspaces, } async fn handle_message( @@ -448,9 +452,25 @@ async fn handle_message( .await?; Ok(None) } - WSCommand::SwayCommand { command } => { - base::sway_run_command(command).await?; + // WSCommand::SwayCommand { command } => { + // base::sway_run_command(command).await?; + // Ok(None) + // } + WSCommand::SwayLaunchBrowser { url } => { + base::sway_launch_browser(&url).await?; Ok(None) } + WSCommand::SwayCloseWorkspace { workspace } => { + base::sway_close_workspace(workspace).await?; + Ok(None) + } + WSCommand::SwayChangeWorkspace { workspace } => { + base::sway_change_workspace(workspace).await?; + Ok(None) + } + WSCommand::SwayGetWorkspaces => { + let workspaces = base::sway_get_workspace_names().await?; + Ok(Some(json!(workspaces))) + } } } -- 2.47.2 From f805c7ecd332607a2f2283e98594e3968d8693d0 Mon Sep 17 00:00:00 2001 From: Adrian Gunnar Lauterer Date: Mon, 3 Feb 2025 16:54:38 +0100 Subject: [PATCH 4/6] update input --- README.md | 13 +++++ src/api/base.rs | 23 +++----- src/api/rest_wrapper_v2.rs | 116 ++++++++++++++++++++++--------------- 3 files changed, 92 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 1ed862b..1063755 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,16 @@ See also https://git.pvv.ntnu.no/Grzegorz/grzegorz-clients for frontend alternat ```sh RUST_LOG=greg_ng=trace,mpvipc=trace cargo run -- --mpv-socket-path /tmp/mpv.sock ``` + +Custom api call in curl examples + +LOL with input command. (utilizing ydotools) +```sh +curl -X POST -H "Content-Type: application/json" -d '{"keys": "42:1 38:1 38:0 24:1 24:0 38:1 38:0 42:0"}' http://localhost:8008/api/v2/sway/input/keys +``` + +Launching DEFAULT_BROWSER with url, in --kiosk mode +```sh +curl -X POST -H "Content-Type: application/json" -d '{"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}' http://localhost:8008/api/v2/sway/browser/launch +``` + diff --git a/src/api/base.rs b/src/api/base.rs index c67a38b..5980d0a 100644 --- a/src/api/base.rs +++ b/src/api/base.rs @@ -238,17 +238,11 @@ pub async fn sway_launch_browser(url: &str) -> Fallible<()> { tokio::task::spawn_blocking(move || -> Fallible<()> { let mut connection = Connection::new()?; // connection.run_command(&format!("exec xdg-open {}", url))?; - connection.run_command(&format!("exec firefox --kiosk {}", url))?; //moved to firefox to pin in kiosk mode. potentially add --new-window + // connection.run_command(&format!("exec firefox --kiosk {}", url))?; //moved to firefox to pin in kiosk mode. potentially add --new-window - // let browser_output = std::process::Command::new("xdg-settings") - // .arg("get") - // .arg("default-web-browser") - // .output()?; - // let default_browser = String::from_utf8(browser_output.stdout)? - // .trim() - // .trim_end_matches(".desktop") - // .to_string(); - // connection.run_command(&format!("exec {} --kiosk {}", default_browser, url))?; // set default browser in kiosk mode + //get the DEFAULT_BROWSER env var + let default_browser = std::env::var("DEFAULT_BROWSER").unwrap_or("firefox".to_string()); + connection.run_command(&format!("exec {} --kiosk {}", default_browser, url))?; // set default browser in kiosk mode Ok(()) }) @@ -335,12 +329,13 @@ fn validate_keypress_string(input: &str) -> Fallible { } //to simulate keypresses 42:1 38:1 38:0 24:1 24:0 38:1 38:0 42:0 -> LOL -pub async fn sway_input(keys: String) -> Fallible<()> { +pub async fn input(keys: String) -> Fallible<()> { let validated_input = validate_keypress_string(&keys)?; tokio::task::spawn_blocking(move || -> Fallible<()> { let mut connection = Connection::new()?; connection.run_command(&format!("exec ydotool key {}", validated_input))?; + // instead of running through swaycmf Ok(()) }) .await @@ -348,7 +343,7 @@ pub async fn sway_input(keys: String) -> Fallible<()> { } // simulate mouse movement -pub async fn sway_mouse_move(x: i32, y: i32) -> Fallible<()> { +pub async fn mouse_move(x: i32, y: i32) -> Fallible<()> { tokio::task::spawn_blocking(move || -> Fallible<()> { let mut connection = Connection::new()?; connection.run_command(&format!("exec ydotool mousemove -x {} -y {}", x, y))?; @@ -360,7 +355,7 @@ pub async fn sway_mouse_move(x: i32, y: i32) -> Fallible<()> { //simulate scroll -pub async fn sway_mouse_scroll(x: i32, y: i32) -> Fallible<()> { +pub async fn mouse_scroll(x: i32, y: i32) -> Fallible<()> { tokio::task::spawn_blocking(move || -> Fallible<()> { let mut connection = Connection::new()?; connection.run_command(&format!("exec ydotool mousemove -w -x {} -y {}", x, y))?; @@ -413,7 +408,7 @@ impl MouseButton { } } -pub async fn sway_mouse_click(button: String) -> Fallible<()> { +pub async fn mouse_click(button: String) -> Fallible<()> { let mouse_button = MouseButton::from_str(&button)?; let click_value = mouse_button.click_value(); diff --git a/src/api/rest_wrapper_v2.rs b/src/api/rest_wrapper_v2.rs index f44870a..e0409c4 100644 --- a/src/api/rest_wrapper_v2.rs +++ b/src/api/rest_wrapper_v2.rs @@ -38,9 +38,10 @@ pub fn rest_api_routes(mpv: Mpv) -> Router { .route("/sway/workspace/change", post(sway_change_workspace_handler)) .route("/sway/workspace/list", get(sway_get_workspace_names_handler)) .route("/sway/browser/launch", post(sway_launch_browser_handler)) - .route("/sway/input/keys", post(sway_input_handler)) - .route("/sway/input/mouse", post(sway_mouse_move_handler)) - .route("/sway/input/scroll", post(sway_mouse_scroll_handler)) + .route("/input/keys", post(input_handler)) + .route("/input/mouse", post(mouse_move_handler)) + .route("/input/scroll", post(mouse_scroll_handler)) + .route("/input/click", post(mouse_click_handler)) .with_state(mpv) } @@ -497,9 +498,6 @@ async fn sway_get_workspace_names_handler() -> RestResponse { - - - #[derive(serde::Deserialize, utoipa::ToSchema)] struct KeyboardInput { keys: String, @@ -508,64 +506,90 @@ struct KeyboardInput { #[utoipa::path( post, - path = "/sway/input/keys", + path = "/input/keys", request_body = KeyboardInput, responses( (status = 200, description = "Success", body = Vec), (status = 500, description = "Internal server error", body = ErrorResponse), ) )] -async fn sway_input_handler( +async fn input_handler( Json(payload): Json ) -> RestResponse { - base::sway_input(payload.keys) + base::input(payload.keys) .await .map(|_| json!({})) .map_err(anyhow::Error::new) .into() } -#[utoipa::path( - post, - path = "/sway/input/mouse", - request_body = MouseMove, - responses( - (status = 200, description = "Success", body = Vec), - (status = 500, description = "Internal server error", body = ErrorResponse), - ) -)] -async fn sway_mouse_move_handler( - Json(payload): Json -) -> RestResponse { - base::sway_mouse_move(payload.x, payload.y) - .await - .map(|_| json!({})) - .map_err(anyhow::Error::new) - .into() -} - -#[utoipa::path( - post, - path = "/sway/input/scroll", - request_body = MouseMove, - responses( - (status = 200, description = "Success", body = Vec), - (status = 500, description = "Internal server error", body = ErrorResponse), - ) -)] -async fn sway_mouse_scroll_handler( - Json(payload): Json -) -> RestResponse { - base::sway_mouse_scroll(payload.x, payload.y) - .await - .map(|_| json!({})) - .map_err(anyhow::Error::new) - .into() -} #[derive(serde::Deserialize, utoipa::ToSchema)] struct MouseMove { x: i32, y: i32, +} + +#[utoipa::path( + post, + path = "/input/mouse", + request_body = MouseMove, + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn mouse_move_handler( + Json(payload): Json +) -> RestResponse { + base::mouse_move(payload.x, payload.y) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new) + .into() +} + +#[utoipa::path( + post, + path = "/input/scroll", + request_body = MouseMove, + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn mouse_scroll_handler( + Json(payload): Json +) -> RestResponse { + base::mouse_scroll(payload.x, payload.y) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new) + .into() +} + +//click +#[derive(serde::Deserialize, utoipa::ToSchema)] +struct MouseClick { + button: String +} + +#[utoipa::path( + post, + path = "/input/click", + request_body = MouseClick, + responses( + (status = 200, description = "Success", body = Vec), + (status = 500, description = "Internal server error", body = ErrorResponse), + ) +)] +async fn mouse_click_handler( + Json(payload): Json +) -> RestResponse { + base::mouse_click(payload.button) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new) + .into() } \ No newline at end of file -- 2.47.2 From 33593c817594bc6f1baa18b035c313abba8ae4ec Mon Sep 17 00:00:00 2001 From: Adrian Gunnar Lauterer Date: Sun, 9 Feb 2025 00:08:13 +0100 Subject: [PATCH 5/6] inputs websocket --- src/api/websocket_v1.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/api/websocket_v1.rs b/src/api/websocket_v1.rs index 30c1066..9b23770 100644 --- a/src/api/websocket_v1.rs +++ b/src/api/websocket_v1.rs @@ -362,6 +362,11 @@ pub enum WSCommand { SwayCloseWorkspace { workspace: String }, SwayChangeWorkspace { workspace: String }, SwayGetWorkspaces, + // New input commands + InputKeys { keys: String }, + MouseMove { x: i32, y: i32 }, + MouseScroll { x: i32, y: i32 }, + MouseClick { button: String }, } async fn handle_message( @@ -472,5 +477,33 @@ async fn handle_message( let workspaces = base::sway_get_workspace_names().await?; Ok(Some(json!(workspaces))) } + WSCommand::InputKeys { keys } => { + base::input(keys) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new)?; + Ok(None) + } + WSCommand::MouseMove { x, y } => { + base::mouse_move(x, y) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new)?; + Ok(None) + } + WSCommand::MouseScroll { x, y } => { + base::mouse_scroll(x, y) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new)?; + Ok(None) + } + WSCommand::MouseClick { button } => { + base::mouse_click(button) + .await + .map(|_| json!({})) + .map_err(anyhow::Error::new)?; + Ok(None) + } } } -- 2.47.2 From 613f66abea6a7ace44a23943a19d381c0ff4a77b Mon Sep 17 00:00:00 2001 From: Adrian Gunnar Lauterer Date: Mon, 10 Feb 2025 10:20:13 +0100 Subject: [PATCH 6/6] cleanup --- src/api/base.rs | 84 ++++++++++++++++++-------------------- src/api/rest_wrapper_v2.rs | 21 ++++------ src/api/websocket_v1.rs | 18 ++++---- 3 files changed, 55 insertions(+), 68 deletions(-) diff --git a/src/api/base.rs b/src/api/base.rs index 5980d0a..76dee18 100644 --- a/src/api/base.rs +++ b/src/api/base.rs @@ -171,11 +171,9 @@ pub async fn playlist_set_looping(mpv: Mpv, r#loop: bool) -> anyhow::Result<()> .map_err(|e| e.into()) } -use swayipc::{Connection, Fallible}; - -// pub async fn sway_run_command(command: String) -> Fallible<()> { -// tokio::task::spawn_blocking(move || -> Fallible<()> { -// let mut connection = Connection::new()?; +// pub async fn sway_run_command(command: String) -> anyhow::Result<()> { +// tokio::task::spawn_blocking(move || -> anyhow::Result<()> { +// let mut connection = swayipc::Connection::new()?; // connection.run_command(&command)?; // Ok(()) // }) @@ -184,21 +182,21 @@ use swayipc::{Connection, Fallible}; // } //only to check if workspace exists. -fn get_workspace_names(connection: &mut Connection) -> Fallible> { +fn get_workspace_names(connection: &mut swayipc::Connection) -> anyhow::Result> { let workspaces = connection.get_workspaces()?; Ok(workspaces.iter().map(|w| w.name.clone()).collect()) } -pub async fn sway_get_workspace_names() -> Fallible> { - tokio::task::spawn_blocking(|| -> Fallible> { - let mut connection = Connection::new()?; +pub async fn sway_get_workspace_names() -> anyhow::Result> { + tokio::task::spawn_blocking(|| -> anyhow::Result> { + let mut connection = swayipc::Connection::new()?; get_workspace_names(&mut connection) }) .await .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? } -fn is_valid_workspace(workspace: &str, connection: &mut Connection) -> Fallible { +fn is_valid_workspace(workspace: &str, connection: &mut swayipc::Connection) -> anyhow::Result { let workspace_names = get_workspace_names(connection)?; Ok(workspace_names.contains(&workspace.to_string()) || workspace.parse::() @@ -206,14 +204,12 @@ fn is_valid_workspace(workspace: &str, connection: &mut Connection) -> Fallible< .unwrap_or(false)) } -pub async fn sway_change_workspace(workspace: String) -> Fallible<()> { - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; +pub async fn sway_change_workspace(workspace: String) -> anyhow::Result<()> { + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let mut connection = swayipc::Connection::new()?; if !is_valid_workspace(&workspace, &mut connection)? { - return Err(swayipc::Error::CommandFailed( - "Invalid workspace name. Must be existing workspace or number 1-10".to_string() - )); + anyhow::bail!("Invalid workspace name"); } connection.run_command(&format!("workspace {}", workspace))?; @@ -225,18 +221,18 @@ pub async fn sway_change_workspace(workspace: String) -> Fallible<()> { use url::Url; -pub async fn sway_launch_browser(url: &str) -> Fallible<()> { +pub async fn sway_launch_browser(url: &str) -> anyhow::Result<()> { // Validate URL let url = Url::parse(url) .map_err(|e| swayipc::Error::CommandFailed(format!("Invalid URL: {}", e)))?; // Ensure URL scheme is http or https if url.scheme() != "http" && url.scheme() != "https" { - return Err(swayipc::Error::CommandFailed("URL must use http or https protocol".into())); - } + anyhow::bail!("URL must use http or https protocol"); + } - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let mut connection = swayipc::Connection::new()?; // connection.run_command(&format!("exec xdg-open {}", url))?; // connection.run_command(&format!("exec firefox --kiosk {}", url))?; //moved to firefox to pin in kiosk mode. potentially add --new-window @@ -250,15 +246,13 @@ pub async fn sway_launch_browser(url: &str) -> Fallible<()> { .map_err(|e| swayipc::Error::CommandFailed(e.to_string()))? } -pub async fn sway_close_workspace(workspace: String) -> Fallible<()> { - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; +pub async fn sway_close_workspace(workspace: String) -> anyhow::Result<()> { + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let mut connection = swayipc::Connection::new()?; // Validate workspace exists if !is_valid_workspace(&workspace, &mut connection)? { - return Err(swayipc::Error::CommandFailed( - "Invalid workspace name".to_string() - )); + anyhow::bail!("Invalid workspace name"); } // // Get workspace tree and find all nodes in target workspace @@ -316,24 +310,22 @@ lazy_static! { static ref CLEANUP_PATTERN: Regex = Regex::new(r"[^0-9: \t]").unwrap(); } -fn validate_keypress_string(input: &str) -> Fallible { +fn validate_keypress_string(input: &str) -> anyhow::Result { let cleaned = CLEANUP_PATTERN.replace_all(input, "").to_string(); let cleaned = cleaned.trim(); if !KEYPRESS_PATTERN.is_match(cleaned) { - return Err(swayipc::Error::CommandFailed( - "Invalid keypress format. Expected 'number:1 number:0'".into() - )); + anyhow::bail!("Invalid keypress string"); } Ok(cleaned.to_string()) } //to simulate keypresses 42:1 38:1 38:0 24:1 24:0 38:1 38:0 42:0 -> LOL -pub async fn input(keys: String) -> Fallible<()> { +pub async fn input(keys: String) -> anyhow::Result<()> { let validated_input = validate_keypress_string(&keys)?; - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let mut connection = swayipc::Connection::new()?; connection.run_command(&format!("exec ydotool key {}", validated_input))?; // instead of running through swaycmf Ok(()) @@ -343,9 +335,9 @@ pub async fn input(keys: String) -> Fallible<()> { } // simulate mouse movement -pub async fn mouse_move(x: i32, y: i32) -> Fallible<()> { - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; +pub async fn mouse_move(x: i32, y: i32) -> anyhow::Result<()> { + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let mut connection = swayipc::Connection::new()?; connection.run_command(&format!("exec ydotool mousemove -x {} -y {}", x, y))?; Ok(()) }) @@ -355,9 +347,9 @@ pub async fn mouse_move(x: i32, y: i32) -> Fallible<()> { //simulate scroll -pub async fn mouse_scroll(x: i32, y: i32) -> Fallible<()> { - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; +pub async fn mouse_scroll(x: i32, y: i32) -> anyhow::Result<()> { + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let mut connection = swayipc::Connection::new()?; connection.run_command(&format!("exec ydotool mousemove -w -x {} -y {}", x, y))?; Ok(()) }) @@ -391,15 +383,17 @@ impl MouseButton { } } - fn from_str(s: &str) -> Fallible { + fn from_str(s: &str) -> anyhow::Result { match s.to_uppercase().as_str() { + "LEFT" => Ok(MouseButton::Left), + "RIGHT" => Ok(MouseButton::Right), "MIDDLE" => Ok(MouseButton::Middle), "SIDE" => Ok(MouseButton::Side), "EXTRA" => Ok(MouseButton::Extra), "FORWARD" => Ok(MouseButton::Forward), "BACK" => Ok(MouseButton::Back), "TASK" => Ok(MouseButton::Task), - _ => Err(swayipc::Error::CommandFailed(format!("Invalid mouse button: {}", s))), + _ => anyhow::bail!("Invalid mouse button"), } } @@ -408,12 +402,12 @@ impl MouseButton { } } -pub async fn mouse_click(button: String) -> Fallible<()> { +pub async fn mouse_click(button: String) -> anyhow::Result<()> { let mouse_button = MouseButton::from_str(&button)?; let click_value = mouse_button.click_value(); - tokio::task::spawn_blocking(move || -> Fallible<()> { - let mut connection = Connection::new()?; + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + let mut connection = swayipc::Connection::new()?; connection.run_command(&format!("exec ydotool click {:#04x}", click_value))?; Ok(()) }) diff --git a/src/api/rest_wrapper_v2.rs b/src/api/rest_wrapper_v2.rs index e0409c4..e7e50ce 100644 --- a/src/api/rest_wrapper_v2.rs +++ b/src/api/rest_wrapper_v2.rs @@ -11,7 +11,7 @@ use serde_json::{json, Value}; use utoipa::OpenApi; use utoipa_axum::{router::OpenApiRouter, routes}; use utoipa_swagger_ui::SwaggerUi; -use futures::FutureExt; +///use futures::FutureExt; use super::base; @@ -413,10 +413,10 @@ async fn playlist_set_looping( } -#[derive(serde::Deserialize, utoipa::ToSchema)] -struct SwayCommandBody { - command: String, -} +// #[derive(serde::Deserialize, utoipa::ToSchema)] +// struct SwayCommandBody { +// command: String, +// } // #[utoipa::path( // post, @@ -446,7 +446,7 @@ struct SwayBrowserBody { ) )] async fn sway_launch_browser_handler(Json(body): Json) -> RestResponse { - base::sway_launch_browser(&body.url).await.map_err(anyhow::Error::new).into() + base::sway_launch_browser(&body.url).await.into() } #[derive(serde::Deserialize, utoipa::ToSchema)] @@ -464,7 +464,7 @@ struct SwayWorkspaceBody { ) )] async fn sway_close_workspace_handler(Json(body): Json) -> RestResponse { - base::sway_close_workspace(body.workspace).await.map_err(anyhow::Error::new).into() + base::sway_close_workspace(body.workspace).await.into() } #[utoipa::path( @@ -477,7 +477,7 @@ async fn sway_close_workspace_handler(Json(body): Json) -> Re ) )] async fn sway_change_workspace_handler(Json(body): Json) -> RestResponse { - base::sway_change_workspace(body.workspace).await.map_err(anyhow::Error::new).into() + base::sway_change_workspace(body.workspace).await.into() } #[utoipa::path( @@ -491,7 +491,6 @@ async fn sway_change_workspace_handler(Json(body): Json) -> R async fn sway_get_workspace_names_handler() -> RestResponse { base::sway_get_workspace_names().await .map(|workspaces| json!(workspaces)) - .map_err(anyhow::Error::new) .into() } @@ -519,7 +518,6 @@ async fn input_handler( base::input(payload.keys) .await .map(|_| json!({})) - .map_err(anyhow::Error::new) .into() } @@ -546,7 +544,6 @@ async fn mouse_move_handler( base::mouse_move(payload.x, payload.y) .await .map(|_| json!({})) - .map_err(anyhow::Error::new) .into() } @@ -565,7 +562,6 @@ async fn mouse_scroll_handler( base::mouse_scroll(payload.x, payload.y) .await .map(|_| json!({})) - .map_err(anyhow::Error::new) .into() } @@ -590,6 +586,5 @@ async fn mouse_click_handler( base::mouse_click(payload.button) .await .map(|_| json!({})) - .map_err(anyhow::Error::new) .into() } \ No newline at end of file diff --git a/src/api/websocket_v1.rs b/src/api/websocket_v1.rs index 9b23770..eba6f55 100644 --- a/src/api/websocket_v1.rs +++ b/src/api/websocket_v1.rs @@ -481,29 +481,27 @@ async fn handle_message( base::input(keys) .await .map(|_| json!({})) - .map_err(anyhow::Error::new)?; + .context("Failed to execute input command")?; Ok(None) } WSCommand::MouseMove { x, y } => { - base::mouse_move(x, y) + let _ = base::mouse_move(x, y) .await - .map(|_| json!({})) - .map_err(anyhow::Error::new)?; + .map(|_| json!({})); Ok(None) } WSCommand::MouseScroll { x, y } => { - base::mouse_scroll(x, y) + let _ = base::mouse_scroll(x, y) .await - .map(|_| json!({})) - .map_err(anyhow::Error::new)?; + .map(|_| json!({})); Ok(None) } WSCommand::MouseClick { button } => { - base::mouse_click(button) + let _ = base::mouse_click(button) .await - .map(|_| json!({})) - .map_err(anyhow::Error::new)?; + .map(|_| json!({})); Ok(None) } } } + -- 2.47.2