use std::collections::HashMap; use blake3::Hash; use chrono::{DateTime, Utc}; use ed25519_dalek::Signature; use serde::{Deserialize, Serialize}; use url::Url; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BundleMetadata { pub version: String, pub created_at: DateTime, #[serde(with = "signature_serde")] pub signature: Signature, pub manifest: Manifest, #[serde(default)] pub properties: HashMap, } mod signature_serde { use super::*; use base64::{engine::general_purpose::STANDARD, Engine}; use serde::{de::Error, Deserializer, Serializer}; pub fn serialize(sig: &Signature, serializer: S) -> Result where S: Serializer, { serializer.serialize_str(&STANDARD.encode(sig.to_bytes())) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let s = String::deserialize(deserializer)?; let bytes = STANDARD.decode(&s).map_err(D::Error::custom)?; let bytes: [u8; 64] = bytes .try_into() .map_err(|_| D::Error::custom("invalid signature length"))?; Ok(Signature::from_bytes(&bytes)) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileEntry { pub path: String, pub size: u64, #[serde(with = "hash_serde")] pub hash: Hash, pub mime_type: Option, } mod hash_serde { use super::*; use base64::{engine::general_purpose::STANDARD, Engine}; use blake3::Hash; pub fn serialize(sig: &Hash, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(&STANDARD.encode(sig.as_bytes())) } pub fn deserialize<'de, D>(deserializer: D) -> Result where D: serde::Deserializer<'de>, { use serde::de::Error; let s = String::deserialize(deserializer)?; let bytes = STANDARD.decode(&s).map_err(D::Error::custom)?; let bytes: [u8; 32] = bytes .try_into() .map_err(|_| D::Error::custom("invalid signature length"))?; Ok(Hash::from_bytes(bytes)) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { pub files: Vec, #[serde(default)] pub version: Option, } impl Manifest { pub fn total_size(&self) -> u64 { self.files.iter().map(|f| f.size).sum() } pub fn get_file(&self, path: &str) -> Option<&FileEntry> { self.files.iter().find(|f| f.path == path) } } #[derive(Debug, Clone, Deserialize)] pub struct ApiResponse { pub success: bool, #[serde(default)] pub error: Option, pub data: T, } #[derive(Debug, Clone, Deserialize)] pub struct BundleList { pub bundles: Vec, } #[derive(Debug, Clone, Deserialize)] pub struct BundleSummary { pub name: String, pub version: String, pub created_at: DateTime, pub file_count: usize, } #[derive(Debug, Clone, Deserialize)] pub struct VerificationResponse { pub status: String, pub created_at: DateTime, pub version: String, pub file_count: usize, } #[derive(Debug, Clone, Deserialize)] pub struct PublicKeyInfo { pub key: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DownloadOptions { pub server_url: Url, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DownloadResponse { pub success: bool, pub bundle_name: String, pub server_url: Url, pub version: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct LoadOptions { pub bundle_name: String, /// Optional host override for the webview URL. /// /// When provided, the webview will be loaded with `app://{host}/` instead of /// `app://{bundle_name}/`. This enables cloud-for-orgs support where the same /// bundle serves multiple organization subdomains. /// /// Example: `host: "acme.hoppscotch.io"` will: /// - Sanitize to "acme_hoppscotch_io" /// - Create webview at `app://acme_hoppscotch_io/` /// - Register mapping so file requests resolve to the correct bundle #[serde(default)] pub host: Option, #[serde(default)] pub inline: bool, #[serde(default)] pub window: WindowOptions, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LoadResponse { pub success: bool, pub window_label: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CloseOptions { pub window_label: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CloseResponse { pub success: bool, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoveOptions { pub bundle_name: String, pub server_url: String, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoveResponse { pub success: bool, pub bundle_name: String, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WindowOptions { #[serde(default = "default_window_title")] pub title: String, #[serde(default = "default_window_width")] pub width: f64, #[serde(default = "default_window_height")] pub height: f64, #[serde(default = "default_resizable")] pub resizable: bool, } fn default_window_title() -> String { "Appload".into() } fn default_window_width() -> f64 { 800.0 } fn default_window_height() -> f64 { 600.0 } fn default_resizable() -> bool { true } impl Default for WindowOptions { fn default() -> Self { Self { title: default_window_title(), width: default_window_width(), height: default_window_height(), resizable: default_resizable(), } } }