Skip to main content

silicon_apps_client/
lib.rs

1//! Primary Silicon Apps interface. Clients read no environment or local files implicitly.
2//! Persistent operations take an explicit [`LocalState`]; callers own credentials and configuration.
3pub mod auth;
4pub mod docs;
5pub mod events;
6pub mod install;
7pub mod signing;
8pub mod state;
9pub mod updater;
10pub use silicon_apps_package as package;
11pub use state::{Config, LocalState};
12
13use anyhow::{Context, Result, bail, ensure};
14use reqwest::{Client as HttpClient, Method};
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use std::time::Duration;
18use url::Url;
19
20pub const APP_ID: &str = "silicon-apps";
21pub const DEFAULT_URL: &str = "https://apps.teamofsilicons.com";
22pub const VERSION: &str = env!("CARGO_PKG_VERSION");
23
24#[derive(Clone)]
25pub struct Client {
26    http: HttpClient,
27    /// For event streams: no total timeout, only a read timeout longer than
28    /// the server's 15 second heartbeat.
29    stream_http: HttpClient,
30    base: Url,
31    token: Option<String>,
32    telemetry: bool,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Package {
37    pub id: String,
38    pub target: String,
39    pub sha256: String,
40    pub size: u64,
41    pub command: String,
42}
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Release {
45    pub id: String,
46    pub app_id: String,
47    pub channel: String,
48    pub version: String,
49    #[serde(default)]
50    pub package_ids: Vec<String>,
51}
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Resolution {
54    pub app_id: String,
55    pub release: Release,
56    pub package: Package,
57    pub download_path: String,
58    /// The API's signature over this package's release manifest.
59    #[serde(default)]
60    pub signature: Option<signing::ReleaseSignature>,
61    /// The uploading author's own signature, when there is one.
62    #[serde(default)]
63    pub author_signature: Option<signing::AuthorSignature>,
64    /// The install script this target runs, as the service recorded it.
65    #[serde(default)]
66    pub install_script: Option<Value>,
67    /// Withdrawn releases on the same channel, newest first.
68    #[serde(default)]
69    pub withdrawn: Vec<WithdrawnRelease>,
70}
71/// A release its authors withdrew. It is never served.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct WithdrawnRelease {
74    pub release_id: String,
75    pub version: String,
76    pub channel: String,
77    pub reason: String,
78    pub withdrawn_at: String,
79    #[serde(default)]
80    pub withdrawn_by: String,
81}
82
83/// An error answer from the Apps API, with its stable code. The text form is
84/// `code (HTTP status): message`, then the hint and details.
85#[derive(Debug, Clone)]
86pub struct ApiError {
87    pub status: u16,
88    pub code: String,
89    pub message: String,
90    pub hint: String,
91    pub details: Value,
92}
93impl std::fmt::Display for ApiError {
94    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95        write!(
96            f,
97            "{} (HTTP {}): {}\nHint: {}",
98            self.code, self.status, self.message, self.hint
99        )?;
100        if !self.details.is_null() {
101            write!(
102                f,
103                "\nDetails: {}",
104                serde_json::to_string_pretty(&self.details).unwrap_or_default()
105            )?;
106        }
107        Ok(())
108    }
109}
110impl std::error::Error for ApiError {}
111
112impl Client {
113    pub fn new(base: &str, token: Option<String>) -> Result<Self> {
114        let base = Url::parse(base).context("Apps server URL must be an absolute HTTP(S) URL")?;
115        ensure!(
116            base.scheme() == "https"
117                || (base.scheme() == "http"
118                    && matches!(
119                        base.host_str(),
120                        Some("127.0.0.1" | "localhost" | "[::1]" | "::1")
121                    )),
122            "Apps server URL must use HTTPS, except localhost development"
123        );
124        ensure!(
125            base.username().is_empty()
126                && base.password().is_none()
127                && base.query().is_none()
128                && base.fragment().is_none(),
129            "Apps server URL cannot contain credentials, query or fragment"
130        );
131        let http = HttpClient::builder()
132            .user_agent(format!("silicon-apps/{VERSION}"))
133            .connect_timeout(Duration::from_secs(10))
134            .timeout(Duration::from_secs(120))
135            .redirect(reqwest::redirect::Policy::none())
136            .build()?;
137        let stream_http = HttpClient::builder()
138            .user_agent(format!("silicon-apps/{VERSION}"))
139            .connect_timeout(Duration::from_secs(10))
140            .read_timeout(Duration::from_secs(60))
141            .redirect(reqwest::redirect::Policy::none())
142            .build()?;
143        Ok(Self {
144            http,
145            stream_http,
146            base,
147            token,
148            telemetry: true,
149        })
150    }
151    pub fn authenticated(&self, token: Option<String>) -> Self {
152        Self {
153            http: self.http.clone(),
154            stream_http: self.stream_http.clone(),
155            base: self.base.clone(),
156            token,
157            telemetry: self.telemetry,
158        }
159    }
160    /// Apply diagnostic opt-out to every Apps API request, including uploads and downloads.
161    pub fn with_telemetry(mut self, enabled: bool) -> Self {
162        self.telemetry = enabled;
163        self
164    }
165    pub fn base_url(&self) -> &str {
166        self.base.as_str()
167    }
168    fn url(&self, segments: &[&str]) -> Result<Url> {
169        let mut url = self.base.clone();
170        {
171            let mut parts = url
172                .path_segments_mut()
173                .map_err(|_| anyhow::anyhow!("server URL has no path"))?;
174            parts.pop_if_empty();
175            parts.extend(segments);
176        }
177        Ok(url)
178    }
179    async fn response(
180        &self,
181        method: Method,
182        path: &[&str],
183        query: &[(&str, String)],
184        body: Option<Value>,
185        key: Option<&str>,
186    ) -> Result<reqwest::Response> {
187        let mut url = self.url(path)?;
188        if !query.is_empty() {
189            url.query_pairs_mut()
190                .extend_pairs(query.iter().map(|(k, v)| (*k, v.as_str())));
191        }
192        let mut request = self.http.request(method.clone(), url).header(
193            "X-Apps-Telemetry",
194            if self.telemetry { "on" } else { "off" },
195        );
196        if let Some(token) = &self.token {
197            request = request.bearer_auth(token);
198        }
199        if let Some(body) = body {
200            request = request.json(&body);
201        }
202        let operation_key = (method != Method::GET).then(|| {
203            key.map(str::to_owned)
204                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
205        });
206        if let Some(key) = &operation_key {
207            request = request.header("Idempotency-Key", key);
208        }
209        let result = async {
210            check_response(request.send().await.with_context(|| {
211                format!(
212                    "{method} /{}: could not reach Silicon Apps at {}",
213                    path.join("/"),
214                    self.base
215                )
216            })?)
217            .await
218        }
219        .await;
220        if let Some(key) = operation_key {
221            result.with_context(||format!("Operation Idempotency-Key: {key}. To retry an identical request after an unknown outcome, pass --idempotency-key {key}."))
222        } else {
223            result
224        }
225    }
226    /// Author/user endpoint escape hatch; CLI features all call this primary package interface.
227    pub async fn request(
228        &self,
229        method: &str,
230        path: &[&str],
231        query: &[(&str, String)],
232        body: Option<Value>,
233        idempotency_key: Option<&str>,
234    ) -> Result<Value> {
235        let method = Method::from_bytes(method.as_bytes())?;
236        let operation_key = (method != Method::GET).then(|| {
237            idempotency_key
238                .map(str::to_owned)
239                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string())
240        });
241        let response = self
242            .response(method, path, query, body, operation_key.as_deref())
243            .await?;
244        if response.status() == reqwest::StatusCode::NO_CONTENT {
245            return Ok(json!({}));
246        }
247        response.json().await.with_context(|| {
248            match operation_key {
249                Some(key) => format!("Silicon Apps returned malformed JSON after a mutation. Operation Idempotency-Key: {key}. Retry the identical request with --idempotency-key {key}."),
250                None => "Silicon Apps returned malformed JSON".into(),
251            }
252        })
253    }
254    pub async fn me(&self) -> Result<Value> {
255        self.request("GET", &["v1", "me"], &[], None, None).await
256    }
257    /// Record this signed-in account's platform for the author-visible target population.
258    pub async fn register_platform(&self, target: &str) -> Result<Value> {
259        self.request(
260            "POST",
261            &["v1", "platforms"],
262            &[],
263            Some(json!({"target":target})),
264            None,
265        )
266        .await
267    }
268    pub async fn search(&self, query: &str, private: bool, mine: bool) -> Result<Value> {
269        let mut params = vec![("q", query.to_owned()), ("mine", mine.to_string())];
270        if private {
271            params.push(("visibility", "private".into()));
272        }
273        self.request("GET", &["v1", "apps"], &params, None, None)
274            .await
275    }
276    pub async fn app(&self, id: &str) -> Result<Value> {
277        self.request("GET", &["v1", "apps", id], &[], None, None)
278            .await
279    }
280    pub async fn available(&self, id: &str) -> Result<Value> {
281        self.request("GET", &["v1", "apps", "availability", id], &[], None, None)
282            .await
283    }
284    pub async fn create(
285        &self,
286        id: &str,
287        name: &str,
288        description: &str,
289        logo: Option<&str>,
290        key: Option<&str>,
291    ) -> Result<Value> {
292        self.request("POST", &["v1","apps"], &[], Some(json!({"app_id":id,"name":name,"description":description,"logo":logo.unwrap_or("")})), key).await
293    }
294    pub async fn edit(&self, id: &str, changes: Value, key: Option<&str>) -> Result<Value> {
295        self.request("PATCH", &["v1", "apps", id], &[], Some(changes), key)
296            .await
297    }
298    pub async fn action(
299        &self,
300        method: &str,
301        app: &str,
302        rest: &[&str],
303        body: Option<Value>,
304        key: Option<&str>,
305    ) -> Result<Value> {
306        let mut p = vec!["v1", "apps", app];
307        p.extend_from_slice(rest);
308        self.request(method, &p, &[], body, key).await
309    }
310    pub async fn resolve(&self, spec: &install::InstallSpec, target: &str) -> Result<Resolution> {
311        let mut query = vec![
312            ("channel", spec.channel.clone()),
313            ("target", target.to_owned()),
314        ];
315        if let Some(v) = &spec.version {
316            query.push(("version", v.clone()));
317        }
318        let value = self
319            .request(
320                "GET",
321                &["v1", "apps", &spec.app_id, "resolve"],
322                &query,
323                None,
324                None,
325            )
326            .await?;
327        serde_json::from_value(value).context("invalid release resolution returned by Silicon Apps")
328    }
329    pub async fn upload(
330        &self,
331        app: &str,
332        target: &str,
333        bytes: Vec<u8>,
334        key: Option<&str>,
335    ) -> Result<Value> {
336        self.upload_signed(app, target, bytes, None, key).await
337    }
338    /// Upload a package, signed with your author key when one is given.
339    pub async fn upload_signed(
340        &self,
341        app: &str,
342        target: &str,
343        bytes: Vec<u8>,
344        author: Option<&signing::AuthorKey>,
345        key: Option<&str>,
346    ) -> Result<Value> {
347        ensure!(
348            package::TARGETS.contains(&target),
349            "unknown target `{target}`"
350        );
351        let manifest = package::inspect_archive(&bytes)?;
352        ensure!(
353            manifest.app_id == app,
354            "package app_id `{}` does not match `{app}`",
355            manifest.app_id
356        );
357        ensure!(
358            manifest.targets.contains_key(target),
359            "package does not contain target `{target}`"
360        );
361        let operation_key = key
362            .map(str::to_owned)
363            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
364        let mut request = self
365            .http
366            .post(self.url(&["v1", "apps", app, "packages", target])?)
367            .header("Content-Type", "application/gzip")
368            .header(
369                "X-Apps-Telemetry",
370                if self.telemetry { "on" } else { "off" },
371            )
372            .header("Idempotency-Key", &operation_key);
373        if let Some(author) = author {
374            request = request
375                .header("X-Apps-Author-Key-Id", &author.key_id)
376                .header(
377                    "X-Apps-Author-Signature",
378                    author.sign_package(app, target, &bytes)?,
379                );
380        }
381        let mut request = request.body(bytes);
382        if let Some(token) = &self.token {
383            request = request.bearer_auth(token);
384        }
385        let result = async {
386            check_response(request.send().await?)
387                .await?
388                .json()
389                .await
390                .context("invalid package upload response")
391        }
392        .await;
393        result.with_context(||format!("Upload Idempotency-Key: {operation_key}. Retry identical bytes with --idempotency-key {operation_key} after an unknown outcome."))
394    }
395    pub async fn download(&self, resolution: &Resolution) -> Result<Vec<u8>> {
396        let url = self.base.join(&resolution.download_path)?;
397        ensure!(
398            url.origin() == self.base.origin(),
399            "server returned a download URL on another origin; refusing to expose credentials"
400        );
401        let mut req = self.http.get(url).header(
402            "X-Apps-Telemetry",
403            if self.telemetry { "on" } else { "off" },
404        );
405        if let Some(token) = &self.token {
406            req = req.bearer_auth(token);
407        }
408        let mut response = check_response(req.send().await?).await?;
409        ensure!(
410            response.content_length().unwrap_or(0) <= package::MAX_ARCHIVE_BYTES,
411            "download is too large"
412        );
413        let mut bytes = Vec::new();
414        while let Some(chunk) = response.chunk().await? {
415            ensure!(
416                (bytes.len() + chunk.len()) as u64 <= package::MAX_ARCHIVE_BYTES,
417                "download exceeds package size limit"
418            );
419            bytes.extend_from_slice(&chunk);
420        }
421        let digest = package::sha256(&bytes);
422        if bytes.len() as u64 != resolution.package.size
423            || digest != resolution.package.sha256.to_ascii_lowercase()
424        {
425            return Err(anyhow::Error::new(signing::VerificationError {
426                code: "checksum_mismatch",
427                message: format!(
428                    "SHA-256 checksum mismatch: expected {} ({} bytes), received {digest} ({} bytes); downloaded package was not installed",
429                    resolution.package.sha256,
430                    resolution.package.size,
431                    bytes.len()
432                ),
433                hint: "Nothing was installed. Try again; if it repeats, report it with silicon-apps report.".into(),
434                details: json!({"expected":{"sha256":resolution.package.sha256,"size":resolution.package.size},"received":{"sha256":digest,"size":bytes.len()}}),
435            }));
436        }
437        Ok(bytes)
438    }
439    /// The public keys that sign this service's releases.
440    pub async fn signing_keys(&self) -> Result<Value> {
441        self.request(
442            "GET",
443            &[".well-known", "silicon-apps-keys.json"],
444            &[],
445            None,
446            None,
447        )
448        .await
449    }
450    /// Withdraw a bad release. It stops being served; installs and the
451    /// updater move to the latest good release on its channel.
452    pub async fn withdraw_release(
453        &self,
454        app: &str,
455        release_id: &str,
456        reason: &str,
457        key: Option<&str>,
458    ) -> Result<Value> {
459        self.action(
460            "POST",
461            app,
462            &["releases", release_id, "withdraw"],
463            Some(json!({"reason":reason})),
464            key,
465        )
466        .await
467    }
468    /// Your registered author keys.
469    pub async fn author_keys(&self) -> Result<Value> {
470        self.request("GET", &["v1", "keys"], &[], None, None).await
471    }
472    /// Register an author public key (base64 Ed25519).
473    pub async fn add_author_key(
474        &self,
475        public_key: &str,
476        name: Option<&str>,
477        key: Option<&str>,
478    ) -> Result<Value> {
479        let mut body = json!({"public_key":public_key});
480        if let Some(name) = name {
481            body["name"] = json!(name);
482        }
483        self.request("POST", &["v1", "keys"], &[], Some(body), key)
484            .await
485    }
486    /// Revoke an author key so nothing new can be signed with it.
487    pub async fn revoke_author_key(
488        &self,
489        key_id: &str,
490        reason: Option<&str>,
491        key: Option<&str>,
492    ) -> Result<Value> {
493        let body = reason
494            .map(|r| json!({"reason":r}))
495            .unwrap_or_else(|| json!({}));
496        self.request("DELETE", &["v1", "keys", key_id], &[], Some(body), key)
497            .await
498    }
499    /// What this server supports, answering `require` queries such as
500    /// `streaming`, `subscriptions` or `target:linux-x86_64` in `requirements`.
501    pub async fn capabilities(&self, require: &[&str]) -> Result<Value> {
502        let query = if require.is_empty() {
503            vec![]
504        } else {
505            vec![("require", require.join(","))]
506        };
507        self.request("GET", &["v1", "capabilities"], &query, None, None)
508            .await
509    }
510    /// One page of a feed as JSON, after the event seq `after`.
511    pub async fn events(
512        &self,
513        feed: &events::Feed,
514        after: Option<i64>,
515        types: &[&str],
516        limit: Option<u32>,
517    ) -> Result<Value> {
518        let path = feed.path(false);
519        let path: Vec<&str> = path.iter().map(String::as_str).collect();
520        let mut query = feed.query();
521        if let Some(after) = after {
522            query.push(("after", after.to_string()));
523        }
524        if !types.is_empty() {
525            query.push(("types", types.join(",")));
526        }
527        if let Some(limit) = limit {
528            query.push(("limit", limit.to_string()));
529        }
530        self.request("GET", &path, &query, None, None).await
531    }
532    /// Open a server-sent event stream. Without `last_event_id` it starts at
533    /// the newest event, or where a subscription's stream last stopped.
534    pub async fn stream_events(
535        &self,
536        feed: &events::Feed,
537        last_event_id: Option<&str>,
538        types: &[&str],
539    ) -> Result<events::EventStream> {
540        let path = feed.path(true);
541        let path: Vec<&str> = path.iter().map(String::as_str).collect();
542        let mut url = self.url(&path)?;
543        {
544            let mut pairs = url.query_pairs_mut();
545            for (k, v) in feed.query() {
546                pairs.append_pair(k, &v);
547            }
548            if !types.is_empty() {
549                pairs.append_pair("types", &types.join(","));
550            }
551        }
552        if url.query() == Some("") {
553            url.set_query(None);
554        }
555        let mut request = self
556            .stream_http
557            .get(url)
558            .header("Accept", "text/event-stream")
559            .header(
560                "X-Apps-Telemetry",
561                if self.telemetry { "on" } else { "off" },
562            );
563        if let Some(id) = last_event_id {
564            request = request.header("Last-Event-ID", id);
565        }
566        if let Some(token) = &self.token {
567            request = request.bearer_auth(token);
568        }
569        let response = check_response(request.send().await.with_context(|| {
570            format!(
571                "could not reach Silicon Apps at {} to stream events",
572                self.base
573            )
574        })?)
575        .await?;
576        Ok(events::EventStream::new(response))
577    }
578    /// Your subscriptions. `status` is active, paused, cancelled or all;
579    /// the default lists active and paused ones.
580    pub async fn subscriptions(&self, status: Option<&str>) -> Result<Value> {
581        let query = status
582            .map(|s| vec![("status", s.to_owned())])
583            .unwrap_or_default();
584        self.request("GET", &["v1", "subscriptions"], &query, None, None)
585            .await
586    }
587    pub async fn subscription(&self, id: &str) -> Result<Value> {
588        self.request("GET", &["v1", "subscriptions", id], &[], None, None)
589            .await
590    }
591    /// Subscribe. A webhook subscription's `secret` is in the result once.
592    pub async fn create_subscription(
593        &self,
594        subscription: &events::NewSubscription,
595        key: Option<&str>,
596    ) -> Result<Value> {
597        self.request(
598            "POST",
599            &["v1", "subscriptions"],
600            &[],
601            Some(subscription.body()),
602            key,
603        )
604        .await
605    }
606    /// Change types, channels, delivery, description or status.
607    pub async fn update_subscription(
608        &self,
609        id: &str,
610        changes: Value,
611        key: Option<&str>,
612    ) -> Result<Value> {
613        self.request(
614            "PATCH",
615            &["v1", "subscriptions", id],
616            &[],
617            Some(changes),
618            key,
619        )
620        .await
621    }
622    pub async fn pause_subscription(&self, id: &str, key: Option<&str>) -> Result<Value> {
623        self.update_subscription(id, json!({"status":"paused"}), key)
624            .await
625    }
626    pub async fn resume_subscription(&self, id: &str, key: Option<&str>) -> Result<Value> {
627        self.update_subscription(id, json!({"status":"active"}), key)
628            .await
629    }
630    pub async fn cancel_subscription(&self, id: &str, key: Option<&str>) -> Result<Value> {
631        self.request("DELETE", &["v1", "subscriptions", id], &[], None, key)
632            .await
633    }
634    pub async fn subscription_deliveries(&self, id: &str, status: Option<&str>) -> Result<Value> {
635        let query = status
636            .map(|s| vec![("status", s.to_owned())])
637            .unwrap_or_default();
638        self.request(
639            "GET",
640            &["v1", "subscriptions", id, "deliveries"],
641            &query,
642            None,
643            None,
644        )
645        .await
646    }
647    pub async fn rotate_subscription_secret(&self, id: &str, key: Option<&str>) -> Result<Value> {
648        self.request(
649            "POST",
650            &["v1", "subscriptions", id, "secret", "rotate"],
651            &[],
652            None,
653            key,
654        )
655        .await
656    }
657    /// Queue a signed `ping` delivery to a webhook subscription.
658    pub async fn ping_subscription(&self, id: &str, key: Option<&str>) -> Result<Value> {
659        self.request("POST", &["v1", "subscriptions", id, "ping"], &[], None, key)
660            .await
661    }
662    pub async fn report(
663        &self,
664        message: &str,
665        pr: Option<&str>,
666        key: Option<&str>,
667    ) -> Result<Value> {
668        self.request(
669            "POST",
670            &["v1", "reports"],
671            &[],
672            Some(json!({"message":message,"pr":pr})),
673            key,
674        )
675        .await
676    }
677}
678
679async fn check_response(response: reqwest::Response) -> Result<reqwest::Response> {
680    if response.status().is_success() {
681        return Ok(response);
682    }
683    let status = response.status();
684    let text = response.text().await.unwrap_or_default();
685    if let Ok(value) = serde_json::from_str::<Value>(&text) {
686        let e = &value["error"];
687        return Err(anyhow::Error::new(ApiError {
688            status: status.as_u16(),
689            code: e["code"].as_str().unwrap_or("api_error").into(),
690            message: e["message"].as_str().unwrap_or("request failed").into(),
691            hint: e["hint"]
692                .as_str()
693                .unwrap_or("Inspect the request arguments and try again.")
694                .into(),
695            details: e["details"].clone(),
696        }));
697    }
698    bail!(
699        "Silicon Apps returned HTTP {} with unexpected body: {}",
700        status.as_u16(),
701        text.chars().take(500).collect::<String>()
702    )
703}