Skip to main content

objectiveai_sdk/filesystem/plugins/
manifest.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use super::Platform;
5
6/// Declarative metadata a plugin ships with itself. The wire shape is
7/// JSON; the on-disk convention (sibling file, embedded resource,
8/// `--manifest` flag, …) is deliberately out of scope of this struct
9/// and will be settled in a follow-up.
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
11#[schemars(rename = "filesystem.plugins.Manifest")]
12pub struct Manifest {
13    /// One-line description of what the plugin does. Surfaced in
14    /// listings and the plugin's `--help`-equivalent UI.
15    pub description: String,
16
17    /// Version string. Semver convention is recommended but not
18    /// enforced — the host just displays whatever's here.
19    pub version: String,
20
21    /// Author or authors of the plugin. Free-form string.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    #[schemars(extend("omitempty" = true))]
24    pub author: Option<String>,
25
26    /// Homepage or repository URL.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    #[schemars(extend("omitempty" = true))]
29    pub homepage: Option<String>,
30
31    /// SPDX license identifier (or any string).
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    #[schemars(extend("omitempty" = true))]
34    pub license: Option<String>,
35
36    /// Release-asset filename per platform — what the cli should
37    /// download from the GitHub release tagged `v<version>` to install
38    /// the plugin's binary on each platform. Values are filenames
39    /// (e.g. `psyops-linux-x86_64`, `psyops-windows-x86_64.exe`), NOT
40    /// URLs; the URL is composed from the repository + tag + asset
41    /// name elsewhere.
42    ///
43    /// **Every platform field is optional.** Declare entries only for
44    /// the platforms this plugin actually ships a binary for; absent
45    /// platforms are simply not supported by this release. A plugin
46    /// shipping only Linux x86_64 declares one entry; a plugin
47    /// shipping all six declares six. All-None ↔ field omitted in
48    /// the wire shape.
49    #[serde(default, skip_serializing_if = "Binaries::is_empty")]
50    #[schemars(extend("omitempty" = true))]
51    pub binaries: Binaries,
52
53    /// GitHub-release asset filename for the plugin's viewer UI
54    /// bundle (a `.zip` whose root contains `index.html` plus
55    /// assets). When absent, the plugin has no viewer tab from this
56    /// source. Mutually exclusive with [`Self::viewer_url`] —
57    /// validated by [`Self::validate`].
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    #[schemars(extend("omitempty" = true))]
60    pub viewer_zip: Option<String>,
61
62    /// Remote URL the viewer's iframe loads directly, instead of an
63    /// on-disk bundle from [`Self::viewer_zip`]. The full URL is used
64    /// as the iframe `src=` verbatim — query string, path, port,
65    /// fragment all pass through. Must use `https://`, or `http://`
66    /// targeting `localhost` / `127.0.0.1` (development only).
67    ///
68    /// Mutually exclusive with [`Self::viewer_zip`]. [`Self::viewer_routes`]
69    /// and [`Self::mobile_ready`] apply to remote-URL viewers the same
70    /// way they apply to zip-bundled viewers — the embedded axum
71    /// server still hosts the declared routes; the iframe still
72    /// receives the same postMessage protocol regardless of where
73    /// its HTML/JS loaded from.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    #[schemars(extend("omitempty" = true))]
76    pub viewer_url: Option<String>,
77
78    /// HTTP routes the viewer exposes on behalf of this plugin.
79    /// Each entry registers a handler at
80    /// `/plugin/<repository>/<path>` on the viewer's embedded axum
81    /// server; a hit emits a `PluginRequest { type, value }` event
82    /// to the React frontend, which dispatches to the plugin's
83    /// iframe via the postMessage bridge.
84    #[serde(default, skip_serializing_if = "Vec::is_empty")]
85    pub viewer_routes: Vec<ViewerRoute>,
86
87    /// Plugin author opts in to mobile viewer support by setting
88    /// this. Mobile viewer builds only surface plugins with this
89    /// flag true — mobile has no local backend binary, so plugin
90    /// UIs that require a backend will misbehave unless their
91    /// authors specifically design for "no-backend" mode. Defaults
92    /// to false (desktop-only).
93    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
94    pub mobile_ready: bool,
95}
96
97impl Manifest {
98    /// Whether this plugin presents a viewer tab in the host. True
99    /// iff either viewer source field — [`Self::viewer_zip`] or
100    /// [`Self::viewer_url`] — is set.
101    pub fn has_viewer(&self) -> bool {
102        self.viewer_zip.is_some() || self.viewer_url.is_some()
103    }
104
105    /// Validate fields that can't be enforced by serde alone:
106    /// `viewer_zip` and `viewer_url` are mutually exclusive, and
107    /// `viewer_url` (when present) must be `https://` or `http://`
108    /// targeting `localhost` / `127.0.0.1`. Called at every parse
109    /// boundary (remote-fetched install, on-disk read) so a broken
110    /// manifest can't sneak through.
111    pub fn validate(&self) -> Result<(), &'static str> {
112        if self.viewer_zip.is_some() && self.viewer_url.is_some() {
113            return Err("viewer_zip and viewer_url are mutually exclusive");
114        }
115        if let Some(url) = self.viewer_url.as_deref() {
116            validate_viewer_url(url)?;
117        }
118        Ok(())
119    }
120}
121
122/// Allow `https://*`. Allow `http://` only when the host is
123/// `localhost` or `127.0.0.1` (development). Reject everything else
124/// — raw http on a public hostname inside a Tauri WebView is a
125/// footgun (plaintext, MITM-able, mixed-content-blocked by the
126/// browser engine in most cases).
127///
128/// Dependency-free: a couple of `starts_with` / split checks beat
129/// pulling the full `url` crate for one validation. Doesn't handle
130/// IPv6 brackets or punycode — neither matters for the localhost
131/// allow-list.
132fn validate_viewer_url(url: &str) -> Result<(), &'static str> {
133    let url = url.trim();
134    if url.is_empty() {
135        return Err("viewer_url cannot be empty");
136    }
137    if url.starts_with("https://") {
138        return Ok(());
139    }
140    if let Some(rest) = url.strip_prefix("http://") {
141        // Host ends at the first '/', ':', '?', '#', or EOF.
142        let host_end = rest
143            .find(|c: char| matches!(c, '/' | ':' | '?' | '#'))
144            .unwrap_or(rest.len());
145        let host = &rest[..host_end];
146        if host == "localhost" || host == "127.0.0.1" {
147            return Ok(());
148        }
149        return Err("viewer_url with http:// scheme is only allowed for localhost or 127.0.0.1");
150    }
151    Err("viewer_url must use https:// or http://localhost / http://127.0.0.1")
152}
153
154/// Release-asset filename per platform. Every field is optional;
155/// declare only the platforms a plugin ships for. The wire shape is
156/// a flat JSON object — absent platforms are omitted, never
157/// serialised as `null`.
158///
159/// Exposes a [`Self::get`] method that takes a [`Platform`] enum so
160/// callers can read the asset filename for the current host without
161/// pattern-matching the field set themselves.
162#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
163#[schemars(rename = "filesystem.plugins.Binaries")]
164pub struct Binaries {
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    #[schemars(extend("omitempty" = true))]
167    pub linux_x86_64: Option<String>,
168
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    #[schemars(extend("omitempty" = true))]
171    pub linux_aarch64: Option<String>,
172
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    #[schemars(extend("omitempty" = true))]
175    pub windows_x86_64: Option<String>,
176
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    #[schemars(extend("omitempty" = true))]
179    pub windows_aarch64: Option<String>,
180
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    #[schemars(extend("omitempty" = true))]
183    pub macos_x86_64: Option<String>,
184
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    #[schemars(extend("omitempty" = true))]
187    pub macos_aarch64: Option<String>,
188}
189
190impl Binaries {
191    /// Asset filename for the given platform, if declared.
192    pub fn get(&self, platform: Platform) -> Option<&String> {
193        match platform {
194            Platform::LinuxX86_64 => self.linux_x86_64.as_ref(),
195            Platform::LinuxAarch64 => self.linux_aarch64.as_ref(),
196            Platform::WindowsX86_64 => self.windows_x86_64.as_ref(),
197            Platform::WindowsAarch64 => self.windows_aarch64.as_ref(),
198            Platform::MacosX86_64 => self.macos_x86_64.as_ref(),
199            Platform::MacosAarch64 => self.macos_aarch64.as_ref(),
200        }
201    }
202
203    /// True when no platform has an asset declared.
204    pub fn is_empty(&self) -> bool {
205        self.linux_x86_64.is_none()
206            && self.linux_aarch64.is_none()
207            && self.windows_x86_64.is_none()
208            && self.windows_aarch64.is_none()
209            && self.macos_x86_64.is_none()
210            && self.macos_aarch64.is_none()
211    }
212
213    /// Count of declared platforms.
214    pub fn len(&self) -> usize {
215        [
216            &self.linux_x86_64,
217            &self.linux_aarch64,
218            &self.windows_x86_64,
219            &self.windows_aarch64,
220            &self.macos_x86_64,
221            &self.macos_aarch64,
222        ]
223        .iter()
224        .filter(|o| o.is_some())
225        .count()
226    }
227}
228
229/// One HTTP route a plugin's viewer registers on the host viewer's
230/// embedded axum server. The full path served is
231/// `/plugin/<repository>/<self.path>`; on a hit, the body is
232/// JSON-decoded and forwarded as a `PluginRequest { type: self.type,
233/// value: body }` event to the frontend.
234#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
235#[schemars(rename = "filesystem.plugins.ViewerRoute")]
236pub struct ViewerRoute {
237    /// Path relative to the plugin's namespace. Must start with `/`;
238    /// the host prepends `/plugin/<repository>` before registering.
239    pub path: String,
240
241    /// HTTP method this route handles. Methods other than the listed
242    /// five aren't supported (and don't appear in plugin practice).
243    pub method: HttpMethod,
244
245    /// String tag forwarded to the plugin's iframe as the `type`
246    /// field of the resulting `PluginRequest`. Plugin authors pick
247    /// any value they want; the host doesn't interpret it.
248    #[serde(rename = "type")]
249    pub r#type: String,
250}
251
252/// HTTP methods supported by [`ViewerRoute`]. Serializes as upper-case
253/// (`"GET"`, `"POST"`, …) on the wire.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
255#[schemars(rename = "filesystem.plugins.HttpMethod")]
256#[serde(rename_all = "UPPERCASE")]
257pub enum HttpMethod {
258    Get,
259    Post,
260    Put,
261    Patch,
262    Delete,
263}
264
265/// A [`Manifest`] enriched with the plugin's identifying `name` and
266/// the `source` it was loaded from. Used when listing or describing
267/// installed plugins, where the bare manifest fields are not enough
268/// to identify which plugin they belong to or where they came from.
269///
270/// `name` sits before the manifest body; `source` sits after. The
271/// `manifest` field is `#[serde(flatten)]`-ed so the wire shape is
272/// one flat JSON object — `serde_json`'s `preserve_order` feature
273/// keeps the declared field order, so consumers see `name` first
274/// and `source` last.
275#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
276#[schemars(rename = "filesystem.plugins.ManifestWithNameAndSource")]
277pub struct ManifestWithNameAndSource {
278    /// The plugin's identifier — the filename it lives under in the
279    /// plugins directory (e.g. `psyops` for `~/.objectiveai/plugins/psyops`).
280    pub name: String,
281    #[serde(flatten)]
282    pub manifest: Manifest,
283    /// Where this manifest came from — e.g. an absolute filesystem path,
284    /// a URL, or a registry reference. Free-form string; the host
285    /// just displays it.
286    pub source: String,
287}