Skip to main content

idlewarden_plugin_api/
manifest.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The plugin manifest, the actual contract (ADR-0010).
3//!
4//! Stability is achievable here precisely *because* this is a data schema and
5//! not a Rust ABI. A declarative plugin only ever breaks when this schema
6//! changes, and the schema is versioned.
7
8use crate::capability::Capability;
9use semver::{Version, VersionReq};
10use serde::{Deserialize, Serialize};
11
12/// The plugin API version this build of the host implements.
13pub 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    /// Reverse-DNS, lowercase, dot-separated. Keeps ids collision-free across
24    /// authors without a central name authority.
25    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    /// Refuse loudly at load time rather than misbehaving at runtime.
49    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/// How to recognise that the game is running. Declarative on purpose: detection
57/// is configuration, not code (ADR-0001).
58#[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    /// Executable file name, case-insensitive, e.g. "Game.exe".
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub executable: Option<String>,
66    /// Substring the window title must contain, compared without case.
67    #[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/// One entry of the plugin's declared state schema.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct SignalDecl {
80    pub id: SignalId,
81    /// Must match `Value::type_name()` of the values the plugin emits.
82    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    /// SPDX identifier of the plugin's own licence, plugins are Apache-2.0
100    /// downstream and may pick anything.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub license: Option<String>,
103
104    pub game: GameMatcher,
105    /// `true` when the target game has any multiplayer or competitive mode.
106    /// The registry refuses such plugins; see PLUGIN_POLICY.md.
107    #[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    /// Structural validation. Compatibility is checked separately by the host
120    /// so it can report the two failures differently.
121    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}