use crate::action::{Action, Error, Result};
use crate::api::{ApiEntry, ApiInput, ApiResult};
use crate::ctx::{BoxFuture, Ctx, Dep};
use crate::i18n::Text;
use crate::screen::{Icon, Screen, ScreenDef, ScreenMeta};
use serde::Serialize;
use std::sync::Arc;
#[derive(Clone, Debug, Serialize)]
pub struct MenuItem {
pub label: Text,
pub screen: &'static str,
}
#[derive(Clone, Debug, Serialize)]
pub struct MenuGroup {
pub label: Text,
pub icon: Icon,
pub items: Vec<MenuItem>,
}
#[derive(Debug, Default)]
pub struct Menu {
groups: Vec<MenuGroup>,
}
impl Menu {
pub fn group(mut self, label: impl Into<Text>, icon: Icon) -> Self {
self.groups.push(MenuGroup { label: label.into(), icon, items: Vec::new() });
self
}
pub fn item<S: ScreenDef>(mut self, label: impl Into<Text>, _screen: S) -> Self {
const { assert!(S::META.route.is_some(), "a menu item must point to a screen that has a route") };
let group = self.groups.last_mut().expect("call `group` before `item`");
group.items.push(MenuItem { label: label.into(), screen: S::META.name });
self
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Route {
pub path: String,
pub screen: &'static str,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum GuardResult {
Allow,
Deny(Text),
}
pub trait Guard: Send + Sync + 'static {
fn check(&self, ctx: &Ctx) -> GuardResult;
fn name(&self) -> &'static str {
std::any::type_name::<Self>()
}
}
impl<F> Guard for F
where
F: Fn(&Ctx) -> GuardResult + Send + Sync + 'static,
{
fn check(&self, ctx: &Ctx) -> GuardResult {
self(ctx)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct SignedIn;
impl Guard for SignedIn {
fn check(&self, ctx: &Ctx) -> GuardResult {
if ctx.identity().is_signed_in() {
GuardResult::Allow
} else {
GuardResult::Deny(Text::key("zeyn.error.forbidden"))
}
}
fn name(&self) -> &'static str {
"SignedIn"
}
}
#[derive(Clone, Copy)]
pub struct ScreenEntry {
pub meta: ScreenMeta,
pub deps: fn() -> Vec<Dep>,
pub build: fn(Ctx, serde_json::Value) -> BoxFuture<Result<Screen>>,
}
fn build_erased<S: ScreenDef>(ctx: Ctx, params: serde_json::Value) -> BoxFuture<Result<Screen>> {
match serde_json::from_value::<S::Params>(params) {
Ok(p) => S::build(ctx, p),
Err(_) => Box::pin(async { Err(Error::NotFound) }),
}
}
pub struct Feature {
name: &'static str,
prefix: String,
screens: Vec<ScreenEntry>,
guards: Vec<Arc<dyn Guard>>,
menu: Vec<MenuGroup>,
apis: Vec<ApiEntry>,
media: Vec<crate::media::MediaEntry>,
commands: Vec<crate::command::Command>,
searches: Vec<crate::command::Search>,
#[cfg(feature = "server")]
files: Vec<crate::files::FileEntry>,
#[cfg(feature = "server")]
scanner: Option<Arc<dyn crate::files::FileScanner>>,
}
impl Feature {
pub fn new(name: &'static str) -> Self {
Self {
name,
prefix: String::new(),
screens: Vec::new(),
guards: Vec::new(),
menu: Vec::new(),
apis: Vec::new(),
commands: Vec::new(),
searches: Vec::new(),
media: Vec::new(),
#[cfg(feature = "server")]
files: Vec::new(),
#[cfg(feature = "server")]
scanner: None,
}
}
pub fn command(mut self, command: crate::command::Command) -> Self {
self.commands.push(command);
self
}
pub fn commands(&self) -> &[crate::command::Command] {
&self.commands
}
pub fn search<A>(mut self, action: A) -> Self
where
A: crate::action::Action<Input = String, Output = crate::action::Result<Vec<crate::command::SearchHit>>>,
{
self.searches.push(crate::command::Search::of(action));
self
}
pub fn searches(&self) -> &[crate::command::Search] {
&self.searches
}
pub fn api<A>(mut self, _action: A) -> Self
where
A: Action,
A::Input: ApiInput,
A::Output: ApiResult,
{
self.apis.push(ApiEntry::of::<A>());
self
}
pub fn apis(&self) -> &[ApiEntry] {
&self.apis
}
pub fn media(
mut self,
name: &'static str,
permission: &'static str,
source: impl crate::media::MediaSource,
) -> Self {
self.media.push(crate::media::MediaEntry { name, permission, source: Arc::new(source) });
self
}
pub fn media_sources(&self) -> &[crate::media::MediaEntry] {
&self.media
}
#[cfg(feature = "server")]
pub fn files(
mut self,
rules: crate::files::FileRules,
permission: &'static str,
store: impl crate::files::FileStore,
) -> Self {
self.files.push(crate::files::FileEntry { rules, permission, store: Arc::new(store) });
self
}
#[cfg(feature = "server")]
pub fn file_stores(&self) -> &[crate::files::FileEntry] {
&self.files
}
#[cfg(feature = "server")]
pub fn scan_files(mut self, scanner: impl crate::files::FileScanner) -> Self {
self.scanner = Some(Arc::new(scanner));
self
}
#[cfg(feature = "server")]
pub fn file_scanner(&self) -> Option<&Arc<dyn crate::files::FileScanner>> {
self.scanner.as_ref()
}
pub fn name(&self) -> &'static str {
self.name
}
pub fn route_prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into().trim_end_matches('/').to_string();
self
}
pub fn guard(mut self, guard: impl Guard) -> Self {
self.guards.push(Arc::new(guard));
self
}
pub fn screen<S: ScreenDef>(mut self, _screen: S) -> Self {
self.screens.push(ScreenEntry { meta: S::META, deps: S::deps, build: build_erased::<S> });
self
}
pub fn menu(mut self, build: impl FnOnce(Menu) -> Menu) -> Self {
self.menu = build(Menu::default()).groups;
self
}
pub fn menu_groups(&self) -> &[MenuGroup] {
&self.menu
}
pub fn screens(&self) -> &[ScreenEntry] {
&self.screens
}
pub fn guards(&self) -> &[Arc<dyn Guard>] {
&self.guards
}
pub fn check_guards(&self, ctx: &Ctx) -> Result<()> {
for g in &self.guards {
if let GuardResult::Deny(reason) = g.check(ctx) {
return Err(Error::Denied(reason));
}
}
Ok(())
}
pub fn path_of(&self, meta: &ScreenMeta) -> Option<String> {
meta.route.map(|r| format!("{}{r}", self.prefix))
}
pub fn routes(&self) -> Vec<Route> {
self.screens
.iter()
.filter_map(|s| self.path_of(&s.meta).map(|path| Route { path, screen: s.meta.name }))
.collect()
}
pub fn check(&self) -> std::result::Result<(), Vec<String>> {
let mut problems = Vec::new();
let routes = self.routes();
for (i, r) in routes.iter().enumerate() {
if routes[..i].iter().any(|o| o.path == r.path) {
problems.push(format!("route `{}` is used by more than one screen", r.path));
}
}
for (i, s) in self.screens.iter().enumerate() {
if self.screens[..i].iter().any(|o| o.meta.name == s.meta.name) {
problems.push(format!("screen `{}` is registered twice", s.meta.name));
}
}
for (i, m) in self.media.iter().enumerate() {
if !crate::media::valid_media_id(m.name) {
problems.push(format!("media source `{}` must use only letters, digits, `-`, `_` and `.`", m.name));
}
if self.media[..i].iter().any(|o| o.name == m.name) {
problems.push(format!("media source `{}` is registered twice", m.name));
}
}
for item in self.menu.iter().flat_map(|g| &g.items) {
match self.screens.iter().find(|s| s.meta.name == item.screen) {
Some(s) if s.meta.route.is_none() => problems.push(format!(
"menu item `{}` points to screen `{}`, which has no route",
item.label.english(),
item.screen
)),
Some(_) => {}
None => problems.push(format!(
"menu item `{}` points to screen `{}`, which is not registered in feature `{}`",
item.label.english(),
item.screen,
self.name
)),
}
}
let commands: Vec<(&str, &crate::command::Command)> = self.commands.iter().map(|c| (self.name, c)).collect();
problems.extend(crate::command::problems(&commands, true, crate::command::PALETTE_SHORTCUT));
if problems.is_empty() { Ok(()) } else { Err(problems) }
}
}