Add event system for SteamClient callbacks (untested; WIP)

This commit is contained in:
NGnius (Graham) 2022-10-12 20:39:34 -04:00
parent a52309484e
commit 17c61907a9
18 changed files with 446 additions and 33 deletions

View file

@ -1,6 +1,8 @@
# React-Frontend Plugin Template
# Caylon
Reference example for using [decky-frontend-lib](https://github.com/SteamDeckHomebrew/decky-frontend-lib) in a [PluginLoader](https://github.com/SteamDeckHomebrew/PluginLoader) plugin.
Custom widgets for simple tasks.
Adapted from a template for using [decky-frontend-lib](https://github.com/SteamDeckHomebrew/decky-frontend-lib) in a [Decky Loader](https://github.com/SteamDeckHomebrew/PluginLoader) plugin.
## PluginLoader Discord [![Chat](https://img.shields.io/badge/chat-on%20discord-7289da.svg)](https://discord.gg/ZU74G2NJzk)
@ -8,30 +10,18 @@ Reference example for using [decky-frontend-lib](https://github.com/SteamDeckHom
### Dependencies
This template relies on the user having `pnpm` installed on their system.
This can be downloaded from `npm` itself which is recommended.
#### Linux
```bash
sudo npm i -g pnpm
```
This project relies on `npm` and `rustup`.
For building on another Linux PC, Rust toolchain `x86_64-unknown-linux-musl` must also be installed.
### Getting Started
1. Clone the repository to use as an example for making your plugin.
2. In your clone of the repository run these commands:
1. ``pnpm i``
2. ``pnpm run build``
3. You should do this every time you make changes to your plugin.
Note: If you are recieveing build errors due to an out of date library, you should run this command inside of your repository:
```bash
pnpm update decky-frontend-lib --latest
```
2. In your clone of the repository run these commands to build the front-end:
1. ``npm install``
2. ``npm run build``
3. From `backend/`, Run `./build.sh` to build the back-end.
### Distribution
WIP. Check back in later.
IDK, maybe a bit of custom spins as well as a barebones offering from me

View file

@ -14,7 +14,7 @@ clap = { version = "3.2", features = ["derive", "std"], default-features = false
# async
tokio = { version = "*", features = ["time"] }
async-trait = { version = "0.1.57" }
async-trait = { version = "0.1" }
# json
serde = { version = "1.0", features = ["derive"] }

View file

@ -2,6 +2,7 @@ mod about;
pub(crate) mod async_utils;
mod get_display;
mod get_items;
mod on_event;
mod on_javascript_result;
mod on_update;
mod reload;
@ -12,6 +13,7 @@ mod types;
pub use about::get_about;
pub use get_display::GetDisplayEndpoint;
pub use get_items::get_items;
pub use on_event::on_event;
pub use on_javascript_result::on_javascript_result;
pub use on_update::on_update;
pub use reload::reload;

View file

@ -0,0 +1,36 @@
use std::sync::{Mutex, mpsc::Sender};
use usdpl_back::core::serdes::Primitive;
use super::ApiParameterType;
use crate::runtime::{QueueAction, QueueItem};
/// API web method to notify the back-end of a steam event (i.e. callback through SteamClient API)
pub fn on_event(sender: Sender<QueueItem>) -> impl Fn(ApiParameterType) -> ApiParameterType {
let sender = Mutex::new(sender);
move |params: ApiParameterType| {
log::debug!("API: on_event");
if let Some(Primitive::Json(event_data)) = params.get(0) {
match serde_json::from_str(event_data) {
Ok(event_obj) => {
sender.lock().unwrap().send(
QueueItem {
action: QueueAction::DoSteamEvent {
event: event_obj,
}
}
).unwrap();
log::info!("Sent steam event");
vec![true.into()]
},
Err(e) => {
log::warn!("Failed to parse event json: {}", e);
vec![false.into()]
}
}
} else {
vec![false.into()]
}
}
}

View file

@ -1,9 +1,44 @@
#![allow(non_snake_case)]
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "event_type", content = "event_data")]
pub enum SteamEvent {
#[serde(rename = "download-items")]
DownloadItems(SteamDownloadInfo),
#[serde(rename = "download-overview")]
DownloadOverview(SteamDownloadOverview),
#[serde(rename = "achievement-notification")]
AchievementNotification(SteamAchievementNotification),
#[serde(rename = "bluetooth-state")]
BluetoothState(SteamBluetoothState),
#[serde(rename = "connectivity-test-change")]
ConnectivityTestChange(SteamConnectivityTestChange),
#[serde(rename = "network-diagnostic")]
NetworkDiagnostic(SteamNetworkDiagnostic),
#[serde(rename = "audio-device-added")]
AudioDeviceAdded(SteamAudioDevice),
#[serde(rename = "audio-device-removed")]
AudioDeviceRemoved(SteamAudioDevice),
#[serde(rename = "brightness")]
Brightness(SteamBrightness),
#[serde(rename = "airplane")]
Airplane(SteamAirplane),
#[serde(rename = "battery")]
Battery(SteamBattery),
#[serde(rename = "screenshot-notification")]
ScreenshotNotification(SteamScreenshotNotification),
#[serde(rename = "controller-input-message")]
ControllerInputMessage(Vec<SteamControllerInputMessage>),
#[serde(rename = "app-lifetime-notification")]
AppLifetimeNotification(SteamAppLifetimeNotification),
#[serde(rename = "game-action-start")]
GameActionStart(SteamGameAction),
}
// list of these is second callback param for SteamClient.Downloads.RegisterForDownloadItems
#[derive(Serialize, Deserialize, Clone)]
pub struct SteamDownloadInfo {
pub struct SteamDownloadItem {
pub active: bool,
pub appid: usize,
pub buildid: usize,
@ -11,6 +46,13 @@ pub struct SteamDownloadInfo {
pub paused: bool,
}
// both params for callback for SteamClient.Downloads.RegisterForDownloadItems
#[derive(Serialize, Deserialize, Clone)]
pub struct SteamDownloadInfo {
pub paused: bool,
pub items: Vec<SteamDownloadItem>,
}
// only callback param for SteamClient.Downloads.RegisterForDownloadOverview
#[derive(Serialize, Deserialize, Clone)]
pub struct SteamDownloadOverview {
@ -103,7 +145,7 @@ pub struct SteamNetworkDiagnostic {
}
// only param of callback for SteamClient.System.Audio.RegisterForDeviceAdded
// and SteamClient.System.Audio.RegisterForDeviceAdded
// and SteamClient.System.Audio.RegisterForDeviceRemoved
// Also type of vecDevices of await SteamClient.System.Audio.GetDevices()
#[derive(Serialize, Deserialize, Clone)]
pub struct SteamAudioDevice {
@ -196,3 +238,19 @@ pub struct SteamControllerInputMessage {
pub nController: usize,
pub strActionName: String,
}
// only param of callback for SteamClient.GameSessions.RegisterForAppLifetimeNotifications
#[derive(Serialize, Deserialize, Clone)]
pub struct SteamAppLifetimeNotification {
pub bRunning: bool,
pub nInstanceID: usize,
pub unAppID: usize,
}
// params of callback for SteamClient.Apps.RegisterForGameActionStart
#[derive(Serialize, Deserialize, Clone)]
pub struct SteamGameAction {
pub param0: usize, // idk what this is supposed to indicate
pub gameID: usize,
pub action: String, // idk what possible values are
}

View file

@ -1,6 +1,6 @@
use serde::{Serialize, Deserialize};
use super::{ButtonConfig, ToggleConfig, SliderConfig, ReadingConfig, ResultDisplayConfig};
use super::{ButtonConfig, ToggleConfig, SliderConfig, ReadingConfig, ResultDisplayConfig, EventDisplayConfig};
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "element")]
@ -15,4 +15,6 @@ pub enum ElementConfig {
ReadingDisplay(ReadingConfig),
#[serde(rename = "result-display")]
ResultDisplay(ResultDisplayConfig),
#[serde(rename = "event-display")]
EventDisplay(EventDisplayConfig)
}

View file

@ -0,0 +1,67 @@
use serde::{Serialize, Deserialize};
use super::TopLevelActionConfig;
#[derive(Serialize, Deserialize, Clone)]
pub struct EventDisplayConfig {
pub title: String,
/// Type of event to listen for
pub event: EventType,
/// Action to perform when the event occurs
pub on_event: TopLevelActionConfig,
}
#[derive(Serialize, Deserialize, Clone)]
pub enum EventType {
#[serde(rename = "achievement")]
Achievement,
#[serde(rename = "airplane", alias = "airplane mode")]
Airplane,
#[serde(rename = "bluetooth")]
Bluetooth,
#[serde(rename = "brightness")]
Brightness,
#[serde(rename = "screenshot")]
Screenshot,
#[serde(rename = "game-start", alias = "game start")]
GameStart,
#[serde(rename = "game-lifetime", alias = "game lifetime")]
GameLifetime,
}
impl EventType {
#[inline]
pub fn is_achievement(&self) -> bool {
matches!(self, Self::Achievement)
}
#[inline]
pub fn is_airplane(&self) -> bool {
matches!(self, Self::Airplane)
}
#[inline]
pub fn is_bluetooth(&self) -> bool {
matches!(self, Self::Bluetooth)
}
#[inline]
pub fn is_brightness(&self) -> bool {
matches!(self, Self::Brightness)
}
#[inline]
pub fn is_screenshot(&self) -> bool {
matches!(self, Self::Screenshot)
}
#[inline]
pub fn is_game_start(&self) -> bool {
matches!(self, Self::GameStart)
}
#[inline]
pub fn is_game_lifetime(&self) -> bool {
matches!(self, Self::GameLifetime)
}
}

View file

@ -3,6 +3,7 @@ mod action;
mod base;
mod button;
mod element;
mod event_display;
mod reading;
mod result_display;
mod slider;
@ -14,6 +15,7 @@ pub use action::{TopLevelActionConfig, ActionConfig, CommandAction, MirrorAction
pub use base::BaseConfig;
pub use button::ButtonConfig;
pub use element::ElementConfig;
pub use event_display::{EventDisplayConfig, EventType};
pub use reading::ReadingConfig;
pub use result_display::ResultDisplayConfig;
pub use slider::SliderConfig;

View file

@ -7,5 +7,6 @@ pub struct ReadingConfig {
pub title: String,
/// Period in milliseconds, or None/null for non-repeating actions
pub period_ms: Option<u64>,
/// Action to perform on every period
pub on_period: TopLevelActionConfig,
}

View file

@ -3,6 +3,6 @@ use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
pub struct ResultDisplayConfig {
pub title: String,
/// index of element who's action's result will be used
/// Index of element who's action's result will be used
pub result_of: usize,
}

View file

@ -33,6 +33,7 @@ fn main() -> Result<(), ()> {
.register_blocking("get_items", api::get_items(sender.clone()))
.register("on_javascript_result", api::on_javascript_result(sender.clone()))
.register("on_update", api::on_update(sender.clone()))
.register("on_event", api::on_event(sender.clone()))
.register_blocking("reload", api::reload(sender.clone()));
let _exec_handle = executor.spawn();
instance.run_blocking()

View file

@ -31,6 +31,7 @@ impl<'a> SeqAct<'a> for Actor {
ElementConfig::Slider(s) => TopLevelActorType::build(&s.on_set, parameter.1),
ElementConfig::ReadingDisplay(r) => TopLevelActorType::build(&r.on_period, parameter.1),
ElementConfig::ResultDisplay(_) => Err(format!("Item #{} is a ResultDisplay, which can't act", i)),
ElementConfig::EventDisplay(e) => TopLevelActorType::build(&e.on_event, parameter.1),
}?;
Ok(Self {
actor_type: a_type,
@ -168,6 +169,7 @@ where
outputs: std::collections::VecDeque<Expected>,
}
#[cfg(test)]
pub enum Expected {
Output(Primitive),
BuildErr(ActError),

View file

@ -85,6 +85,7 @@ mod test {
#[test]
fn json_actor_test() {
//let (runtime_io, _result_rx, _js_rx) = crate::runtime::RuntimeIO::mock();
// test data """borrowed""" from https://jmespath.org/
SeqActTestHarness::builder(JsonActor::build)
// test 1 ---
.with_io(

View file

@ -2,6 +2,7 @@ use std::sync::mpsc::Sender;
use usdpl_back::core::serdes::Primitive;
use crate::api::SteamEvent;
use crate::config::{AboutConfig, ElementConfig};
/// An API operation for the executor to perform
@ -27,6 +28,9 @@ pub enum QueueAction {
id: usize,
value: Primitive,
},
DoSteamEvent {
event: SteamEvent,
},
}
/// Wrapper for an executor command

View file

@ -2,10 +2,43 @@ use std::thread;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use usdpl_back::core::serdes::Primitive;
use crate::config::{BaseConfig, ElementConfig};
use crate::api::SteamEvent;
use super::{QueueItem, QueueAction, Act, SeqAct};
use super::{ResultRouter, RouterCommand, JavascriptRouter, JavascriptCommand};
macro_rules! wire_steam_event {
(
$func_name: ident,
$display_name: literal,
$self: ident,
$conf: ident,
$item: ident,
$index: ident,
$event: ident,
$event_json_cache: ident,
) => {
if $conf.event.$func_name() {
let value = cache_event_maybe(&$event, &mut $event_json_cache);
match super::Actor::build($item, ($index, &$self.handlers)) {
Ok(act) => {
let respond_to = $self.handlers.result.clone();
thread::spawn(move || {
let result = act.run(value);
match respond_to.send(RouterCommand::HandleResult{$index, result}) {
Ok(_) => {},
Err(_) => log::warn!("Failed to send DoSteamEvent `{}` response for item #{}", $display_name, $index),
}
});
},
Err(e) => log::error!("Failed to build DoSteamEvent `{}` actor for item #{}: {}", $display_name, $index, e)
}
}
}
}
#[derive(Clone)]
pub struct RuntimeIO {
pub result: Sender<RouterCommand>,
@ -83,7 +116,7 @@ impl ExecutorState {
},
QueueAction::DoUpdate { index, value } => {
// trigger update event for element
// i.e. on_click, on_toggle, etc. action
// e.g. on_click, on_toggle, etc. action
if let Some(item) = self.config_data.get_item(index) {
match super::Actor::build(item, (index, &self.handlers)) {
Ok(act) => {
@ -151,6 +184,97 @@ impl ExecutorState {
log::error!("Failed to send to JavascriptRouter again, did not SetJavascriptSubscriber");
}
}
},
QueueAction::DoSteamEvent { event } => {
// handle steam event for all elements that may be listening
let mut event_json_cache: Option<String> = None;
for (index, item) in self.config_data.items().iter().enumerate() {
match item {
ElementConfig::EventDisplay(conf) => {
match &event {
SteamEvent::DownloadItems(_x) => log::error!("Unsupported event"),
SteamEvent::DownloadOverview(_x) => log::error!("Unsupported event"),
SteamEvent::AchievementNotification(x) => wire_steam_event!{
is_achievement,
"achievement",
self,
conf,
item,
index,
x,
event_json_cache,
},
SteamEvent::BluetoothState(x) => wire_steam_event!{
is_bluetooth,
"bluetooth",
self,
conf,
item,
index,
x,
event_json_cache,
},
SteamEvent::ConnectivityTestChange(_x) => log::error!("Unsupported event"),
SteamEvent::NetworkDiagnostic(_x) => log::error!("Unsupported event"),
SteamEvent::AudioDeviceAdded(_x) => log::error!("Unsupported event"),
SteamEvent::AudioDeviceRemoved(_x) => log::error!("Unsupported event"),
SteamEvent::Brightness(x) => wire_steam_event!{
is_brightness,
"brightness",
self,
conf,
item,
index,
x,
event_json_cache,
},
SteamEvent::Airplane(x) => wire_steam_event!{
is_airplane,
"airplane",
self,
conf,
item,
index,
x,
event_json_cache,
},
SteamEvent::Battery(_x) => log::error!("Unsupported event"),
SteamEvent::ScreenshotNotification(x) => wire_steam_event!{
is_screenshot,
"screenshot",
self,
conf,
item,
index,
x,
event_json_cache,
},
SteamEvent::ControllerInputMessage(_x) => log::error!("Unsupported event"),
SteamEvent::AppLifetimeNotification(x) => wire_steam_event!{
is_game_lifetime,
"game-lifetime",
self,
conf,
item,
index,
x,
event_json_cache,
},
SteamEvent::GameActionStart(x) => wire_steam_event!{
is_game_start,
"game-start",
self,
conf,
item,
index,
x,
event_json_cache,
},
}
}
_ => {}
}
}
}
}
}
@ -183,3 +307,14 @@ impl ExecutorState {
}
}
}
#[inline]
fn cache_event_maybe<T: serde::Serialize>(event: &T, cache: &mut Option<String>) -> Primitive {
if let Some(cached) = cache {
Primitive::Json(cached.to_owned())
} else {
let dump = serde_json::to_string(event).unwrap();
*cache = Some(dump.clone());
Primitive::Json(dump)
}
}

View file

@ -4,7 +4,7 @@ const USDPL_PORT: number = 25717;
// Utility
export function resolve(promise: Promise<any>, setter: any) {
export function resolve<T>(promise: Promise<T>, setter: (x: T) => void) {
(async function () {
let data = await promise;
if (data != null) {
@ -64,7 +64,18 @@ export type CResultDisplay = {
result_of: number;
}
export type CElement = CButton | CToggle | CSlider | CReading | CResultDisplay;
export type CEventDisplay = {
element: string; // "event-display"
title: string;
event: string;
}
export type CSteamEvent = {
event_type: string; // enum; see steam_types.rs
event_data: any;
}
export type CElement = CButton | CToggle | CSlider | CReading | CResultDisplay | CEventDisplay;
export type CErrorResult = {
result: string; // "error"
@ -91,7 +102,7 @@ export async function getElements(): Promise<CElement[]> {
return (await call_backend("get_items", []))[0];
}
export async function onUpdate(index: number, value: any): Promise<any> {
export async function onUpdate(index: number, value: any): Promise<boolean> {
return (await call_backend("on_update", [index, value]))[0];
}
@ -111,6 +122,10 @@ export async function getJavascriptToRun(): Promise<CJavascriptResponse> {
return (await call_backend("get_javascript_to_run", []))[0];
}
export async function onJavascriptResult(id: number, value: any): Promise<any> {
export async function onJavascriptResult(id: number, value: any): Promise<boolean> {
return (await call_backend("on_javascript_result", [id, value]))[0];
}
export async function onSteamEvent(data: CSteamEvent): Promise<boolean> {
return (await call_backend("on_javascript_result", [data]))[0];
}

View file

@ -21,6 +21,7 @@ import { GiWashingMachine } from "react-icons/gi";
import { get_value, set_value } from "usdpl-front";
import * as backend from "./backend";
import {register_for_steam_events, unregister_for_steam_events} from "./steam_events";
const FieldWithSeparator = joinClassNames(gamepadDialogClasses.Field, gamepadDialogClasses.WithBottomSeparatorStandard);
@ -65,6 +66,7 @@ function onGetElements() {
backend.resolve(backend.getDisplay(i), displayCallback(i));
}
backend.resolve(backend.getJavascriptToRun(), jsCallback());
register_for_steam_events();
}
const eval2 = eval;
@ -110,6 +112,7 @@ function jsCallback() {
console.warn("CAYLON: backend connection failed");
}
backend.resolve(backend.getJavascriptToRun(), jsCallback());
register_for_steam_events();
})();
const Content: VFC<{ serverAPI: ServerAPI }> = ({}) => {
@ -181,6 +184,8 @@ function buildHtmlElement(element: backend.CElement, index: number, updateIdc: a
return buildReading(element as backend.CReading, index, updateIdc);
case "result-display":
return buildResultDisplay(element as backend.CResultDisplay, index, updateIdc);
case "event-display":
return buildEventDisplay(element as backend.CEventDisplay, index, updateIdc);
}
console.error("CAYLON: Unsupported element", element);
return <div>Unsupported</div>;
@ -256,6 +261,17 @@ function buildResultDisplay(element: backend.CResultDisplay, index: number, _upd
);
}
function buildEventDisplay(element: backend.CEventDisplay, index: number, _updateIdc: any) {
return (
<div className={FieldWithSeparator}>
<div className={gamepadDialogClasses.FieldLabelRow}>
<div className={gamepadDialogClasses.FieldLabel}>{element.title}</div>
<div className={gamepadDialogClasses.FieldChildren}>{get_value(DISPLAY_KEY + index.toString())}</div>
</div>
</div>
);
}
function buildAbout() {
if (about == null) {
return [];
@ -360,12 +376,13 @@ function buildAbout() {
}
export default definePlugin((serverApi: ServerAPI) => {
register_for_steam_events()
return {
title: <div className={staticClasses.Title}>{about == null? "Kaylon": about.name}</div>,
title: <div className={staticClasses.Title}>{about == null? "Caylon": about.name}</div>,
content: <Content serverAPI={serverApi} />,
icon: <GiWashingMachine />,
onDismount() {
//serverApi.routerHook.removeRoute("/decky-plugin-test");
unregister_for_steam_events();
},
};
});

80
src/steam_events.ts Normal file
View file

@ -0,0 +1,80 @@
import * as backend from "./backend";
type Unregisterer = {
unregister: () => void;
}
let callbacks: Unregisterer[] = [];
export function register_for_steam_events() {
unregister_for_steam_events();
//@ts-ignore
SteamClient.Apps.RegisterForGameActionStart((p0, p1, p2) => {
backend.onSteamEvent( {
event_type: "game-action-start",
event_data: {
param0: p0,
gameID: p1,
action: p2,
}
});
});
//@ts-ignore
SteamClient.GameSessions.RegisterForAppLifetimeNotifications((p0) => {
backend.onSteamEvent( {
event_type: "app-lifetime-notification",
event_data: p0
});
});
//@ts-ignore
SteamClient.GameSessions.RegisterForAchievementNotification((p0) => {
backend.onSteamEvent( {
event_type: "achievement-notification",
event_data: p0
});
});
//@ts-ignore
SteamClient.System.Bluetooth.RegisterForStateChanges((p0) => {
backend.onSteamEvent( {
event_type: "bluetooth-state",
event_data: p0
});
});
//@ts-ignore
SteamClient.System.Display.RegisterForBrightnessChanges((p0) => {
backend.onSteamEvent( {
event_type: "brightness",
event_data: p0
});
});
//@ts-ignore
SteamClient.System.RegisterForAirplaneModeChanges((p0) => {
backend.onSteamEvent( {
event_type: "airplane",
event_data: p0
});
});
//@ts-ignore
SteamClient.GameSessions.RegisterForScreenshotNotification((p0) => {
backend.onSteamEvent( {
event_type: "screenshot-notification",
event_data: p0
});
});
// TODO add more events
}
export function unregister_for_steam_events() {
for (let i = 0; i < callbacks.length; i++) {
callbacks[i].unregister();
}
callbacks = [];
}