feat: add authenticated IPC and feresectl
This commit is contained in:
1 parent
3d0007ba12
commit
d6acfa89e7
12 files changed
+774
-2
No files matched your search
Generated
+57
@@ -171,6 +171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
|
checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.2",
|
"bitflags 2.13.2",
|
||||||
|
"nix",
|
||||||
"polling",
|
"polling",
|
||||||
"rustix 1.1.5",
|
"rustix 1.1.5",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -416,11 +417,14 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
|||||||
name = "ferese"
|
name = "ferese"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"calloop 0.14.4",
|
||||||
"ferese-animation",
|
"ferese-animation",
|
||||||
"ferese-core",
|
"ferese-core",
|
||||||
|
"ferese-ipc",
|
||||||
"ferese-layout",
|
"ferese-layout",
|
||||||
"libc",
|
"libc",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"smithay",
|
"smithay",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -442,10 +446,26 @@ dependencies = [
|
|||||||
"ferese-layout",
|
"ferese-layout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ferese-ipc"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ferese-layout"
|
name = "ferese-layout"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "feresectl"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"ferese-ipc",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.13"
|
version = "0.1.13"
|
||||||
@@ -636,6 +656,12 @@ dependencies = [
|
|||||||
"windows-sys 0.48.0",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.22.4"
|
version = "0.22.4"
|
||||||
@@ -863,6 +889,18 @@ dependencies = [
|
|||||||
"jni-sys 0.3.1",
|
"jni-sys 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.31.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.13.2",
|
||||||
|
"cfg-if",
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -1418,6 +1456,19 @@ dependencies = [
|
|||||||
"syn 3.0.6",
|
"syn 3.0.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_json"
|
||||||
|
version = "1.0.151"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
|
dependencies = [
|
||||||
|
"itoa",
|
||||||
|
"memchr",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
|
"zmij",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2387,3 +2438,9 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zmij"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"compositor",
|
"compositor",
|
||||||
|
"feresectl",
|
||||||
"crates/ferese-animation",
|
"crates/ferese-animation",
|
||||||
"crates/ferese-core",
|
"crates/ferese-core",
|
||||||
|
"crates/ferese-ipc",
|
||||||
"crates/ferese-layout",
|
"crates/ferese-layout",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
@@ -14,6 +16,7 @@ license = "MIT"
|
|||||||
rust-version = "1.87"
|
rust-version = "1.87"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
calloop = { version = "0.14.4", features = ["signals"] }
|
||||||
libc = "0.2.176"
|
libc = "0.2.176"
|
||||||
smithay = { version = "0.7.0", default-features = false, features = [
|
smithay = { version = "0.7.0", default-features = false, features = [
|
||||||
"backend_drm",
|
"backend_drm",
|
||||||
@@ -29,5 +32,6 @@ smithay = { version = "0.7.0", default-features = false, features = [
|
|||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3.20", features = ["env-filter"] }
|
||||||
serde = { version = "1.0.228", features = ["derive"] }
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
|
serde_json = "1.0.145"
|
||||||
toml = "0.9.7"
|
toml = "0.9.7"
|
||||||
xcursor = "0.3.11"
|
xcursor = "0.3.11"
|
||||||
@@ -121,6 +121,21 @@ fullscreen = false
|
|||||||
Rules are finalized on the first toplevel commit, before its buffer is
|
Rules are finalized on the first toplevel commit, before its buffer is
|
||||||
presented, and are not re-applied when an application later changes its title.
|
presented, and are not re-applied when an application later changes its title.
|
||||||
|
|
||||||
|
`feresectl` controls the running compositor through the same-user socket at
|
||||||
|
`$XDG_RUNTIME_DIR/ferese/control.sock`. For example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p feresectl -- focus left
|
||||||
|
cargo run -p feresectl -- workspace 2
|
||||||
|
cargo run -p feresectl -- get-workspaces
|
||||||
|
```
|
||||||
|
|
||||||
|
The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`,
|
||||||
|
`toggle-floating`, `toggle-fullscreen`, `close`, `get-focused-window`, and
|
||||||
|
`get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The
|
||||||
|
runtime directory and socket are restricted to modes `0700` and `0600`, and the
|
||||||
|
server rejects peers whose effective UID differs from the compositor's.
|
||||||
|
|
||||||
Text-input-v3 is available to applications by default. Input-method-v2 is
|
Text-input-v3 is available to applications by default. Input-method-v2 is
|
||||||
hidden unless the session explicitly opts in, because an input method can
|
hidden unless the session explicitly opts in, because an input method can
|
||||||
request privileged keyboard access. Enable it only when launching a trusted
|
request privileged keyboard access. Enable it only when launching a trusted
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ license.workspace = true
|
|||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
calloop.workspace = true
|
||||||
ferese-animation = { path = "../crates/ferese-animation" }
|
ferese-animation = { path = "../crates/ferese-animation" }
|
||||||
ferese-core = { path = "../crates/ferese-core" }
|
ferese-core = { path = "../crates/ferese-core" }
|
||||||
|
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||||
ferese-layout = { path = "../crates/ferese-layout" }
|
ferese-layout = { path = "../crates/ferese-layout" }
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
smithay.workspace = true
|
smithay.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
|
|||||||
@@ -0,0 +1,392 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
use std::os::unix::fs::{FileTypeExt, MetadataExt, PermissionsExt};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{SyncSender, sync_channel};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use ferese_core::LayoutMode;
|
||||||
|
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||||
|
use ferese_layout::Direction;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use smithay::reexports::calloop::{EventLoop, channel};
|
||||||
|
|
||||||
|
use crate::Ferese;
|
||||||
|
|
||||||
|
const REQUEST_QUEUE_CAPACITY: usize = 128;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct IpcCall {
|
||||||
|
request: Request,
|
||||||
|
response: SyncSender<Response>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct IpcSocketGuard {
|
||||||
|
path: PathBuf,
|
||||||
|
device: u64,
|
||||||
|
inode: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for IpcSocketGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.path.symlink_metadata().is_ok_and(|metadata| {
|
||||||
|
metadata.file_type().is_socket()
|
||||||
|
&& metadata.dev() == self.device
|
||||||
|
&& metadata.ino() == self.inode
|
||||||
|
}) {
|
||||||
|
let _ = fs::remove_file(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn init(
|
||||||
|
event_loop: &mut EventLoop<'static, Ferese>,
|
||||||
|
) -> Result<IpcSocketGuard, Box<dyn std::error::Error>> {
|
||||||
|
let path = socket_path()?;
|
||||||
|
prepare_parent(&path)?;
|
||||||
|
let listener = bind_listener(&path)?;
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))?;
|
||||||
|
let metadata = fs::symlink_metadata(&path)?;
|
||||||
|
let guard = IpcSocketGuard {
|
||||||
|
path: path.clone(),
|
||||||
|
device: metadata.dev(),
|
||||||
|
inode: metadata.ino(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (sender, receiver): (channel::SyncSender<IpcCall>, channel::Channel<IpcCall>) =
|
||||||
|
channel::sync_channel(REQUEST_QUEUE_CAPACITY);
|
||||||
|
event_loop
|
||||||
|
.handle()
|
||||||
|
.insert_source(receiver, |event, _, state| {
|
||||||
|
if let channel::Event::Msg(call) = event {
|
||||||
|
let response = state.handle_ipc_request(call.request);
|
||||||
|
let _ = call.response.send(response);
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("ferese-ipc-listener".to_owned())
|
||||||
|
.spawn(move || accept_connections(listener, sender))?;
|
||||||
|
|
||||||
|
tracing::info!(path = %path.display(), "Ferese IPC is accepting connections");
|
||||||
|
Ok(guard)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept_connections(listener: UnixListener, sender: channel::SyncSender<IpcCall>) {
|
||||||
|
loop {
|
||||||
|
let (stream, _) = match listener.accept() {
|
||||||
|
Ok(connection) => connection,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::error!(%error, "IPC listener stopped");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match peer_uid(&stream) {
|
||||||
|
Ok(uid) if uid == effective_uid() => {}
|
||||||
|
Ok(uid) => {
|
||||||
|
tracing::warn!(uid, "rejected IPC connection from a different user");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "could not authenticate IPC peer");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = sender.clone();
|
||||||
|
if let Err(error) = thread::Builder::new()
|
||||||
|
.name("ferese-ipc-client".to_owned())
|
||||||
|
.spawn(move || serve_connection(stream, sender))
|
||||||
|
{
|
||||||
|
tracing::warn!(%error, "could not start IPC connection worker");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcCall>) {
|
||||||
|
loop {
|
||||||
|
let request = match read_frame(&mut stream) {
|
||||||
|
Ok(request) => request,
|
||||||
|
Err(ferese_ipc::FrameError::Io(error))
|
||||||
|
if matches!(
|
||||||
|
error.kind(),
|
||||||
|
io::ErrorKind::UnexpectedEof
|
||||||
|
| io::ErrorKind::ConnectionReset
|
||||||
|
| io::ErrorKind::BrokenPipe
|
||||||
|
) =>
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "closing invalid IPC connection");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (response, receiver) = sync_channel(1);
|
||||||
|
let call = IpcCall { request, response };
|
||||||
|
if sender.try_send(call).is_err() {
|
||||||
|
tracing::warn!("disconnecting IPC client because the request queue is full");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(response) = receiver.recv() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Err(error) = write_frame(&mut stream, &response) {
|
||||||
|
tracing::debug!(%error, "IPC client disconnected before receiving its response");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ferese {
|
||||||
|
fn handle_ipc_request(&mut self, request: Request) -> Response {
|
||||||
|
if request.version != VERSION {
|
||||||
|
return Response::error(
|
||||||
|
request.id,
|
||||||
|
"unsupported_version",
|
||||||
|
format!("supported version is {VERSION}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if request.kind != "command" {
|
||||||
|
return Response::error(request.id, "invalid_type", "expected type \"command\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
match self.dispatch_ipc_command(&request.command, &request.args) {
|
||||||
|
Ok(result) => Response::success(request.id, result),
|
||||||
|
Err(error) => Response::error(request.id, error.code, error.message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_ipc_command(&mut self, command: &str, args: &Value) -> Result<Value, CommandError> {
|
||||||
|
match command {
|
||||||
|
"focus" => self.focus_direction(direction_arg(args)?),
|
||||||
|
"move" => self.move_direction(direction_arg(args)?),
|
||||||
|
"resize" => self.resize_direction(direction_arg(args)?),
|
||||||
|
"workspace" => self.switch_workspace(workspace_arg(args)?),
|
||||||
|
"move-to-workspace" => self.move_focused_to_workspace(workspace_arg(args)?),
|
||||||
|
"toggle-floating" => self.toggle_focused_floating(),
|
||||||
|
"toggle-fullscreen" => self.toggle_focused_fullscreen(),
|
||||||
|
"close" => self.close_focused_window(),
|
||||||
|
"get-focused-window" => return Ok(self.focused_window_json()),
|
||||||
|
"get-workspaces" => return Ok(self.workspaces_json()),
|
||||||
|
"get-outputs" => return Ok(self.outputs_json()),
|
||||||
|
_ => {
|
||||||
|
return Err(CommandError::new(
|
||||||
|
"unknown_command",
|
||||||
|
format!("unknown command {command:?}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn focused_window_json(&self) -> Value {
|
||||||
|
self.focused_window
|
||||||
|
.map(|window| json!({ "id": window.0 }))
|
||||||
|
.unwrap_or(Value::Null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspaces_json(&self) -> Value {
|
||||||
|
let active = self.workspaces.active_id();
|
||||||
|
let mut workspaces = self
|
||||||
|
.workspaces
|
||||||
|
.iter()
|
||||||
|
.map(|workspace| {
|
||||||
|
let mode = match workspace.layout.mode() {
|
||||||
|
LayoutMode::Scrolling => "scrolling",
|
||||||
|
LayoutMode::Tree => "tree",
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"id": workspace.id.0,
|
||||||
|
"name": workspace.name,
|
||||||
|
"active": workspace.id == active,
|
||||||
|
"layout": mode,
|
||||||
|
"focused_window": workspace.last_focused.map(|window| window.0),
|
||||||
|
"fullscreen_window": workspace.fullscreen.map(|window| window.0),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
workspaces.sort_by_key(|workspace| workspace["id"].as_u64());
|
||||||
|
Value::Array(workspaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn outputs_json(&self) -> Value {
|
||||||
|
let focused = self.output_workspaces.focused_output();
|
||||||
|
let mut outputs = self
|
||||||
|
.space
|
||||||
|
.outputs()
|
||||||
|
.filter_map(|output| {
|
||||||
|
let id = self.output_id(output)?;
|
||||||
|
let geometry = self.space.output_geometry(output)?;
|
||||||
|
let workspace = self.output_workspaces.active_workspace(id)?;
|
||||||
|
Some(json!({
|
||||||
|
"id": id.0,
|
||||||
|
"name": output.name(),
|
||||||
|
"focused": Some(id) == focused,
|
||||||
|
"workspace": workspace.0,
|
||||||
|
"x": geometry.loc.x,
|
||||||
|
"y": geometry.loc.y,
|
||||||
|
"width": geometry.size.w,
|
||||||
|
"height": geometry.size.h,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
outputs.sort_by_key(|output| output["id"].as_u64());
|
||||||
|
Value::Array(outputs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct CommandError {
|
||||||
|
code: &'static str,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandError {
|
||||||
|
fn new(code: &'static str, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn direction_arg(args: &Value) -> Result<Direction, CommandError> {
|
||||||
|
match args.get("direction").and_then(Value::as_str) {
|
||||||
|
Some("left") => Ok(Direction::Left),
|
||||||
|
Some("right") => Ok(Direction::Right),
|
||||||
|
Some("up") => Ok(Direction::Up),
|
||||||
|
Some("down") => Ok(Direction::Down),
|
||||||
|
_ => Err(CommandError::new(
|
||||||
|
"invalid_argument",
|
||||||
|
"direction must be left, right, up, or down",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_arg(args: &Value) -> Result<u32, CommandError> {
|
||||||
|
args.get("index")
|
||||||
|
.and_then(Value::as_u64)
|
||||||
|
.and_then(|index| u32::try_from(index).ok())
|
||||||
|
.filter(|index| *index > 0)
|
||||||
|
.ok_or_else(|| {
|
||||||
|
CommandError::new(
|
||||||
|
"invalid_argument",
|
||||||
|
"index must be a positive 32-bit integer",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn socket_path() -> Result<PathBuf, io::Error> {
|
||||||
|
env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|directory| directory.join("ferese/control.sock"))
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_parent(socket: &Path) -> Result<(), io::Error> {
|
||||||
|
let parent = socket
|
||||||
|
.parent()
|
||||||
|
.expect("the Ferese control socket always has a parent");
|
||||||
|
match fs::symlink_metadata(parent) {
|
||||||
|
Ok(metadata) => {
|
||||||
|
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
format!("{} is not a real directory", parent.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if metadata.uid() != effective_uid() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::PermissionDenied,
|
||||||
|
format!("{} is owned by another user", parent.display()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => fs::create_dir(parent)?,
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::set_permissions(parent, fs::Permissions::from_mode(0o700))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bind_listener(path: &Path) -> Result<UnixListener, io::Error> {
|
||||||
|
match UnixListener::bind(path) {
|
||||||
|
Ok(listener) => Ok(listener),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::AddrInUse => {
|
||||||
|
if UnixStream::connect(path).is_ok() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::AddrInUse,
|
||||||
|
"another Ferese IPC server is already running",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let metadata = fs::symlink_metadata(path)?;
|
||||||
|
if !metadata.file_type().is_socket() || metadata.uid() != effective_uid() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
fs::remove_file(path)?;
|
||||||
|
UnixListener::bind(path)
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peer_uid(stream: &UnixStream) -> Result<u32, io::Error> {
|
||||||
|
let mut credentials = libc::ucred {
|
||||||
|
pid: 0,
|
||||||
|
uid: 0,
|
||||||
|
gid: 0,
|
||||||
|
};
|
||||||
|
let mut length = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||||
|
|
||||||
|
// SAFETY: `credentials` and `length` point to initialized, correctly sized
|
||||||
|
// storage for Linux SO_PEERCRED, and the stream owns a live file descriptor.
|
||||||
|
let result = unsafe {
|
||||||
|
libc::getsockopt(
|
||||||
|
stream.as_raw_fd(),
|
||||||
|
libc::SOL_SOCKET,
|
||||||
|
libc::SO_PEERCRED,
|
||||||
|
(&raw mut credentials).cast(),
|
||||||
|
&raw mut length,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if result == -1 {
|
||||||
|
Err(io::Error::last_os_error())
|
||||||
|
} else {
|
||||||
|
Ok(credentials.uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_uid() -> u32 {
|
||||||
|
// SAFETY: `geteuid` has no preconditions and cannot fail.
|
||||||
|
unsafe { libc::geteuid() }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_direction_arguments() {
|
||||||
|
assert_eq!(
|
||||||
|
direction_arg(&json!({ "direction": "left" })).unwrap(),
|
||||||
|
Direction::Left
|
||||||
|
);
|
||||||
|
assert!(direction_arg(&json!({ "direction": "diagonal" })).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_workspace_arguments() {
|
||||||
|
assert_eq!(workspace_arg(&json!({ "index": 9 })).unwrap(), 9);
|
||||||
|
assert!(workspace_arg(&json!({ "index": 0 })).is_err());
|
||||||
|
assert!(workspace_arg(&json!({ "index": -1 })).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ mod cursor;
|
|||||||
mod grabs;
|
mod grabs;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod input;
|
mod input;
|
||||||
|
mod ipc;
|
||||||
mod state;
|
mod state;
|
||||||
mod window_rules;
|
mod window_rules;
|
||||||
mod winit;
|
mod winit;
|
||||||
|
|
||||||
use std::{error::Error, io, process::Command};
|
use std::{error::Error, io, process::Command};
|
||||||
|
|
||||||
|
use calloop::signals::{Signal, Signals};
|
||||||
use smithay::reexports::{calloop::EventLoop, wayland_server::Display};
|
use smithay::reexports::{calloop::EventLoop, wayland_server::Display};
|
||||||
pub use state::{Ferese, RuntimeConfig};
|
pub use state::{Ferese, RuntimeConfig};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
@@ -29,6 +31,13 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
let bindings = config.bindings(&input_settings)?;
|
let bindings = config.bindings(&input_settings)?;
|
||||||
let window_rules = config.window_rules()?;
|
let window_rules = config.window_rules()?;
|
||||||
let mut event_loop = EventLoop::try_new()?;
|
let mut event_loop = EventLoop::try_new()?;
|
||||||
|
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
|
||||||
|
event_loop
|
||||||
|
.handle()
|
||||||
|
.insert_source(signals, |event, _, state: &mut Ferese| {
|
||||||
|
info!(signal = ?event.signal(), "stopping Ferese");
|
||||||
|
state.loop_signal.stop();
|
||||||
|
})?;
|
||||||
let display = Display::new()?;
|
let display = Display::new()?;
|
||||||
let runtime = RuntimeConfig {
|
let runtime = RuntimeConfig {
|
||||||
layout_mode: config.layout_mode(),
|
layout_mode: config.layout_mode(),
|
||||||
|
|||||||
+10
-2
@@ -112,6 +112,7 @@ pub struct Ferese {
|
|||||||
pub idle_inhibitors: HashMap<WlSurface, usize>,
|
pub idle_inhibitors: HashMap<WlSurface, usize>,
|
||||||
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
pub active_shortcuts_inhibitor: Option<KeyboardShortcutsInhibitor>,
|
||||||
pub direct_backend: Option<crate::backends::direct::DirectBackendState>,
|
pub direct_backend: Option<crate::backends::direct::DirectBackendState>,
|
||||||
|
_ipc_socket: Option<crate::ipc::IpcSocketGuard>,
|
||||||
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
|
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
|
||||||
pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>,
|
pub(crate) pending_screencopies: Vec<crate::handlers::screencopy::PendingScreencopy>,
|
||||||
next_window_id: u64,
|
next_window_id: u64,
|
||||||
@@ -260,6 +261,10 @@ impl Ferese {
|
|||||||
.find_map(|(output, id)| (*id == focused).then_some(output))
|
.find_map(|(output, id)| (*id == focused).then_some(output))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn output_id(&self, output: &Output) -> Option<OutputId> {
|
||||||
|
self.output_ids.get(output).copied()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) {
|
pub(crate) fn focus_output_at(&mut self, position: Point<f64, Logical>) {
|
||||||
let output = self.space.outputs().find_map(|output| {
|
let output = self.space.outputs().find_map(|output| {
|
||||||
let geometry = self.space.output_geometry(output)?;
|
let geometry = self.space.output_geometry(output)?;
|
||||||
@@ -369,7 +374,7 @@ impl Ferese {
|
|||||||
let default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default);
|
let default_cursor = crate::cursor::load_named_cursor(&cursor_theme, CursorIcon::Default);
|
||||||
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
|
let named_cursors = HashMap::from([(CursorIcon::Default, default_cursor)]);
|
||||||
|
|
||||||
Ok(Self {
|
let mut state = Self {
|
||||||
start_time,
|
start_time,
|
||||||
socket_name,
|
socket_name,
|
||||||
display_handle,
|
display_handle,
|
||||||
@@ -405,6 +410,7 @@ impl Ferese {
|
|||||||
idle_inhibitors: HashMap::new(),
|
idle_inhibitors: HashMap::new(),
|
||||||
active_shortcuts_inhibitor: None,
|
active_shortcuts_inhibitor: None,
|
||||||
direct_backend: None,
|
direct_backend: None,
|
||||||
|
_ipc_socket: None,
|
||||||
pending_dmabuf_imports: Vec::new(),
|
pending_dmabuf_imports: Vec::new(),
|
||||||
pending_screencopies: Vec::new(),
|
pending_screencopies: Vec::new(),
|
||||||
next_window_id: 1,
|
next_window_id: 1,
|
||||||
@@ -437,7 +443,9 @@ impl Ferese {
|
|||||||
xdg_foreign_state,
|
xdg_foreign_state,
|
||||||
xdg_shell_state,
|
xdg_shell_state,
|
||||||
xdg_toplevel_icon_manager,
|
xdg_toplevel_icon_manager,
|
||||||
})
|
};
|
||||||
|
state._ipc_socket = Some(crate::ipc::init(event_loop)?);
|
||||||
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn init_wayland_listener(
|
fn init_wayland_listener(
|
||||||
|
|||||||
@@ -445,6 +445,10 @@ impl WorkspaceSet {
|
|||||||
self.workspaces.get_mut(&id)
|
self.workspaces.get_mut(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &Workspace> {
|
||||||
|
self.workspaces.values()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> {
|
pub fn workspace_for_window(&self, window: WindowId) -> Option<WorkspaceId> {
|
||||||
self.window_workspaces.get(&window).copied()
|
self.window_workspaces.get(&window).copied()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "ferese-ipc"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
pub const VERSION: u32 = 1;
|
||||||
|
pub const MAX_PAYLOAD_SIZE: usize = 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Request {
|
||||||
|
pub version: u32,
|
||||||
|
pub id: u64,
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: String,
|
||||||
|
pub command: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct Response {
|
||||||
|
pub version: u32,
|
||||||
|
pub id: u64,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub result: Option<Value>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<ResponseError>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
pub struct ResponseError {
|
||||||
|
pub code: String,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Response {
|
||||||
|
pub fn success(id: u64, result: Value) -> Self {
|
||||||
|
Self {
|
||||||
|
version: VERSION,
|
||||||
|
id,
|
||||||
|
result: Some(result),
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn error(id: u64, code: impl Into<String>, message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
version: VERSION,
|
||||||
|
id,
|
||||||
|
result: None,
|
||||||
|
error: Some(ResponseError {
|
||||||
|
code: code.into(),
|
||||||
|
message: message.into(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FrameError {
|
||||||
|
Io(io::Error),
|
||||||
|
Oversized(usize),
|
||||||
|
InvalidJson(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for FrameError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => error.fmt(formatter),
|
||||||
|
Self::Oversized(size) => write!(
|
||||||
|
formatter,
|
||||||
|
"IPC payload is {size} bytes; maximum is {MAX_PAYLOAD_SIZE}"
|
||||||
|
),
|
||||||
|
Self::InvalidJson(error) => write!(formatter, "invalid IPC JSON: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for FrameError {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => Some(error),
|
||||||
|
Self::InvalidJson(error) => Some(error),
|
||||||
|
Self::Oversized(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<io::Error> for FrameError {
|
||||||
|
fn from(error: io::Error) -> Self {
|
||||||
|
Self::Io(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_frame<T: for<'de> Deserialize<'de>>(reader: &mut impl Read) -> Result<T, FrameError> {
|
||||||
|
let mut header = [0_u8; 4];
|
||||||
|
reader.read_exact(&mut header)?;
|
||||||
|
let size = u32::from_be_bytes(header) as usize;
|
||||||
|
if size > MAX_PAYLOAD_SIZE {
|
||||||
|
return Err(FrameError::Oversized(size));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut payload = vec![0; size];
|
||||||
|
reader.read_exact(&mut payload)?;
|
||||||
|
serde_json::from_slice(&payload).map_err(FrameError::InvalidJson)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_frame<T: Serialize>(writer: &mut impl Write, value: &T) -> Result<(), FrameError> {
|
||||||
|
let payload = serde_json::to_vec(value).map_err(FrameError::InvalidJson)?;
|
||||||
|
if payload.len() > MAX_PAYLOAD_SIZE {
|
||||||
|
return Err(FrameError::Oversized(payload.len()));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.write_all(&(payload.len() as u32).to_be_bytes())?;
|
||||||
|
writer.write_all(&payload)?;
|
||||||
|
writer.flush()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn framed_json_round_trips() {
|
||||||
|
let request = Request {
|
||||||
|
version: VERSION,
|
||||||
|
id: 42,
|
||||||
|
kind: "command".to_owned(),
|
||||||
|
command: "focus".to_owned(),
|
||||||
|
args: serde_json::json!({ "direction": "left" }),
|
||||||
|
};
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
write_frame(&mut bytes, &request).unwrap();
|
||||||
|
let decoded: Request = read_frame(&mut bytes.as_slice()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.version, VERSION);
|
||||||
|
assert_eq!(decoded.id, 42);
|
||||||
|
assert_eq!(decoded.command, "focus");
|
||||||
|
assert_eq!(decoded.args["direction"], "left");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_frames_before_allocating_payload() {
|
||||||
|
let size = (MAX_PAYLOAD_SIZE as u32 + 1).to_be_bytes();
|
||||||
|
let error = read_frame::<Request>(&mut size.as_slice()).unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, FrameError::Oversized(_)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "feresectl"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ferese-ipc = { path = "../crates/ferese-ipc" }
|
||||||
|
serde_json.workspace = true
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::error::Error;
|
||||||
|
use std::io;
|
||||||
|
use std::os::unix::net::UnixStream;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let (command, args) = parse_args(env::args().skip(1))?;
|
||||||
|
let request = Request {
|
||||||
|
version: VERSION,
|
||||||
|
id: 1,
|
||||||
|
kind: "command".to_owned(),
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
let mut stream = UnixStream::connect(socket_path()?)?;
|
||||||
|
|
||||||
|
write_frame(&mut stream, &request)?;
|
||||||
|
let response: Response = read_frame(&mut stream)?;
|
||||||
|
if let Some(error) = response.error {
|
||||||
|
return Err(format!("{}: {}", error.code, error.message).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"{}",
|
||||||
|
serde_json::to_string_pretty(&response.result.unwrap_or(Value::Null))?
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_args(args: impl IntoIterator<Item = String>) -> Result<(String, Value), String> {
|
||||||
|
let mut args = args.into_iter();
|
||||||
|
let command = args.next().ok_or_else(usage)?;
|
||||||
|
let positional = args.collect::<Vec<_>>();
|
||||||
|
let payload = match command.as_str() {
|
||||||
|
"focus" | "move" | "resize" => {
|
||||||
|
exactly_one(&command, &positional, "direction")?;
|
||||||
|
json!({ "direction": positional[0] })
|
||||||
|
}
|
||||||
|
"workspace" | "move-to-workspace" => {
|
||||||
|
exactly_one(&command, &positional, "index")?;
|
||||||
|
let index = positional[0]
|
||||||
|
.parse::<u32>()
|
||||||
|
.map_err(|_| format!("{} requires a positive workspace index", command))?;
|
||||||
|
json!({ "index": index })
|
||||||
|
}
|
||||||
|
"toggle-floating" | "toggle-fullscreen" | "close" | "get-focused-window"
|
||||||
|
| "get-workspaces" | "get-outputs" => {
|
||||||
|
if !positional.is_empty() {
|
||||||
|
return Err(format!("{command} does not accept arguments"));
|
||||||
|
}
|
||||||
|
json!({})
|
||||||
|
}
|
||||||
|
_ => return Err(format!("unknown command {command:?}\n{}", usage())),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((command, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exactly_one(command: &str, args: &[String], name: &str) -> Result<(), String> {
|
||||||
|
if args.len() == 1 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{command} requires exactly one {name}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn socket_path() -> Result<PathBuf, io::Error> {
|
||||||
|
env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.map(|directory| directory.join("ferese/control.sock"))
|
||||||
|
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "XDG_RUNTIME_DIR is not set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> String {
|
||||||
|
"usage: feresectl <focus|move|resize> <direction>\n feresectl <workspace|move-to-workspace> <index>\n feresectl <toggle-floating|toggle-fullscreen|close|get-focused-window|get-workspaces|get-outputs>".to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_direction_and_workspace_commands() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(["focus".to_owned(), "left".to_owned()])
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
json!({ "direction": "left" })
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(["workspace".to_owned(), "7".to_owned()])
|
||||||
|
.unwrap()
|
||||||
|
.1,
|
||||||
|
json!({ "index": 7 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unknown_or_missing_arguments() {
|
||||||
|
assert!(parse_args(["focus".to_owned()]).is_err());
|
||||||
|
assert!(parse_args(["reload-config".to_owned()]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user