mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-23 13:01:55 +02:00
1164 lines
36 KiB
Rust
1164 lines
36 KiB
Rust
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>,
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||
pub struct GazeCalibration {
|
||
#[serde(default)]
|
||
pub space: String,
|
||
pub matrix: [f64; 6],
|
||
pub width: u32,
|
||
pub height: u32,
|
||
pub error: f64,
|
||
pub max_error: f64,
|
||
}
|
||
impl GazeCalibration {
|
||
// Preview models have not passed independent validation yet. Accept valid
|
||
// geometry and finite metrics so validation can measure their actual quality.
|
||
pub fn validate_preview(&self) -> Result<()> {
|
||
let m = self.matrix;
|
||
let det = m[0] * m[4] - m[1] * m[3];
|
||
ensure!(
|
||
self.space == "headAngles"
|
||
&& m.iter().all(|v| v.is_finite() && v.abs() <= 4.0)
|
||
&& (0.25..=4.0).contains(&det)
|
||
&& m[2].abs() <= 0.5
|
||
&& m[5].abs() <= 0.5
|
||
&& (320..=8192).contains(&self.width)
|
||
&& (240..=8192).contains(&self.height)
|
||
&& self.error.is_finite()
|
||
&& self.error >= 0.0
|
||
&& self.max_error.is_finite()
|
||
&& self.max_error >= 0.0
|
||
&& self.max_error + 1e-12 >= self.error,
|
||
"Invalid gaze calibration model"
|
||
);
|
||
Ok(())
|
||
}
|
||
pub fn validate(&self) -> Result<()> {
|
||
self.validate_preview()?;
|
||
ensure!(
|
||
self.error <= 0.035 && self.max_error <= 0.065,
|
||
"Gaze calibration validation error exceeds the allowed limit"
|
||
);
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
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 gaze_calibration: Option<GazeCalibration>,
|
||
#[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")
|
||
}
|
||
}
|