1pub 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 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 #[serde(default)]
60 pub signature: Option<signing::ReleaseSignature>,
61 #[serde(default)]
63 pub author_signature: Option<signing::AuthorSignature>,
64 #[serde(default)]
66 pub install_script: Option<Value>,
67 #[serde(default)]
69 pub withdrawn: Vec<WithdrawnRelease>,
70}
71#[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#[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 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 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 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"], ¶ms, 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 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 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 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 pub async fn author_keys(&self) -> Result<Value> {
470 self.request("GET", &["v1", "keys"], &[], None, None).await
471 }
472 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 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 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 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 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 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 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 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 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}