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}