idlewarden_plugin_api/
manifest.rs1use crate::capability::Capability;
9use semver::{Version, VersionReq};
10use serde::{Deserialize, Serialize};
11
12pub const API_VERSION: &str = "0.1.0";
14
15#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct PluginId(pub String);
17
18impl PluginId {
19 pub fn as_str(&self) -> &str {
20 &self.0
21 }
22
23 pub fn is_valid(&self) -> bool {
26 let s = &self.0;
27 !s.is_empty()
28 && s.len() <= 128
29 && s.split('.').count() >= 2
30 && s.chars()
31 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-')
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
36pub struct SignalId(pub String);
37
38impl SignalId {
39 pub fn as_str(&self) -> &str {
40 &self.0
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45pub struct ApiVersion(pub VersionReq);
46
47impl ApiVersion {
48 pub fn is_satisfied_by_host(&self) -> bool {
50 Version::parse(API_VERSION)
51 .map(|v| self.0.matches(&v))
52 .unwrap_or(false)
53 }
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub struct GameMatcher {
61 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub steam_appid: Option<u32>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub executable: Option<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub window_title: Option<String>,
69}
70
71impl GameMatcher {
72 pub fn is_empty(&self) -> bool {
73 self.steam_appid.is_none() && self.executable.is_none() && self.window_title.is_none()
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct SignalDecl {
80 pub id: SignalId,
81 pub value_type: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub description: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub unit: Option<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct PluginManifest {
91 pub id: PluginId,
92 pub name: String,
93 pub version: Version,
94 pub api_version: ApiVersion,
95 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub authors: Vec<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub description: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub license: Option<String>,
103
104 pub game: GameMatcher,
105 #[serde(default)]
108 pub multiplayer: bool,
109
110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 pub signals: Vec<SignalDecl>,
112 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub intents: Vec<String>,
114 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub capabilities: Vec<Capability>,
116}
117
118impl PluginManifest {
119 pub fn validate(&self) -> Result<(), crate::PluginError> {
122 use crate::PluginError::InvalidManifest;
123 if !self.id.is_valid() {
124 return Err(InvalidManifest(format!(
125 "id `{}` must be reverse-DNS, lowercase, e.g. `dev.bryan.mygame`",
126 self.id.0
127 )));
128 }
129 if self.name.trim().is_empty() {
130 return Err(InvalidManifest("name must not be empty".into()));
131 }
132 if self.game.is_empty() {
133 return Err(InvalidManifest(
134 "game matcher must set at least one of steam_appid, executable, window_title"
135 .into(),
136 ));
137 }
138 let mut ids: Vec<&SignalId> = self.signals.iter().map(|s| &s.id).collect();
139 ids.sort();
140 let before = ids.len();
141 ids.dedup();
142 if ids.len() != before {
143 return Err(InvalidManifest("duplicate signal id in schema".into()));
144 }
145 Ok(())
146 }
147}