feat(URI): add mime type handler for opening ogui:// uri's

This commit is contained in:
William Edwards
2025-03-25 19:38:31 -07:00
parent 4c0b931d9e
commit f8bd7709ac
13 changed files with 426 additions and 3 deletions

View File

@@ -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"

View 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

View 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

View 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()

View File

@@ -0,0 +1 @@
uid://ompm0veql0ng

View 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")

View File

@@ -0,0 +1 @@
uid://dx7fwtl7k300t

View File

@@ -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

View File

@@ -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"

View File

@@ -1,3 +1,4 @@
pub mod command;
pub mod fifo;
pub mod pty;
pub mod subreaper;

View 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", &[]);
}
}
}
}

View File

@@ -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}" "$@"

View File

@@ -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;