Files
SteamFramelyHomebrew--framely/src/model.rs
T

1118 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::{bail, ensure, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Component, Path};
pub const API_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum RunAs {
#[default]
Steamos,
Root,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Backend {
pub entry: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub run_as: RunAs,
#[serde(default)]
pub autostart: bool,
#[serde(default)]
pub restart: RestartPolicy,
#[serde(default = "restart_limit")]
pub restart_limit: u32,
#[serde(
rename = "memoryLimitMiB",
default = "default_memory_limit_mib",
skip_serializing_if = "is_default_memory_limit_mib"
)]
pub memory_limit_mib: u32,
#[serde(default, skip_serializing_if = "is_false")]
pub ui_visibility_events: bool,
}
pub const DEFAULT_MEMORY_LIMIT_MIB: u32 = 512;
fn is_false(v: &bool) -> bool {
!*v
}
fn default_memory_limit_mib() -> u32 {
DEFAULT_MEMORY_LIMIT_MIB
}
fn is_default_memory_limit_mib(value: &u32) -> bool {
*value == DEFAULT_MEMORY_LIMIT_MIB
}
fn restart_limit() -> u32 {
3
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum RestartPolicy {
Never,
#[default]
OnFailure,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Hook {
pub entry: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Lifecycle {
#[serde(default)]
pub run_as: Option<RunAs>,
#[serde(default)]
pub on_install: Option<Hook>,
#[serde(default)]
pub on_update: Option<Hook>,
#[serde(default)]
pub on_uninstall: Option<Hook>,
#[serde(default)]
pub on_crash_cleanup: Option<Hook>,
#[serde(default)]
pub on_start: bool,
#[serde(default)]
pub on_stop: bool,
#[serde(default = "hook_timeout")]
pub timeout_seconds: u64,
}
fn hook_timeout() -> u64 {
10
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Window {
pub entry: String,
pub title: String,
#[serde(default)]
pub dock_icon: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub local_web: bool,
#[serde(default = "window_width")]
pub width: u32,
#[serde(default = "window_height")]
pub height: u32,
#[serde(
default = "window_width_meters",
skip_serializing_if = "Option::is_none"
)]
pub width_meters: Option<f32>,
}
fn window_width() -> u32 {
1600
}
fn window_height() -> u32 {
900
}
fn window_width_meters() -> Option<f32> {
Some(3.0)
}
pub const LAUNCHER_VERSION: &str = "0.4.3-preview.2";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Engines {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub framely: Option<String>,
}
impl Engines {
pub fn validate(&self) -> Result<()> {
if let Some(r) = &self.framely {
crate::relations::requirement(r)?;
}
Ok(())
}
pub fn matches(&self, version: &str) -> bool {
self.framely.as_ref().is_none_or(|r| {
crate::relations::requirement(r)
.ok()
.zip(semver::Version::parse(version).ok())
.is_some_and(|(r, v)| r.matches(&v))
})
}
pub fn check(&self) -> Result<()> {
ensure!(
self.matches(env!("CARGO_PKG_VERSION")),
"Plugin requires Framely {}; current version is {}",
self.framely.as_deref().unwrap_or("*"),
env!("CARGO_PKG_VERSION")
);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum LaunchTarget {
QuickPage,
Window { window: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum LauncherActionTarget {
Window {
window: String,
},
Backend {
method: String,
#[serde(default)]
params: serde_json::Value,
},
Frontend {
entry: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherAction {
pub id: String,
pub label: String,
pub target: LauncherActionTarget,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct LauncherSettings {
#[serde(default = "yes")]
pub primary_trigger: bool,
#[serde(default = "yes")]
pub menu_auto_close: bool,
#[serde(default = "menu_timeout")]
pub menu_timeout_seconds: u32,
#[serde(default = "launcher_categories")]
pub all_categories: Vec<String>,
}
fn launcher_categories() -> Vec<String> {
["plugin", "steam", "lepton", "desktop"]
.into_iter()
.map(str::to_owned)
.collect()
}
fn menu_timeout() -> u32 {
10
}
impl Default for LauncherSettings {
fn default() -> Self {
Self {
primary_trigger: true,
menu_auto_close: true,
menu_timeout_seconds: 10,
all_categories: launcher_categories(),
}
}
}
impl LauncherSettings {
pub fn validate(&self) -> Result<()> {
let unique: std::collections::BTreeSet<_> = self.all_categories.iter().collect();
ensure!(
unique.len() == self.all_categories.len()
&& unique
.iter()
.all(|c| ["plugin", "steam", "lepton", "desktop"].contains(&c.as_str())),
"Invalid launcher categories"
);
ensure!(
(1..=300).contains(&self.menu_timeout_seconds),
"Menu timeout must be between 1 and 300 seconds"
);
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Ui {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub launch: BTreeMap<String, LaunchTarget>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub launcher_actions: Vec<LauncherAction>,
pub quick_page: Option<String>,
#[serde(default)]
pub windows: BTreeMap<String, Window>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Publish {
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub screenshots: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Manifest {
pub schema_version: u32,
pub api_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engines: Option<Engines>,
pub id: String,
pub name: String,
pub version: String,
#[serde(default)]
pub description: String,
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documentation_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub details: String,
// Accept old packages and catalogs without exporting their retired category.
#[serde(default, rename = "category", skip_serializing)]
pub _legacy_category: Option<serde_json::Value>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub screenshots: Vec<String>,
#[serde(default)]
pub changelog: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dependencies: BTreeMap<String, crate::relations::Dependency>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub optional_dependencies: BTreeMap<String, crate::relations::Dependency>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub conflicts: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclusive_resources: Vec<String>,
pub backend: Option<Backend>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle: Option<Lifecycle>,
#[serde(default)]
pub ui: Ui,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish: Option<Publish>,
pub files: BTreeMap<String, String>,
}
impl Manifest {
pub fn relations(&self) -> crate::relations::Relations {
crate::relations::Relations {
dependencies: self.dependencies.clone(),
optional_dependencies: self.optional_dependencies.clone(),
conflicts: self.conflicts.clone(),
exclusive_resources: self.exclusive_resources.clone(),
}
}
pub fn validate(&self) -> Result<()> {
ensure!(
self.schema_version == 1 && self.api_version == API_VERSION,
"Unsupported manifest/API version"
);
if let Some(e) = &self.engines {
e.validate()?;
}
valid_id(&self.id)?;
self.relations().validate(&self.id, &self.version)?;
for link in self
.author_url
.iter()
.chain(self.documentation_url.iter())
.chain(self.homepage.iter())
{
validate_web_link(link)?;
}
if let Some(url) = &self.download_url {
validate_url(url, false)?;
}
if let Some(publish) = &self.publish {
ensure!(publish.screenshots.len() <= 8, "Too many store screenshots");
for image in publish.icon.iter().chain(publish.screenshots.iter()) {
validate_url(image, false)?;
}
}
ensure!(
!self.name.trim().is_empty() && self.name.len() <= 120,
"Invalid plugin name"
);
ensure!(
!self.author.trim().is_empty() && self.author.len() <= 120,
"Invalid author"
);
ensure!(
!self.version.is_empty()
&& self.version.len() <= 64
&& self
.version
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b".-+".contains(&c)),
"Invalid version"
);
ensure!(
self.files.len() <= 2048 && !self.files.is_empty(),
"Invalid file count"
);
for (path, hash) in &self.files {
safe_path(path)?;
ensure!(
hash.len() == 64 && hex::decode(hash)?.len() == 32,
"Invalid SHA256"
);
}
let check = |p: &str| -> Result<()> {
safe_path(p)?;
ensure!(self.files.contains_key(p), "Entry is not hashed: {p}");
Ok(())
};
validate_presentation(
&self.details,
&self.tags,
&self.screenshots,
&self.changelog,
)?;
if let Some(icon) = &self.icon {
check(icon)?;
ensure!(icon.ends_with(".png"), "Plugin icon must be PNG");
}
for screenshot in &self.screenshots {
check(screenshot)?;
ensure!(
screenshot.ends_with(".png")
|| screenshot.ends_with(".jpg")
|| screenshot.ends_with(".jpeg"),
"Screenshot must be PNG/JPEG"
);
}
if let Some(b) = &self.backend {
check(&b.entry)?;
ensure!(
b.memory_limit_mib > 0,
"Backend memoryLimitMiB must be a positive integer"
);
ensure!((1..=10).contains(&b.restart_limit), "Invalid restart limit");
ensure!(
b.args.len() <= 64 && b.args.iter().all(|a| a.len() <= 4096 && !a.contains('\0')),
"Invalid backend arguments"
);
}
if let Some(l) = &self.lifecycle {
ensure!(
(1..=15).contains(&l.timeout_seconds),
"Lifecycle timeout must be 1–15 seconds"
);
ensure!(
self.backend.is_some() || (!l.on_start && !l.on_stop),
"Start/stop hooks require a backend"
);
if let (Some(b), Some(user)) = (&self.backend, l.run_as) {
ensure!(
b.run_as == user,
"Lifecycle and backend must use the same user"
);
}
for hook in [
&l.on_install,
&l.on_update,
&l.on_uninstall,
&l.on_crash_cleanup,
]
.into_iter()
.flatten()
{
check(&hook.entry)?;
ensure!(
hook.args.len() <= 64
&& hook
.args
.iter()
.all(|a| a.len() <= 4096 && !a.contains('\0')),
"Invalid hook arguments"
);
}
}
if let Some(p) = &self.ui.quick_page {
check(p)?;
}
if !self.ui.launch.is_empty() || !self.ui.launcher_actions.is_empty() {
let range = self
.engines
.as_ref()
.and_then(|e| e.framely.as_ref())
.context("Launcher features require engines.framely")?;
let requirement = crate::relations::requirement(range)?;
// Require an explicit floor so older hosts cannot be included accidentally.
let floor = semver::Version::parse(LAUNCHER_VERSION)?;
ensure!(
requirement.comparators.iter().any(|c| {
matches!(
c.op,
semver::Op::Greater
| semver::Op::GreaterEq
| semver::Op::Exact
| semver::Op::Caret
| semver::Op::Tilde
) && c.minor.is_some()
&& c.patch.is_some()
&& semver::Version {
major: c.major,
minor: c.minor.unwrap(),
patch: c.patch.unwrap(),
pre: c.pre.clone(),
build: semver::BuildMetadata::EMPTY,
} >= floor
}),
"Launcher features require an explicit version floor of {} or newer",
LAUNCHER_VERSION
);
}
for (source, target) in &self.ui.launch {
ensure!(
["launcher", "quickPanel", "manager"].contains(&source.as_str()),
"Unknown launch source"
);
match target {
LaunchTarget::QuickPage => {
ensure!(self.ui.quick_page.is_some(), "No quick page declared")
}
LaunchTarget::Window { window } => ensure!(
self.ui.windows.contains_key(window),
"Launch window not declared"
),
}
}
ensure!(
self.ui.launcher_actions.len() <= 16,
"Too many launcher actions"
);
let mut action_ids = std::collections::BTreeSet::new();
for action in &self.ui.launcher_actions {
valid_id(&action.id)?;
ensure!(
action_ids.insert(&action.id)
&& !action.label.is_empty()
&& action.label.len() <= 120,
"Invalid launcher action"
);
match &action.target {
LauncherActionTarget::Window { window } => ensure!(
self.ui.windows.contains_key(window),
"Action window not declared"
),
LauncherActionTarget::Frontend { entry } => check(entry)?,
LauncherActionTarget::Backend { method, .. } => ensure!(
self.backend.is_some()
&& !method.is_empty()
&& method.len() <= 120
&& !method.starts_with("framely."),
"Invalid backend action method"
),
}
}
ensure!(self.ui.windows.len() <= 8, "Too many declared windows");
for (key, w) in &self.ui.windows {
valid_id(key)?;
check(&w.entry)?;
ensure!(
(640..=2560).contains(&w.width) && (360..=1440).contains(&w.height),
"Window size outside supported range"
);
if let Some(width) = w.width_meters {
ensure!(
width.is_finite() && (0.4..=4.0).contains(&width),
"Invalid physical window width"
);
}
ensure!(
!w.title.is_empty() && w.title.len() <= 120,
"Invalid window title"
);
}
Ok(())
}
pub fn memory_limit_mib(&self) -> u32 {
self.backend
.as_ref()
.map(|b| b.memory_limit_mib)
.unwrap_or(DEFAULT_MEMORY_LIMIT_MIB)
}
pub fn run_as(&self) -> RunAs {
self.backend
.as_ref()
.map(|b| b.run_as)
.or_else(|| self.lifecycle.as_ref().and_then(|l| l.run_as))
.unwrap_or_default()
}
}
fn validate_presentation(
details: &str,
tags: &[String],
screenshots: &[String],
changelog: &str,
) -> Result<()> {
ensure!(
details.len() <= 32768 && changelog.len() <= 16384,
"Plugin description too long"
);
ensure!(
tags.len() <= 12 && screenshots.len() <= 8,
"Invalid plugin presentation"
);
ensure!(
tags.iter().all(|t| !t.trim().is_empty() && t.len() <= 80),
"Invalid plugin tags"
);
Ok(())
}
pub fn valid_id(s: &str) -> Result<()> {
ensure!(
!s.is_empty()
&& s.len() <= 80
&& s.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b".-_".contains(&b))
&& !s.starts_with('.')
&& !s.contains(".."),
"Invalid identifier"
);
Ok(())
}
pub fn safe_path(s: &str) -> Result<()> {
ensure!(
!s.is_empty()
&& s.len() <= 512
&& s.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"/._-+".contains(&c)),
"Invalid package path"
);
for c in Path::new(s).components() {
if !matches!(c, Component::Normal(_)) {
bail!("Unsafe package path: {s}");
}
}
ensure!(
!s.split('/').any(|p| p.is_empty() || p == "." || p == ".."),
"Unsafe package path"
);
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Installed {
pub manifest: Manifest,
pub enabled: bool,
pub favorite: bool,
pub order: u32,
pub source: Option<String>,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Source {
pub id: String,
pub name: String,
pub url: String,
#[serde(default = "yes")]
pub enabled: bool,
#[serde(default)]
pub allow_http: bool,
}
fn yes() -> bool {
true
}
impl Source {
pub fn validate(&self) -> Result<()> {
valid_id(&self.id)?;
ensure!(
!self.name.is_empty() && self.name.len() <= 120,
"Invalid source name"
);
validate_url(&self.url, self.allow_http)
}
}
/// User-facing web links may include fragments, but cannot launch other URI schemes.
pub fn validate_web_link(value: &str) -> Result<()> {
ensure!(
value.len() <= 2048 && !value.chars().any(char::is_whitespace),
"Invalid web link"
);
let url = url::Url::parse(value)?;
ensure!(
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none(),
"Invalid web link"
);
Ok(())
}
pub fn validate_url(url: &str, allow_http: bool) -> Result<()> {
ensure!(
url.len() <= 2048
&& (url.starts_with("https://") || (allow_http && url.starts_with("http://"))),
"An HTTPS URL is required (HTTP must be explicitly enabled)"
);
ensure!(
!url.contains('@') && !url.contains('#') && !url.chars().any(char::is_whitespace),
"Invalid URL"
);
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NetworkPanel {
pub enabled: bool,
pub port: u16,
#[serde(default)]
pub password_enabled: bool,
}
impl Default for NetworkPanel {
fn default() -> Self {
Self {
enabled: true,
port: 15915,
password_enabled: false,
}
}
}
impl NetworkPanel {
pub fn validate(&self) -> Result<()> {
ensure!(self.port >= 1024, "端口必须在 1024–65535 之间");
Ok(())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ProxySettings {
#[serde(default)]
pub http_enabled: bool,
#[serde(default)]
pub github_enabled: bool,
#[serde(default)]
pub http: String,
#[serde(default)]
pub github: String,
}
impl ProxySettings {
pub fn validate(&self) -> Result<()> {
ensure!(
!self.http_enabled || !self.http.is_empty(),
"请填写 HTTP 代理地址"
);
ensure!(
!self.github_enabled || !self.github.is_empty(),
"请填写 GitHub 代理地址"
);
for (name, value) in [("HTTP", &self.http), ("GitHub", &self.github)] {
if value.is_empty() {
continue;
}
ensure!(
value.len() <= 2048 && !value.chars().any(char::is_whitespace),
"{name} 代理地址无效"
);
let parsed =
url::Url::parse(value).map_err(|_| anyhow::anyhow!("{name} 代理地址无效"))?;
ensure!(
matches!(parsed.scheme(), "http" | "https")
&& parsed.host_str().is_some()
&& parsed.username().is_empty()
&& parsed.password().is_none()
&& parsed.query().is_none()
&& parsed.fragment().is_none(),
"{name} 代理请填写不含账号密码的 HTTP(S) 地址"
);
if name == "HTTP" {
ensure!(parsed.path() == "/", "HTTP 代理不能包含路径");
}
}
Ok(())
}
}
pub fn default_language() -> String {
"auto".into()
}
pub const AGREEMENT_VERSION: &str = "2026-10-03.1";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgreementAcceptance {
pub version: String,
pub accepted_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Database {
#[serde(default = "default_language")]
pub language: String,
#[serde(default)]
pub agreement_acceptance: Option<AgreementAcceptance>,
#[serde(default)]
pub proxy: ProxySettings,
#[serde(default)]
pub network_panel: NetworkPanel,
pub plugins: BTreeMap<String, Installed>,
pub sources: Vec<Source>,
#[serde(default)]
pub default_sources_initialized: bool,
pub safe_mode: bool,
#[serde(default)]
pub subscriptions: Vec<crate::subscriptions::Subscription>,
#[serde(default)]
pub source_origins: BTreeMap<String, crate::subscriptions::Origins>,
#[serde(default)]
pub source_urls: BTreeMap<String, String>,
#[serde(default)]
pub source_enabled: BTreeMap<String, bool>,
#[serde(default)]
pub update_source: Option<UpdateSource>,
#[serde(default, skip_serializing_if = "UpdateChannel::is_stable")]
pub update_channel: UpdateChannel,
#[serde(default)]
pub update_check: UpdateCheckSettings,
#[serde(default)]
pub update_notice: Option<UpdateNotice>,
#[serde(default)]
pub notification_inbox: BTreeMap<String, NotificationEntry>,
#[serde(default)]
pub notification_settings: NotificationSettings,
#[serde(default)]
pub launcher: LauncherSettings,
#[serde(default)]
pub steam_favorites: std::collections::BTreeSet<u32>,
#[serde(default)]
pub launcher_order: Vec<String>,
#[serde(default)]
pub launcher_favorites: std::collections::BTreeSet<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationPreference {
#[serde(default = "yes")]
pub popups: bool,
#[serde(default = "yes")]
pub badge: bool,
}
impl Default for NotificationPreference {
fn default() -> Self {
Self {
popups: true,
badge: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationSettings {
#[serde(default = "yes")]
pub popups: bool,
#[serde(default = "yes")]
pub badge: bool,
#[serde(default)]
pub plugins: BTreeMap<String, NotificationPreference>,
}
impl Default for NotificationSettings {
fn default() -> Self {
Self {
popups: true,
badge: true,
plugins: BTreeMap::new(),
}
}
}
impl NotificationSettings {
pub fn popups_for(&self, plugin: &str) -> bool {
self.popups && self.plugins.get(plugin).is_none_or(|p| p.popups)
}
pub fn badge_for(&self, plugin: &str) -> bool {
self.badge && self.plugins.get(plugin).is_none_or(|p| p.badge)
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationSettingsPatch {
pub plugin: Option<String>,
pub popups: Option<bool>,
pub badge: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateCheckSettings {
pub enabled: bool,
pub interval_hours: u64,
}
impl Default for UpdateCheckSettings {
fn default() -> Self {
Self {
enabled: true,
interval_hours: 6,
}
}
}
impl UpdateCheckSettings {
pub fn validate(&self) -> Result<()> {
ensure!(
(1..=168).contains(&self.interval_hours),
"自动检查间隔须为 1-168 小时"
);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateNotice {
pub source: UpdateSource,
pub channel: UpdateChannel,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct NotificationEntry {
pub plugin: String,
pub plugin_name: String,
pub created_at: u64,
pub expires_at: Option<u64>,
pub toast: bool,
pub in_inbox: bool,
#[serde(default)]
pub read: bool,
pub notification: Notification,
#[serde(default)]
pub update: Option<UpdateNotice>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Action {
pub id: String,
pub label: String,
#[serde(default)]
pub icon: String,
#[serde(default = "yes")]
pub close_on_click: bool,
#[serde(default = "yes")]
pub remove_from_inbox_on_click: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Notification {
pub id: String,
pub title: String,
pub body: String,
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub actions: Vec<Action>,
#[serde(default = "ttl")]
pub duration_ms: u64,
#[serde(default)]
pub inbox: bool,
}
fn ttl() -> u64 {
8000
}
impl Notification {
pub fn validate(&self) -> Result<()> {
valid_id(&self.id)?;
ensure!(
self.title.len() <= 160 && self.body.len() <= 4096 && self.actions.len() <= 3,
"Notification limit exceeded"
);
ensure!(
self.duration_ms == 0 || (1000..=60000).contains(&self.duration_ms),
"Invalid notification duration"
);
let mut ids = std::collections::BTreeSet::new();
for a in &self.actions {
valid_id(&a.id)?;
ensure!(
!a.label.is_empty()
&& a.label.len() <= 80
&& a.icon.len() <= 16
&& ids.insert(&a.id),
"Invalid/duplicate action"
);
}
if let Some(i) = &self.image {
ensure!(i.len() <= 1024 * 1024, "Notification image is too large");
ensure!(
i.starts_with("data:image/png;base64,")
|| i.starts_with("data:image/jpeg;base64,")
|| i.starts_with("https://"),
"Unsupported image URL"
);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Catalog {
pub schema_version: u32,
pub name: String,
pub plugins: Vec<CatalogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntry {
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub documentation_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compatible: Option<bool>,
pub api_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engines: Option<Engines>,
pub url: String,
pub sha256: String,
#[serde(default)]
pub run_as: Option<RunAs>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub details: String,
// Accept old packages and catalogs without exporting their retired category.
#[serde(default, rename = "category", skip_serializing)]
pub _legacy_category: Option<serde_json::Value>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub screenshots: Vec<String>,
#[serde(default)]
pub changelog: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dependencies: BTreeMap<String, crate::relations::Dependency>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub optional_dependencies: BTreeMap<String, crate::relations::Dependency>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub conflicts: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclusive_resources: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginVersions {
pub schema_version: u32,
pub id: String,
pub versions: Vec<CatalogEntry>,
}
impl CatalogEntry {
pub fn relations(&self) -> crate::relations::Relations {
crate::relations::Relations {
dependencies: self.dependencies.clone(),
optional_dependencies: self.optional_dependencies.clone(),
conflicts: self.conflicts.clone(),
exclusive_resources: self.exclusive_resources.clone(),
}
}
pub fn validate(&self, allow_http: bool) -> Result<()> {
if let Some(e) = &self.engines {
e.validate()?;
}
valid_id(&self.id)?;
self.relations().validate(&self.id, &self.version)?;
for link in self
.author_url
.iter()
.chain(self.documentation_url.iter())
.chain(self.homepage.iter())
{
validate_web_link(link)?;
}
validate_url(&self.url, allow_http)?;
ensure!(
!self.name.trim().is_empty()
&& self.name.len() <= 120
&& self.author.len() <= 120
&& self.description.len() <= 4096
&& self.version.len() <= 64,
"Invalid catalog entry"
);
ensure!(
hex::decode(&self.sha256)?.len() == 32,
"Invalid catalog integrity metadata"
);
validate_presentation(
&self.details,
&self.tags,
&self.screenshots,
&self.changelog,
)?;
if let Some(icon) = &self.icon {
validate_url(icon, allow_http)?;
}
for screenshot in &self.screenshots {
validate_url(screenshot, allow_http)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum UpdateChannel {
#[default]
Stable,
Testing,
}
impl UpdateChannel {
fn is_stable(&self) -> bool {
*self == Self::Stable
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct UpdateSource {
pub url: String,
}
impl UpdateSource {
pub fn validate(&self) -> Result<()> {
validate_url(&self.url, false)?;
Ok(())
}
pub fn github_repository(&self) -> Option<String> {
let url = url::Url::parse(&self.url).ok()?;
let parts: Vec<_> = url.path_segments()?.collect();
if url.host_str() != Some("github.com")
|| url.query().is_some()
|| url.fragment().is_some()
|| parts.len() != 6
|| parts[2..] != ["releases", "latest", "download", "framely-release.json"]
|| parts[..2].iter().any(|p| {
p.is_empty()
|| !p
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"_.-".contains(&c))
})
{
return None;
}
Some(format!("{}/{}", parts[0], parts[1]))
}
}
impl Default for Database {
fn default() -> Self {
serde_json::from_value(serde_json::json!({"plugins":{},"sources":[],"safeMode":false}))
.expect("valid database defaults")
}
}