sway + input #40
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -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,16 +620,20 @@ dependencies = [
|
||||
"clap-verbosity-flag",
|
||||
"env_logger",
|
||||
"futures",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"mpvipc-async",
|
||||
"regex",
|
||||
"sd-notify",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swayipc",
|
||||
"systemd-journal-logger",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
"url",
|
||||
"utoipa",
|
||||
"utoipa-axum",
|
||||
"utoipa-swagger-ui",
|
||||
@@ -986,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"
|
||||
@@ -1501,6 +1511,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"
|
||||
|
||||
@@ -9,6 +9,10 @@ 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"
|
||||
axum = { version = "0.7.7", features = ["macros", "ws"] }
|
||||
clap = { version = "4.4.1", features = ["derive"] }
|
||||
|
||||
13
README.md
13
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)
|
||||
|
oysteikt
commented
It's not really clear to me what this does. Could you expand it a bit? It's not really clear to me what this does. Could you expand it a bit?
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
20
module.nix
20
module.nix
@@ -11,6 +11,10 @@ in
|
||||
mpvPackage = lib.mkPackageOption pkgs "mpv" { };
|
||||
|
||||
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,";
|
||||
|
oysteikt
commented
`mkEnableOption` wraps your string in `Whether to enable ${str}.`. That would leave this as `Whether to enable enable input...`. Also, there's a comma at the end
|
||||
|
||||
enablePipewire = lib.mkEnableOption "pipewire" // { default = true; };
|
||||
|
||||
@@ -160,12 +164,27 @@ in
|
||||
pulse.enable = true;
|
||||
};
|
||||
})
|
||||
(lib.mkIf (cfg.enable && cfg.enableInput) {
|
||||
programs.ydotool = {
|
||||
enable = true;
|
||||
};
|
||||
})
|
||||
(lib.mkIf (cfg.enable && cfg.enableSway) {
|
||||
programs.sway = {
|
||||
enable = true;
|
||||
wrapperFeatures.gtk = true;
|
||||
};
|
||||
|
||||
(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;
|
||||
wlr.enable = true;
|
||||
@@ -176,6 +195,7 @@ in
|
||||
users.greg = {
|
||||
isNormalUser = true;
|
||||
group = "greg";
|
||||
extraGroups [ "audio" "video" "input" "ydotool" ];
|
||||
|
oysteikt
commented
Is ydotool a group? Is ydotool a group?
|
||||
uid = 2000;
|
||||
description = "loud gym bro";
|
||||
};
|
||||
|
||||
@@ -1,6 +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, 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;
|
||||
|
||||
244
src/api/base.rs
244
src/api/base.rs
@@ -170,3 +170,247 @@ pub async fn playlist_set_looping(mpv: Mpv, r#loop: bool) -> anyhow::Result<()>
|
||||
.await
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
|
||||
// pub async fn sway_run_command(command: String) -> anyhow::Result<()> {
|
||||
|
oysteikt
commented
Please remove leftover comments Please remove leftover comments
|
||||
// tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
||||
// let mut connection = swayipc::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 swayipc::Connection) -> anyhow::Result<Vec<String>> {
|
||||
let workspaces = connection.get_workspaces()?;
|
||||
Ok(workspaces.iter().map(|w| w.name.clone()).collect())
|
||||
}
|
||||
|
||||
pub async fn sway_get_workspace_names() -> anyhow::Result<Vec<String>> {
|
||||
tokio::task::spawn_blocking(|| -> anyhow::Result<Vec<String>> {
|
||||
|
oysteikt
commented
None of these seem to be async. Maybe they can just be run as normal, without None of these seem to be async. Maybe they can just be run as normal, without `spawn_blocking`?
|
||||
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 swayipc::Connection) -> anyhow::Result<bool> {
|
||||
let workspace_names = get_workspace_names(connection)?;
|
||||
Ok(workspace_names.contains(&workspace.to_string()) ||
|
||||
workspace.parse::<u32>()
|
||||
.map(|num| num >= 1 && num <= 10)
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
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)? {
|
||||
anyhow::bail!("Invalid workspace name");
|
||||
}
|
||||
|
||||
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) -> 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" {
|
||||
anyhow::bail!("URL must use http or https protocol");
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
//get the DEFAULT_BROWSER env var
|
||||
let default_browser = std::env::var("DEFAULT_BROWSER").unwrap_or("firefox".to_string());
|
||||
|
oysteikt
commented
This kinda feels like a security hole waiting to happen. Could we bake in the executable path during build time instead maybe? This kinda feels like a security hole waiting to happen. Could we bake in the executable path during build time instead maybe?
|
||||
connection.run_command(&format!("exec {} --kiosk {}", default_browser, url))?; // set default browser in kiosk mode
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| swayipc::Error::CommandFailed(e.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn sway_close_workspace(workspace: String) -> anyhow::Result<()> {
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
|
||||
let mut connection = swayipc::Connection::new()?;
|
||||
|
oysteikt
commented
I wonder if it would be better to let these api functions take in a swayipc connection, so we don't have to spawn a new one all the time. Do you know roughly how much overhead this brings? I wonder if it would be better to let these api functions take in a swayipc connection, so we don't have to spawn a new one all the time. Do you know roughly how much overhead this brings?
|
||||
|
||||
// Validate workspace exists
|
||||
if !is_valid_workspace(&workspace, &mut connection)? {
|
||||
anyhow::bail!("Invalid workspace name");
|
||||
}
|
||||
|
||||
// // 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))?;
|
||||
// }
|
||||
// }
|
||||
|
||||
// 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(())
|
||||
})
|
||||
.await
|
||||
.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) -> anyhow::Result<String> {
|
||||
let cleaned = CLEANUP_PATTERN.replace_all(input, "").to_string();
|
||||
let cleaned = cleaned.trim();
|
||||
|
||||
if !KEYPRESS_PATTERN.is_match(cleaned) {
|
||||
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) -> anyhow::Result<()> {
|
||||
let validated_input = validate_keypress_string(&keys)?;
|
||||
|
||||
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(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| swayipc::Error::CommandFailed(e.to_string()))?
|
||||
}
|
||||
|
||||
// simulate mouse movement
|
||||
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(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| swayipc::Error::CommandFailed(e.to_string()))?
|
||||
}
|
||||
|
||||
|
||||
//simulate scroll
|
||||
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(())
|
||||
})
|
||||
.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) -> anyhow::Result<Self> {
|
||||
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),
|
||||
_ => anyhow::bail!("Invalid mouse button"),
|
||||
}
|
||||
}
|
||||
|
||||
fn click_value(&self) -> u8 {
|
||||
self.to_base_value() | 0xC0 // Combines DOWN (0x40) and UP (0x80)
|
||||
}
|
||||
}
|
||||
|
||||
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 || -> anyhow::Result<()> {
|
||||
let mut connection = swayipc::Connection::new()?;
|
||||
connection.run_command(&format!("exec ydotool click {:#04x}", click_value))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| swayipc::Error::CommandFailed(e.to_string()))?
|
||||
}
|
||||
@@ -51,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))
|
||||
|
oysteikt
commented
Remind me to have a look at this Remind me to have a look at this
|
||||
}
|
||||
|
||||
// NOTE: the openapi stuff is very heavily duplicated and introduces
|
||||
|
||||
590
src/api/rest_wrapper_v2.rs
Normal file
590
src/api/rest_wrapper_v2.rs
Normal file
@@ -0,0 +1,590 @@
|
||||
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 futures::FutureExt;
|
||||
|
||||
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))
|
||||
.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)
|
||||
}
|
||||
|
||||
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<Value>);
|
||||
|
||||
impl From<anyhow::Result<Value>> for RestResponse {
|
||||
fn from(result: anyhow::Result<Value>) -> Self {
|
||||
Self(result.map(|value| json!({ "success": true, "error": false, "value": value })))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Result<()>> 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<Mpv>, Query(query): Query<LoadFileArgs>) -> 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<Mpv>) -> 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<Mpv>, Query(query): Query<PlaySetArgs>) -> 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<Mpv>) -> 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<Mpv>, Query(query): Query<VolumeSetArgs>) -> 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<Mpv>) -> RestResponse {
|
||||
base::time_get(mpv).await.into()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::IntoParams)]
|
||||
struct TimeSetArgs {
|
||||
pos: Option<f64>,
|
||||
percent: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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<Mpv>, Query(query): Query<TimeSetArgs>) -> 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<Mpv>) -> 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<Mpv>) -> 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<Mpv>) -> 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<Mpv>,
|
||||
Query(query): Query<PlaylistGotoArgs>,
|
||||
) -> RestResponse {
|
||||
base::playlist_goto(mpv, query.index).await.into()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::IntoParams)]
|
||||
struct PlaylistRemoveOrClearArgs {
|
||||
index: Option<usize>,
|
||||
}
|
||||
|
||||
/// 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<Mpv>,
|
||||
Query(query): Query<PlaylistRemoveOrClearArgs>,
|
||||
) -> 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<Mpv>,
|
||||
Query(query): Query<PlaylistMoveArgs>,
|
||||
) -> 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<Mpv>) -> 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<Mpv>) -> 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<Mpv>,
|
||||
Query(query): Query<PlaylistSetLoopingArgs>,
|
||||
) -> RestResponse {
|
||||
base::playlist_set_looping(mpv, query.r#loop).await.into()
|
||||
}
|
||||
|
||||
|
||||
// #[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
// struct SwayCommandBody {
|
||||
// command: String,
|
||||
// }
|
||||
|
||||
// #[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<SwayCommandBody>) -> RestResponse {
|
||||
// base::sway_run_command(body.command).await.map_err(anyhow::Error::new).into()
|
||||
// }
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
struct SwayBrowserBody {
|
||||
url: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
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(Json(body): Json<SwayBrowserBody>) -> RestResponse {
|
||||
base::sway_launch_browser(&body.url).await.into()
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
struct SwayWorkspaceBody {
|
||||
workspace: String,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/sway/workspace/close",
|
||||
request_body = SwayWorkspaceBody,
|
||||
responses(
|
||||
(status = 200, description = "Success", body = EmptySuccessResponse),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn sway_close_workspace_handler(Json(body): Json<SwayWorkspaceBody>) -> RestResponse {
|
||||
base::sway_close_workspace(body.workspace).await.into()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
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(Json(body): Json<SwayWorkspaceBody>) -> RestResponse {
|
||||
base::sway_change_workspace(body.workspace).await.into()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/sway/workspace/list",
|
||||
responses(
|
||||
(status = 200, description = "Success", body = Vec<String>),
|
||||
(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))
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#[derive(serde::Deserialize, utoipa::ToSchema)]
|
||||
struct KeyboardInput {
|
||||
keys: String,
|
||||
}
|
||||
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/input/keys",
|
||||
request_body = KeyboardInput,
|
||||
responses(
|
||||
(status = 200, description = "Success", body = Vec<String>),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn input_handler(
|
||||
Json(payload): Json<KeyboardInput>
|
||||
) -> RestResponse {
|
||||
base::input(payload.keys)
|
||||
.await
|
||||
.map(|_| json!({}))
|
||||
.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<String>),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn mouse_move_handler(
|
||||
Json(payload): Json<MouseMove>
|
||||
) -> RestResponse {
|
||||
base::mouse_move(payload.x, payload.y)
|
||||
.await
|
||||
.map(|_| json!({}))
|
||||
.into()
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/input/scroll",
|
||||
request_body = MouseMove,
|
||||
responses(
|
||||
(status = 200, description = "Success", body = Vec<String>),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn mouse_scroll_handler(
|
||||
Json(payload): Json<MouseMove>
|
||||
) -> RestResponse {
|
||||
base::mouse_scroll(payload.x, payload.y)
|
||||
.await
|
||||
.map(|_| json!({}))
|
||||
.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<String>),
|
||||
(status = 500, description = "Internal server error", body = ErrorResponse),
|
||||
)
|
||||
)]
|
||||
async fn mouse_click_handler(
|
||||
Json(payload): Json<MouseClick>
|
||||
) -> RestResponse {
|
||||
base::mouse_click(payload.button)
|
||||
.await
|
||||
.map(|_| json!({}))
|
||||
.into()
|
||||
}
|
||||
@@ -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,16 @@ pub enum WSCommand {
|
||||
Shuffle,
|
||||
SetSubtitleTrack { track: Option<usize> },
|
||||
SetLooping { value: bool },
|
||||
// SwayCommand { command: String },
|
||||
SwayLaunchBrowser { url: String },
|
||||
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(
|
||||
@@ -445,5 +457,51 @@ async fn handle_message(
|
||||
.await?;
|
||||
Ok(None)
|
||||
}
|
||||
// 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)))
|
||||
}
|
||||
WSCommand::InputKeys { keys } => {
|
||||
base::input(keys)
|
||||
.await
|
||||
.map(|_| json!({}))
|
||||
.context("Failed to execute input command")?;
|
||||
Ok(None)
|
||||
}
|
||||
WSCommand::MouseMove { x, y } => {
|
||||
let _ = base::mouse_move(x, y)
|
||||
.await
|
||||
.map(|_| json!({}));
|
||||
Ok(None)
|
||||
}
|
||||
WSCommand::MouseScroll { x, y } => {
|
||||
let _ = base::mouse_scroll(x, y)
|
||||
.await
|
||||
.map(|_| json!({}));
|
||||
Ok(None)
|
||||
}
|
||||
WSCommand::MouseClick { button } => {
|
||||
let _ = base::mouse_click(button)
|
||||
.await
|
||||
.map(|_| json!({}));
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<SocketAddr>();
|
||||
|
||||
let listener = match tokio::net::TcpListener::bind(&socket_addr)
|
||||
|
||||
Reference in New Issue
Block a user
See https://github.com/rust-lang-nursery/lazy-static.rs/issues/214