use serde::{Deserialize, Serialize};
mod ids;
pub use ids::{NodeKey, RouteViewId};
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SurfaceTargetId(pub String);
impl SurfaceTargetId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
pub const ENGINE_SERVAL_WEB: &str = "serval.web";
pub const ENGINE_SERVAL_INTERACTIVE: &str = "serval.interactive";
pub const ENGINE_SERVAL_SCRIPTED: &str = "serval.scripted";
pub const ENGINE_SERVAL_SCRIPTED_NOVA: &str = "serval.scripted.nova";
pub const ENGINE_SERVAL_FULLWEB: &str = "serval.fullweb";
pub const ENGINE_SCRYING_WEB: &str = "scrying.web";
pub const ENGINE_GRAFT_SERVO: &str = "graft.servo";
pub const ENGINE_WELD_CHROMIUM: &str = "weld.chromium";
pub const ENGINE_NEMATIC_FEED: &str = "nematic.feed";
pub const ENGINE_NEMATIC_FILE: &str = "nematic.file";
pub const ENGINE_NEMATIC_FINGER: &str = "nematic.finger";
pub const ENGINE_NEMATIC_GEMTEXT: &str = "nematic.gemtext";
pub const ENGINE_NEMATIC_GOPHER: &str = "nematic.gopher";
pub const ENGINE_NEMATIC_GUPPY: &str = "nematic.guppy";
pub const ENGINE_NEMATIC_KNOT: &str = "nematic.knot";
pub const ENGINE_NEMATIC_KNOT_DJOT: &str = "nematic.knot-djot";
pub const ENGINE_NEMATIC_MARKDOWN: &str = "nematic.markdown";
pub const ENGINE_NEMATIC_MISFIN: &str = "nematic.misfin";
pub const ENGINE_NEMATIC_NEX: &str = "nematic.nex";
pub const ENGINE_NEMATIC_SCROLL: &str = "nematic.scroll";
pub const ENGINE_NEMATIC_TEXT: &str = "nematic.text";
pub const ENGINE_NEMATIC_TITAN: &str = "nematic.titan";
pub const ENGINE_EXTERNAL_PROTOCOL: &str = "host.external-protocol";
pub fn is_surface_engine(engine_id: &str) -> bool {
matches!(
engine_id,
ENGINE_SCRYING_WEB | ENGINE_GRAFT_SERVO | ENGINE_WELD_CHROMIUM
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ServalRung {
Static,
Interactive,
Scripted,
FullWeb,
}
impl ServalRung {
pub const ALL: [ServalRung; 4] = [
ServalRung::Static,
ServalRung::Interactive,
ServalRung::Scripted,
ServalRung::FullWeb,
];
pub fn engine_id(self) -> &'static str {
match self {
ServalRung::Static => ENGINE_SERVAL_WEB,
ServalRung::Interactive => ENGINE_SERVAL_INTERACTIVE,
ServalRung::Scripted => ENGINE_SERVAL_SCRIPTED,
ServalRung::FullWeb => ENGINE_SERVAL_FULLWEB,
}
}
pub fn label(self) -> &'static str {
match self {
ServalRung::Static => "Static",
ServalRung::Interactive => "Interactive",
ServalRung::Scripted => "Scripted",
ServalRung::FullWeb => "Full Web",
}
}
}
pub fn serval_rung(engine_id: &str) -> Option<ServalRung> {
match engine_id {
ENGINE_SERVAL_WEB => Some(ServalRung::Static),
ENGINE_SERVAL_INTERACTIVE => Some(ServalRung::Interactive),
ENGINE_SERVAL_SCRIPTED | ENGINE_SERVAL_SCRIPTED_NOVA => Some(ServalRung::Scripted),
ENGINE_SERVAL_FULLWEB => Some(ServalRung::FullWeb),
_ => None,
}
}
pub fn is_serval_rung(engine_id: &str) -> bool {
serval_rung(engine_id).is_some()
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct WorkspaceRouteId(pub String);
impl WorkspaceRouteId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineRouteRequest {
pub workspace_id: WorkspaceRouteId,
pub view: Option<RouteViewId>,
pub node: Option<NodeKey>,
pub address: String,
#[serde(default)]
pub content_type: Option<String>,
#[serde(default)]
pub pinned_engine: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineRouteDecision {
pub engine_id: String,
pub surface_contract: SurfaceContract,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineRoutePolicy {
pub rules: Vec<EngineRouteRule>,
pub fallback: EngineRouteRule,
#[serde(default)]
pub per_host_overrides: std::collections::HashMap<String, String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineRouteRule {
pub schemes: Vec<String>,
#[serde(default)]
pub content_types: Vec<String>,
pub engine_id: String,
pub mode: SurfaceContractMode,
}
impl EngineRoutePolicy {
pub fn route(&self, request: &EngineRouteRequest) -> EngineRouteDecision {
self.route_filtered(request, |_| true)
}
pub fn route_filtered(
&self,
request: &EngineRouteRequest,
is_available: impl Fn(&str) -> bool,
) -> EngineRouteDecision {
let scheme = address_scheme(&request.address);
if let Some(pin) = request.pinned_engine.as_deref() {
if is_available(pin) {
return EngineRouteDecision {
engine_id: pin.to_string(),
surface_contract: SurfaceContract {
target: surface_target_for_request(request, scheme),
mode: SurfaceContractMode::CompositedTexture,
},
};
}
}
let by_content_type = request.content_type.as_deref().and_then(|ct| {
self.rules
.iter()
.find(|rule| rule.matches_content_type(ct) && is_available(&rule.engine_id))
});
let by_host = host_from_address(&request.address).and_then(|host| {
let host_lower = host.to_ascii_lowercase();
self.per_host_overrides
.iter()
.find(|(domain, engine_id)| {
domain.eq_ignore_ascii_case(&host_lower) && is_available(engine_id)
})
.map(|(_, engine_id)| engine_id.as_str())
});
let by_scheme = scheme.and_then(|scheme| {
self.rules
.iter()
.find(|rule| rule.matches_scheme(scheme) && is_available(&rule.engine_id))
});
if let Some(rule) = by_content_type {
return EngineRouteDecision {
engine_id: rule.engine_id.clone(),
surface_contract: SurfaceContract {
target: surface_target_for_request(request, scheme),
mode: rule.mode,
},
};
}
if let Some(host_engine) = by_host {
return EngineRouteDecision {
engine_id: host_engine.to_string(),
surface_contract: SurfaceContract {
target: surface_target_for_request(request, scheme),
mode: SurfaceContractMode::CompositedTexture,
},
};
}
let rule = by_scheme.unwrap_or(&self.fallback);
EngineRouteDecision {
engine_id: rule.engine_id.clone(),
surface_contract: SurfaceContract {
target: surface_target_for_request(request, scheme),
mode: rule.mode,
},
}
}
}
impl Default for EngineRoutePolicy {
fn default() -> Self {
Self {
rules: vec![
EngineRouteRule::new(
["http", "https"],
ENGINE_SERVAL_WEB,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["gemini", "spartan"],
ENGINE_NEMATIC_GEMTEXT,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["gopher"],
ENGINE_NEMATIC_GOPHER,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["finger"],
ENGINE_NEMATIC_FINGER,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["scroll"],
ENGINE_NEMATIC_SCROLL,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["misfin"],
ENGINE_NEMATIC_MISFIN,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["nex"],
ENGINE_NEMATIC_NEX,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["guppy"],
ENGINE_NEMATIC_GUPPY,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["titan"],
ENGINE_NEMATIC_TITAN,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::new(
["file"],
ENGINE_NEMATIC_FILE,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["text/markdown", "text/x-markdown"],
ENGINE_NEMATIC_MARKDOWN,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["text/gemini"],
ENGINE_NEMATIC_GEMTEXT,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["text/plain"],
ENGINE_NEMATIC_TEXT,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
[
"application/rss+xml",
"application/atom+xml",
"application/feed+xml",
],
ENGINE_NEMATIC_FEED,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["text/x-knot", "application/x-knot"],
ENGINE_NEMATIC_KNOT_DJOT,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["text/html", "application/xhtml+xml"],
ENGINE_SERVAL_WEB,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["application/gopher-menu"],
ENGINE_NEMATIC_GOPHER,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["text/x-finger"],
ENGINE_NEMATIC_FINGER,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["application/x-nex"],
ENGINE_NEMATIC_NEX,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["application/x-guppy"],
ENGINE_NEMATIC_GUPPY,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["application/x-titan"],
ENGINE_NEMATIC_TITAN,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["message/x-misfin"],
ENGINE_NEMATIC_MISFIN,
SurfaceContractMode::CompositedTexture,
),
EngineRouteRule::content_type(
["application/feed+json"],
ENGINE_NEMATIC_FEED,
SurfaceContractMode::CompositedTexture,
),
],
fallback: EngineRouteRule::new(
Vec::<&str>::new(),
ENGINE_EXTERNAL_PROTOCOL,
SurfaceContractMode::Headless,
),
per_host_overrides: std::collections::HashMap::new(),
}
}
}
impl EngineRouteRule {
pub fn new(
schemes: impl IntoIterator<Item = impl Into<String>>,
engine_id: impl Into<String>,
mode: SurfaceContractMode,
) -> Self {
Self {
schemes: schemes.into_iter().map(Into::into).collect(),
content_types: Vec::new(),
engine_id: engine_id.into(),
mode,
}
}
pub fn content_type(
types: impl IntoIterator<Item = impl Into<String>>,
engine_id: impl Into<String>,
mode: SurfaceContractMode,
) -> Self {
Self {
schemes: Vec::new(),
content_types: types.into_iter().map(Into::into).collect(),
engine_id: engine_id.into(),
mode,
}
}
pub fn matches_scheme(&self, scheme: &str) -> bool {
self.schemes
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(scheme))
}
pub fn matches_content_type(&self, content_type: &str) -> bool {
let primary = content_type
.split(';')
.next()
.unwrap_or(content_type)
.trim();
self.content_types
.iter()
.any(|candidate| candidate.eq_ignore_ascii_case(primary))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SurfaceContract {
pub target: SurfaceTargetId,
pub mode: SurfaceContractMode,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SurfaceContractMode {
CompositedTexture,
NativeOverlay,
EmbeddedHost,
Headless,
}
pub fn host_from_address(address: &str) -> Option<&str> {
let after_scheme = address.split_once("://")?.1;
let authority = after_scheme
.split(['/', '?', '#'])
.next()
.unwrap_or(after_scheme);
let host_with_port = authority.rsplit('@').next().unwrap_or(authority);
let host = host_with_port.split(':').next().unwrap_or(host_with_port);
if host.is_empty() { None } else { Some(host) }
}
pub fn address_scheme(address: &str) -> Option<&str> {
let (scheme, _) = address.split_once(':')?;
let first = scheme.as_bytes().first()?;
if !first.is_ascii_alphabetic() {
return None;
}
if scheme
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.'))
{
Some(scheme)
} else {
None
}
}
fn surface_target_for_request(
request: &EngineRouteRequest,
scheme: Option<&str>,
) -> SurfaceTargetId {
if let Some(node) = request.node {
return SurfaceTargetId::new(format!("node:{}", node.index()));
}
if let Some(view) = request.view {
return SurfaceTargetId::new(format!("view:{}", view.as_uuid()));
}
SurfaceTargetId::new(format!(
"workspace:{}:{}",
request.workspace_id.as_str(),
scheme.unwrap_or("unknown")
))
}
#[cfg(test)]
mod tests;