mirror of
https://github.com/ShadowBlip/OpenGamepadUI.git
synced 2025-12-19 08:25:55 +01:00
feat(URI): add mime type handler for opening ogui:// uri's
This commit is contained in:
@@ -18,3 +18,4 @@ ResourceProcessor = "res://addons/core/assets/icons/clarity--process-on-vm-line.
|
||||
ResourceRegistry = "res://addons/core/assets/icons/carbon--cloud-registry.svg"
|
||||
UPowerInstance = "res://assets/editor-icons/material-symbols--battery-profile-sharp.svg"
|
||||
Vdf = "res://addons/core/assets/icons/material-symbols-light--valve.svg"
|
||||
FifoReader = "res://assets/editor-icons/fluent--pipeline-20-filled.svg"
|
||||
|
||||
1
assets/editor-icons/fluent--pipeline-20-filled.svg
Normal file
1
assets/editor-icons/fluent--pipeline-20-filled.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 20 20"><path fill="#fff" d="M2 5.5a1.5 1.5 0 0 1 3 0v9a1.5 1.5 0 0 1-3-.001zm13 0v9a1.5 1.5 0 0 0 3 0v-9a1.5 1.5 0 0 0-3 0M14 14V6H6v8z"/></svg>
|
||||
|
After Width: | Height: | Size: 220 B |
37
assets/editor-icons/fluent--pipeline-20-filled.svg.import
Normal file
37
assets/editor-icons/fluent--pipeline-20-filled.svg.import
Normal file
@@ -0,0 +1,37 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cgl7d0purhfh2"
|
||||
path="res://.godot/imported/fluent--pipeline-20-filled.svg-6cd997c93f64610de8c42827f96b306b.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/editor-icons/fluent--pipeline-20-filled.svg"
|
||||
dest_files=["res://.godot/imported/fluent--pipeline-20-filled.svg-6cd997c93f64610de8c42827f96b306b.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
127
core/systems/ipc/pipe_manager.gd
Normal file
127
core/systems/ipc/pipe_manager.gd
Normal file
@@ -0,0 +1,127 @@
|
||||
@icon("res://assets/editor-icons/fluent--pipeline-20-filled.svg")
|
||||
extends Node
|
||||
class_name PipeManager
|
||||
|
||||
## Class for managing control messages sent through a named pipe
|
||||
##
|
||||
## The [PipeManager] creates a named pipe in `/run/user/<uid>/opengamepadui`
|
||||
## that can be used as a communication mechanism to send OpenGamepadUI commands
|
||||
## from another process. This is mostly done to handle custom `ogui://` URIs
|
||||
## which can be used to react in different ways.
|
||||
|
||||
const RUN_FOLDER: String = "/run/user/{}/opengamepadui"
|
||||
const URI_PREFIX: String = "ogui://"
|
||||
|
||||
signal line_written(line: String)
|
||||
|
||||
var launch_manager := load("res://core/global/launch_manager.tres") as LaunchManager
|
||||
var library_manager := load("res://core/global/library_manager.tres") as LibraryManager
|
||||
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
||||
|
||||
var pipe: FifoReader
|
||||
var pipe_path: String
|
||||
var logger := Log.get_logger("PipeManager", Log.LEVEL.DEBUG)
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
# Ensure the run directory exists
|
||||
var run_path := get_run_path()
|
||||
DirAccess.make_dir_recursive_absolute(run_path)
|
||||
|
||||
# Create a unix named pipe
|
||||
pipe_path = get_pipe_path()
|
||||
if pipe_path.is_empty():
|
||||
logger.error("Failed to get pipe path!")
|
||||
return
|
||||
logger.info("Opening pipe:", pipe_path)
|
||||
pipe = FifoReader.new()
|
||||
if pipe.open(pipe_path) != OK:
|
||||
return
|
||||
pipe.line_written.connect(_on_line_written)
|
||||
add_child(pipe)
|
||||
|
||||
|
||||
## Returns the path to the named pipe (e.g. /run/user/1000/opengamepadui/opengamepadui-0)
|
||||
func get_pipe_path() -> String:
|
||||
var run_path := get_run_path()
|
||||
if run_path.is_empty():
|
||||
return ""
|
||||
var path := "/".join([run_path, "opengamepadui-0"])
|
||||
return path
|
||||
|
||||
|
||||
## Returns the run path for the current user (e.g. /run/user/1000/opengamepadui)
|
||||
func get_run_path() -> String:
|
||||
var uid := get_uid()
|
||||
if uid < 0:
|
||||
return ""
|
||||
var run_folder := RUN_FOLDER.format([uid], "{}")
|
||||
|
||||
return run_folder
|
||||
|
||||
|
||||
## Returns the current user id (e.g. 1000)
|
||||
func get_uid() -> int:
|
||||
var output: Array = []
|
||||
if OS.execute("id", ["-u"], output) != OK:
|
||||
return -1
|
||||
if output.is_empty():
|
||||
return -1
|
||||
var data := output[0] as String
|
||||
var uid := data.to_int()
|
||||
|
||||
return uid
|
||||
|
||||
|
||||
func _on_line_written(line: String) -> void:
|
||||
if line.is_empty():
|
||||
return
|
||||
logger.debug("Received piped message:", line)
|
||||
line_written.emit(line)
|
||||
|
||||
# Check to see if the message is an ogui URI
|
||||
if line.begins_with(URI_PREFIX):
|
||||
_handle_uri_request(line)
|
||||
|
||||
|
||||
func _handle_uri_request(uri: String) -> void:
|
||||
var path := uri.replace(URI_PREFIX, "")
|
||||
var parts: Array[String]
|
||||
parts.assign(path.split("/"))
|
||||
if parts.is_empty():
|
||||
return
|
||||
var cmd := parts.pop_front() as String
|
||||
logger.info("Received URI command:", cmd)
|
||||
match cmd:
|
||||
"run":
|
||||
_handle_run_cmd(parts)
|
||||
|
||||
|
||||
func _handle_run_cmd(args: Array[String]) -> void:
|
||||
if args.is_empty():
|
||||
logger.debug("No game name was found in URI")
|
||||
return
|
||||
var game_name := args[0]
|
||||
var library_item := library_manager.get_app_by_name(game_name)
|
||||
if not library_item:
|
||||
logger.warn("Unable to find game with name:", game_name)
|
||||
return
|
||||
|
||||
# Select the provider
|
||||
var launch_item := library_item.launch_items[0] as LibraryLaunchItem
|
||||
var section := "game.{0}".format([library_item.name.to_lower()])
|
||||
var provider_id = settings_manager.get_value(section, "provider", "")
|
||||
if provider_id != "":
|
||||
var p := library_item.get_launch_item(provider_id)
|
||||
if p != null:
|
||||
launch_item = p
|
||||
|
||||
# Start the game
|
||||
logger.info("Starting game:", game_name)
|
||||
launch_manager.launch(launch_item)
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if not pipe:
|
||||
return
|
||||
pipe.close()
|
||||
1
core/systems/ipc/pipe_manager.gd.uid
Normal file
1
core/systems/ipc/pipe_manager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ompm0veql0ng
|
||||
25
core/systems/ipc/pipe_manager_test.gd
Normal file
25
core/systems/ipc/pipe_manager_test.gd
Normal file
@@ -0,0 +1,25 @@
|
||||
extends GutTest
|
||||
|
||||
|
||||
func test_pipe() -> void:
|
||||
var pipe_manager := PipeManager.new()
|
||||
add_child_autoqfree(pipe_manager)
|
||||
watch_signals(pipe_manager)
|
||||
gut.p("Got manager: " + str(pipe_manager))
|
||||
|
||||
var run_path := pipe_manager.get_run_path()
|
||||
if not DirAccess.dir_exists_absolute(run_path):
|
||||
pass_test("Unable to create pipe directory for test. Skipping.")
|
||||
return
|
||||
|
||||
var on_line_written := func(line: String):
|
||||
gut.p("Got message: " + line)
|
||||
assert_eq(line, "test")
|
||||
pipe_manager.line_written.connect(on_line_written)
|
||||
|
||||
OS.execute("sh", ["-c", "echo -n test > " + pipe_manager.get_pipe_path()])
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
|
||||
assert_signal_emitted(pipe_manager, "line_written", "should emit line")
|
||||
1
core/systems/ipc/pipe_manager_test.gd.uid
Normal file
1
core/systems/ipc/pipe_manager_test.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dx7fwtl7k300t
|
||||
@@ -1,4 +1,4 @@
|
||||
[gd_scene load_steps=47 format=3 uid="uid://fhriwlhm0lcj"]
|
||||
[gd_scene load_steps=48 format=3 uid="uid://fhriwlhm0lcj"]
|
||||
|
||||
[ext_resource type="PackedScene" uid="uid://n83wlhmmsu3j" path="res://core/systems/input/input_manager.tscn" id="1_34t85"]
|
||||
[ext_resource type="Script" uid="uid://dcv2u3sn10dg5" path="res://core/ui/card_ui/card_ui.gd" id="1_f8851"]
|
||||
@@ -31,6 +31,7 @@
|
||||
[ext_resource type="PackedScene" uid="uid://dj1fooc3gh13l" path="res://core/ui/card_ui/help/help_menu.tscn" id="15_m1wp2"]
|
||||
[ext_resource type="ResourceRegistry" uid="uid://bsr58xihnpn1j" path="res://core/systems/resource/resource_registry.tres" id="15_ne50o"]
|
||||
[ext_resource type="PackedScene" uid="uid://cu4l0d1joc37w" path="res://core/ui/card_ui/navigation/in-game_notification.tscn" id="16_bcudw"]
|
||||
[ext_resource type="Script" uid="uid://ompm0veql0ng" path="res://core/systems/ipc/pipe_manager.gd" id="17_s6ods"]
|
||||
[ext_resource type="PackedScene" uid="uid://vf4sij64f82b" path="res://core/ui/common/osk/on_screen_keyboard.tscn" id="18_462u5"]
|
||||
[ext_resource type="PackedScene" uid="uid://doft5r1y37j1" path="res://core/ui/components/volume_indicator.tscn" id="18_g4maw"]
|
||||
[ext_resource type="PackedScene" uid="uid://bbcd5tclmp2ux" path="res://core/ui/card_ui/navigation/library_loading_notification.tscn" id="20_7tnsn"]
|
||||
@@ -126,6 +127,10 @@ instance = ExtResource("15_k47ua")
|
||||
[node name="ResourceProcessor" type="ResourceProcessor" parent="."]
|
||||
registry = ExtResource("15_ne50o")
|
||||
|
||||
[node name="PipeManager" type="Node" parent="."]
|
||||
script = ExtResource("17_s6ods")
|
||||
metadata/_custom_type_script = "uid://ompm0veql0ng"
|
||||
|
||||
[node name="PowerTimer" type="Timer" parent="."]
|
||||
unique_name_in_owner = true
|
||||
wait_time = 1.5
|
||||
|
||||
@@ -12,7 +12,7 @@ godot = { version = "0.2.4", features = [
|
||||
"experimental-threads",
|
||||
"register-docs",
|
||||
] }
|
||||
nix = { version = "0.29.0", features = ["term", "process"] }
|
||||
nix = { version = "0.29.0", features = ["term", "process", "fs"] }
|
||||
once_cell = "1.21.0"
|
||||
tokio = { version = "1.44.0", features = ["full"] }
|
||||
zbus = "5.5.0"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod command;
|
||||
pub mod fifo;
|
||||
pub mod pty;
|
||||
pub mod subreaper;
|
||||
|
||||
215
extensions/core/src/system/fifo.rs
Normal file
215
extensions/core/src/system/fifo.rs
Normal file
@@ -0,0 +1,215 @@
|
||||
use nix::unistd::mkfifo;
|
||||
use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError};
|
||||
use tokio::{io::AsyncReadExt, net::unix::pipe::OpenOptions, select};
|
||||
|
||||
use godot::{obj::WithBaseField, prelude::*};
|
||||
|
||||
use crate::RUNTIME;
|
||||
|
||||
/// Signals that can be emitted
|
||||
#[derive(Debug)]
|
||||
enum Signal {
|
||||
LineWritten { line: String },
|
||||
}
|
||||
|
||||
/// Commands that can be sent to a pipe
|
||||
#[derive(Debug)]
|
||||
enum ControlCommand {
|
||||
Close,
|
||||
}
|
||||
|
||||
#[derive(GodotClass)]
|
||||
#[class(base=Node)]
|
||||
pub struct FifoReader {
|
||||
base: Base<Node>,
|
||||
/// Receiver to listen for signals emitted from the async runtime
|
||||
rx: Receiver<Signal>,
|
||||
/// Transmitter to send signals from the async runtime
|
||||
tx: Sender<Signal>,
|
||||
/// Transmitter to send commands to the async task
|
||||
cmd_tx: Option<tokio::sync::mpsc::Sender<ControlCommand>>,
|
||||
|
||||
/// Whether or not the pipe is currently open
|
||||
#[var(get = get_is_open)]
|
||||
is_open: bool,
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl FifoReader {
|
||||
/// Emitted when the pipe is opened.
|
||||
#[signal]
|
||||
fn opened();
|
||||
|
||||
/// Emitted when the pipe is closed.
|
||||
#[signal]
|
||||
fn closed();
|
||||
|
||||
/// Emitted when a line is written to the pipe.
|
||||
#[signal]
|
||||
fn line_written(line: GString);
|
||||
|
||||
/// Open the given named pipe
|
||||
#[func]
|
||||
fn open(&mut self, path: GString) -> i32 {
|
||||
if self.is_open {
|
||||
log::error!("Named pipe is already open");
|
||||
return -1;
|
||||
}
|
||||
let path = path.to_string();
|
||||
|
||||
// Create the named pipe if it does not exist
|
||||
let _ = mkfifo(path.as_str(), nix::sys::stat::Mode::S_IRWXU);
|
||||
|
||||
// Create a channel so input commands can be sent to the running pipe task
|
||||
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel(1);
|
||||
self.cmd_tx = Some(cmd_tx);
|
||||
|
||||
// Spawn the async task to handle reading from the pipe
|
||||
let signals_tx = self.tx.clone();
|
||||
RUNTIME.spawn(async move {
|
||||
let mut pipe = match OpenOptions::new().open_receiver(path.as_str()) {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
log::error!("Failed to open named pipe {path}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Select between read and command operations in a loop
|
||||
loop {
|
||||
let mut buffer = [0; 4096];
|
||||
select! {
|
||||
// Handle output
|
||||
read_result = pipe.read(&mut buffer) => {
|
||||
let bytes_read = match read_result {
|
||||
Ok(n) => n,
|
||||
Err(_e) => break,
|
||||
};
|
||||
if bytes_read != 0 {
|
||||
Self::process_read(&buffer, bytes_read, &signals_tx);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Re-open the receiver and wait for other writers if
|
||||
// no other bytes are read
|
||||
pipe = match OpenOptions::new().open_receiver(path.as_str()) {
|
||||
Ok(pipe) => pipe,
|
||||
Err(e) => {
|
||||
log::error!("Failed to re-open named pipe {path}: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
}
|
||||
// Handle process commands
|
||||
Some(cmd) = cmd_rx.recv() => {
|
||||
match cmd {
|
||||
ControlCommand::Close => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(pipe);
|
||||
|
||||
// Remove the pipe file
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
});
|
||||
|
||||
self.is_open = true;
|
||||
self.base_mut().emit_signal("opened", &[]);
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// Returns whether or not the named pipe is currently opened
|
||||
#[func]
|
||||
fn get_is_open(&self) -> bool {
|
||||
self.is_open
|
||||
}
|
||||
|
||||
/// Close the pipe
|
||||
#[func]
|
||||
fn close(&mut self) -> i32 {
|
||||
self.is_open = false;
|
||||
let Some(cmd_tx) = self.cmd_tx.take() else {
|
||||
log::error!("Named pipe is not open to close");
|
||||
return -1;
|
||||
};
|
||||
let command = ControlCommand::Close;
|
||||
if let Err(e) = cmd_tx.blocking_send(command) {
|
||||
log::error!("Error sending close command to pipe: {e}");
|
||||
}
|
||||
self.base_mut().emit_signal("closed", &[]);
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
/// Process reading output from the pipe
|
||||
fn process_read(buffer: &[u8], bytes_read: usize, signals_tx: &Sender<Signal>) {
|
||||
let data = &buffer[..bytes_read];
|
||||
let text = String::from_utf8_lossy(data).to_string();
|
||||
let text = text.replace('\r', "");
|
||||
let lines = text.split('\n');
|
||||
for line in lines {
|
||||
let line = line.to_string();
|
||||
let signal = Signal::LineWritten { line };
|
||||
if let Err(e) = signals_tx.send(signal) {
|
||||
log::error!("Error sending line: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Process and dispatch the given signal
|
||||
fn process_signal(&mut self, signal: Signal) {
|
||||
match signal {
|
||||
Signal::LineWritten { line } => {
|
||||
self.base_mut()
|
||||
.emit_signal("line_written", &[line.to_godot().to_variant()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[godot_api]
|
||||
impl INode for FifoReader {
|
||||
/// Called upon object initialization in the engine
|
||||
fn init(base: Base<Self::Base>) -> Self {
|
||||
// Create a channel to communicate with the async runtime
|
||||
let (tx, rx) = channel();
|
||||
|
||||
Self {
|
||||
base,
|
||||
rx,
|
||||
tx,
|
||||
cmd_tx: None,
|
||||
is_open: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed every engine frame
|
||||
fn process(&mut self, _delta: f64) {
|
||||
// Drain all messages from the channel to process them
|
||||
loop {
|
||||
let signal = match self.rx.try_recv() {
|
||||
Ok(value) => value,
|
||||
Err(e) => match e {
|
||||
TryRecvError::Empty => break,
|
||||
TryRecvError::Disconnected => {
|
||||
log::debug!("Backend thread is not running!");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
self.process_signal(signal);
|
||||
}
|
||||
|
||||
// Check to see if the pipe has closed
|
||||
if let Some(cmd_tx) = self.cmd_tx.as_ref() {
|
||||
if cmd_tx.is_closed() {
|
||||
log::debug!("Pipe reader task has stopped");
|
||||
self.is_open = false;
|
||||
self.cmd_tx = None;
|
||||
self.base_mut().emit_signal("closed", &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,14 @@ PREFIX=$(dirname -- "${SCRIPT_DIR}")
|
||||
OGUI_BIN=${OGUI_BIN:-"${PREFIX}/share/opengamepadui/opengamepad-ui.x86_64"}
|
||||
GAMESCOPE_CMD=${GAMESCOPE_CMD:-gamescope -e -w 1920 -h 1080 -f --xwayland-count 2}
|
||||
|
||||
# Check to see if a URI is being sent
|
||||
if [[ "$1" == ogui://* ]]; then
|
||||
if ls /run/user/${UID}/opengamepadui/opengamepadui-0 >/dev/null 2>&1; then
|
||||
echo "$1" >/run/user/${UID}/opengamepadui/opengamepadui-0
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Launch normally if gamescope is already running
|
||||
if ls /run/user/${UID}/gamescope* >/dev/null 2>&1; then
|
||||
echo "Executing: ${OGUI_BIN}" "$@"
|
||||
|
||||
@@ -5,6 +5,7 @@ GenericName=Game launcher and overlay
|
||||
Type=Application
|
||||
Comment=Game launcher and overlay
|
||||
Icon=opengamepadui
|
||||
Exec=opengamepadui
|
||||
Exec=opengamepadui %u
|
||||
Terminal=false
|
||||
StartupNotify=false
|
||||
MimeType=x-scheme-handler/ogui;
|
||||
|
||||
Reference in New Issue
Block a user