diff --git a/server/lib/systant/command_executor.ex b/server/lib/systant/command_executor.ex index 094ae1b..3cf9098 100644 --- a/server/lib/systant/command_executor.ex +++ b/server/lib/systant/command_executor.ex @@ -42,7 +42,8 @@ defmodule Systant.CommandExecutor do description: cmd["description"], trigger: cmd["trigger"], allowed_params: cmd["allowed_params"] || [], - timeout: cmd["timeout"] || 10 + timeout: cmd["timeout"] || 10, + detached: cmd["detached"] || false } end) else @@ -115,8 +116,8 @@ defmodule Systant.CommandExecutor do # If running as root and this looks like a Wayland command, wrap with sudo final_command_with_user = maybe_wrap_with_sudo(final_command_string, command_config) - # Always wrap in sh -c for shell features (variables, pipes, etc.) - {:ok, ["sh", "-c", final_command_with_user]} + # Return the command string directly - we'll handle shell execution in execute_regular_command + {:ok, final_command_with_user} else {:error, "Command configuration must be a string"} end @@ -172,18 +173,6 @@ defmodule Systant.CommandExecutor do end) end - defp substitute_parameters(command_parts, params) do - param_map = build_param_map(params) - - Enum.map(command_parts, fn part -> - case part do - "$" <> var_name -> - Map.get(param_map, var_name, part) - _ -> - part - end - end) - end defp build_param_map(params) do # For now, use simple mapping: first param is $SERVICE, $PATH, $PROCESS, $HOST, etc. @@ -241,66 +230,233 @@ defmodule Systant.CommandExecutor do end defp execute_system_command(final_command, command_config, parsed_command) do + is_detached = command_config["detached"] || false timeout = (command_config["timeout"] || 10) * 1000 # Convert to milliseconds - start_time = System.monotonic_time(:millisecond) # Build environment for command execution env = build_command_environment() - Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)") + if is_detached do + Logger.info("Executing detached command: #{inspect(final_command)}") + else + Logger.info("Executing system command: #{inspect(final_command)} (timeout: #{timeout}ms)") + end + Logger.debug("Environment PATH: #{Map.get(env, "PATH")}") Logger.debug("Environment USER: #{Map.get(env, "USER")}") Logger.debug("Environment HOME: #{Map.get(env, "HOME")}") Logger.debug("Environment XDG_RUNTIME_DIR: #{Map.get(env, "XDG_RUNTIME_DIR")}") + if is_detached do + # For detached processes, spawn and immediately return success + execute_detached_command(final_command, env, parsed_command) + else + # For regular processes, wait for completion with timeout + execute_regular_command(final_command, env, timeout, parsed_command) + end + end + + defp execute_detached_command(command_string, env, parsed_command) do try do - task = Task.async(fn -> - System.cmd(List.first(final_command), Enum.drop(final_command, 1), - stderr_to_stdout: true, env: env) - end) + # Use spawn to start process without waiting + port = Port.open({:spawn_executable, "/bin/sh"}, [ + :binary, + :exit_status, + args: ["-c", command_string], + env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) + ]) - case Task.await(task, timeout) do - {output, 0} -> - execution_time = System.monotonic_time(:millisecond) - start_time - Logger.info("Command completed successfully in #{execution_time}ms") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "success", - output: String.trim(output), - execution_time: execution_time / 1000.0, - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} - - {output, exit_code} -> - execution_time = System.monotonic_time(:millisecond) - start_time - Logger.warning("Command failed with exit code #{exit_code} in #{execution_time}ms") - - {:ok, %{ - request_id: parsed_command.request_id, - command: parsed_command.trigger, - status: "error", - output: String.trim(output), - error: "Command exited with code #{exit_code}", - execution_time: execution_time / 1000.0, - timestamp: DateTime.utc_now() |> DateTime.to_iso8601() - }} - end + # Close the port immediately to detach + Port.close(port) + + Logger.info("Detached command started successfully") + + {:ok, %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "success", + output: "Command started in detached mode", + detached: true, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} rescue error -> - execution_time = System.monotonic_time(:millisecond) - start_time - Logger.error("Command execution failed: #{inspect(error)}") + Logger.error("Failed to start detached command: #{inspect(error)}") {:ok, %{ request_id: parsed_command.request_id, command: parsed_command.trigger, status: "error", output: "", - error: "Execution failed: #{inspect(error)}", + error: "Failed to start detached command: #{inspect(error)}", + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} + end + end + + defp execute_regular_command(command_string, env, timeout, parsed_command) do + start_time = System.monotonic_time(:millisecond) + + # Wrap the command with PID tracking + wrapper_script = """ + echo "SYSTANT_PID:$$" + exec #{command_string} + """ + + port = Port.open({:spawn_executable, "/bin/sh"}, [ + :binary, + :exit_status, + :stderr_to_stdout, + args: ["-c", wrapper_script], + env: Enum.map(env, fn {k, v} -> {String.to_charlist(k), String.to_charlist(v)} end) + ]) + + # Set up monitoring + ref = Port.monitor(port) + + # Collect output with PID extraction + output = collect_port_output_with_pid(port, ref, timeout, "", nil) + + case output do + {:ok, data, exit_status, _pid} -> + execution_time = System.monotonic_time(:millisecond) - start_time + + case exit_status do + 0 -> + Logger.info("Command completed successfully in #{execution_time}ms") + + {:ok, %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "success", + output: String.trim(data), + execution_time: execution_time / 1000.0, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} + + code -> + Logger.warning("Command failed with exit code #{code} in #{execution_time}ms") + + {:ok, %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "error", + output: String.trim(data), + error: "Command exited with code #{code}", + execution_time: execution_time / 1000.0, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} + end + + {:timeout, partial_output, pid} -> + execution_time = System.monotonic_time(:millisecond) - start_time + + # First, close the port to prevent more data + try do + Port.close(port) + rescue + _ -> :ok + end + + # Kill the process group if we have a PID + if pid do + kill_process_group(pid) + end + + # Flush any remaining port messages to prevent them from going to other processes + flush_port_messages(port) + + Logger.error("Command timed out after #{timeout}ms and was terminated") + + {:ok, %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "error", + output: String.trim(partial_output), + error: "Command timed out after #{timeout / 1000} seconds and was terminated", execution_time: execution_time / 1000.0, timestamp: DateTime.utc_now() |> DateTime.to_iso8601() }} + + {:error, reason} -> + execution_time = System.monotonic_time(:millisecond) - start_time + Logger.error("Command execution failed: #{inspect(reason)}") + + {:ok, %{ + request_id: parsed_command.request_id, + command: parsed_command.trigger, + status: "error", + output: "", + error: "Execution failed: #{inspect(reason)}", + execution_time: execution_time / 1000.0, + timestamp: DateTime.utc_now() |> DateTime.to_iso8601() + }} + end + end + + + defp kill_process_group(pid) when is_integer(pid) do + # Kill the entire process group + Logger.info("Killing process group for PID #{pid}") + System.cmd("kill", ["-TERM", "-#{pid}"], stderr_to_stdout: true) + + # Give it a moment to terminate gracefully + Process.sleep(100) + + # Force kill if still alive + case System.cmd("kill", ["-0", "#{pid}"], stderr_to_stdout: true) do + {_, 0} -> + Logger.warning("Process #{pid} still alive, sending SIGKILL") + System.cmd("kill", ["-KILL", "-#{pid}"], stderr_to_stdout: true) + _ -> + :ok + end + end + defp kill_process_group(_), do: :ok + + defp flush_port_messages(port) do + receive do + {^port, _} -> + # Recursively flush more messages + flush_port_messages(port) + after + 0 -> + # No more messages + :ok + end + end + + defp collect_port_output_with_pid(port, ref, timeout, acc, pid) do + receive do + {^port, {:data, data}} -> + # Extract PID if we see it in the output + {new_pid, cleaned_data} = extract_pid(data, pid) + collect_port_output_with_pid(port, ref, timeout, acc <> cleaned_data, new_pid) + + {^port, {:exit_status, status}} -> + # Demonitor to avoid receiving DOWN message + Port.demonitor(ref, [:flush]) + {:ok, acc, status, pid} + + {:DOWN, ^ref, :port, ^port, reason} -> + {:error, reason} + + after + timeout -> + # Demonitor to avoid receiving DOWN message after timeout + Port.demonitor(ref, [:flush]) + {:timeout, acc, pid} + end + end + + defp extract_pid(data, current_pid) do + case Regex.run(~r/SYSTANT_PID:(\d+)\n/, data) do + [full_match, pid_str] -> + pid = String.to_integer(pid_str) + cleaned = String.replace(data, full_match, "") + {pid, cleaned} + nil -> + {current_pid, data} end end diff --git a/server/systant.toml b/server/systant.toml index 5d37be2..2155287 100644 --- a/server/systant.toml +++ b/server/systant.toml @@ -120,11 +120,24 @@ log_all_commands = true [[commands.available]] name = "screenshot" trigger = "screenshot" -description = "Take a screenshot and save to home directory" -command = "grim /home/ryan/screenshot-$(date +%Y%m%d-%H%M%S).png" +description = "Take a screenshot and save to ~/Pictures/Screenshots" +command = "grim /home/ryan/Pictures/Screenshots/screenshot-$(date +%Y%m%d-%H%M%S).png" [[commands.available]] name = "lock_screen" trigger = "lock" description = "Lock the screen immediately" command = "hyprctl dispatch exec swaylock" + +[[commands.available]] +name = "android_mirror_start" +trigger = "android_mirror_start" +description = "Start Android screen mirroring" +command = "scrcpy --tcpip=luna.home --window-title luna.scrcpy -S --audio-source=playback --audio-dup" +detached = true + +[[commands.available]] +name = "android_mirror_stop" +trigger = "android_mirror_stop" +description = "Stop Android screen mirroring" +command = "hyprctl clients -j | jq '. | map(select(.title == \"luna.scrcpy\")).[0].pid' | xargs kill" diff --git a/server/systant.toml.example b/server/systant.toml.example index 5e59634..e203cb3 100644 --- a/server/systant.toml.example +++ b/server/systant.toml.example @@ -102,6 +102,14 @@ command = "ping" allowed_params = ["-c", "4", "8.8.8.8", "google.com", "1.1.1.1"] description = "Network connectivity test" +# Example of a detached command for long-running processes +[[commands.available]] +trigger = "start_app" +command = "firefox" +detached = true # Don't wait for the process to exit, just launch it +timeout = 5 # Timeout only applies to launching, not running +description = "Start Firefox browser (detached)" + [logging] level = "info" # debug, info, warn, error log_config_changes = true