use specta::Types;
use specta::datatype::DataType;
#[derive(Clone)]
pub struct ProcedureType {
pub name: &'static str,
pub args: Vec<(&'static str, DataType)>,
pub channels: Vec<(&'static str, DataType)>,
pub output: DataType,
pub error: Option<ErrorType>,
}
#[derive(Clone)]
pub struct ErrorType {
pub name: &'static str,
pub variants: Vec<&'static str>,
}
pub trait ProcedureSet {
const OBJECT: &'static str;
const NAMESPACE: Option<&'static str> = None;
fn procedures(types: &mut Types) -> Vec<ProcedureType>;
}
pub trait ErrorSet {
fn error_type() -> ErrorType;
}
impl ErrorSet for String {
fn error_type() -> ErrorType {
ErrorType {
name: "string",
variants: Vec::new(),
}
}
}
#[derive(Clone)]
pub struct EventType {
pub variant: &'static str,
pub name: &'static str,
pub fields: Vec<(&'static str, DataType)>,
pub data: Option<DataType>,
}
pub trait EventSet {
const GROUP: &'static str;
const NAME: &'static str;
fn events(types: &mut Types) -> Vec<EventType>;
}
#[cfg(feature = "export")]
pub use render::{Bindings, BindingsError, MethodCase};
#[cfg(feature = "export")]
pub use specta_typescript::Layout;
#[cfg(feature = "export")]
mod render {
use super::{ErrorType, EventSet, EventType, ProcedureSet, ProcedureType};
use specta::Types;
use specta::datatype::DataType;
use specta_typescript::{Error, Exporter, FrameworkExporter, Layout, Typescript};
use std::borrow::Cow;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
const HEADER: &str = "// This file has been generated by tauri_typed_ipc. DO NOT EDIT.";
const IMPORT_INVOKE: &str = "import { invoke } from \"@tauri-apps/api/core\";\n";
const IMPORT_INVOKE_CHANNEL: &str =
"import { invoke, Channel } from \"@tauri-apps/api/core\";\n";
const IMPORT_LISTEN: &str = "import { listen } from \"@tauri-apps/api/event\";\n";
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MethodCase {
#[default]
Camel,
Snake,
}
#[must_use]
pub struct Bindings {
types: Types,
sets: Vec<Set>,
events: Vec<EventGroup>,
layout: Layout,
method_case: MethodCase,
router: Option<Cow<'static, str>>,
#[cfg(feature = "validate")]
validate: bool,
}
#[derive(Clone)]
struct Set {
object: &'static str,
namespace: Option<&'static str>,
procedures: Vec<ProcedureType>,
}
#[derive(Clone)]
struct EventGroup {
group: &'static str,
name: &'static str,
variants: Vec<EventType>,
}
impl Bindings {
pub fn new() -> Self {
Self {
types: Types::default(),
sets: Vec::new(),
events: Vec::new(),
layout: Layout::default(),
method_case: MethodCase::default(),
router: None,
#[cfg(feature = "validate")]
validate: false,
}
}
pub fn layout(mut self, layout: Layout) -> Self {
self.layout = layout;
self
}
pub fn method_case(mut self, method_case: MethodCase) -> Self {
self.method_case = method_case;
self
}
pub fn router(mut self, name: impl Into<Cow<'static, str>>) -> Self {
self.router = Some(name.into());
self
}
#[cfg(feature = "validate")]
pub fn validate(mut self, validate: bool) -> Self {
self.validate = validate;
self
}
pub fn register<P: ProcedureSet>(mut self) -> Self {
let procedures = P::procedures(&mut self.types);
self.sets.push(Set {
object: P::OBJECT,
namespace: P::NAMESPACE,
procedures,
});
self
}
pub fn register_events<E: EventSet>(mut self) -> Self {
let variants = E::events(&mut self.types);
self.events.push(EventGroup {
group: E::GROUP,
name: E::NAME,
variants,
});
self
}
pub fn export(&self) -> Result<String, BindingsError> {
let block = self.validate_block()?;
Ok(self
.exporter(block)
.export(&self.types, specta_serde::Format)?)
}
pub fn export_to(&self, path: impl AsRef<Path>) -> Result<(), BindingsError> {
let block = self.validate_block()?;
self.exporter(block)
.export_to(path, &self.types, specta_serde::Format)?;
Ok(())
}
pub fn check(&self, path: impl AsRef<Path>) -> Result<(), BindingsError> {
let path = path.as_ref();
if self.layout == Layout::Files {
return self.check_files(path);
}
let generated = self.export()?;
let committed = std::fs::read_to_string(path)
.map_err(|source| BindingsError::read(path, source))?;
if generated == committed {
Ok(())
} else {
Err(BindingsError::Drift {
path: path.to_path_buf(),
})
}
}
fn check_files(&self, path: &Path) -> Result<(), BindingsError> {
let temp = TempDir::new().map_err(|source| BindingsError::read(path, source))?;
self.export_to(temp.path())?;
let generated = read_tree(temp.path())
.map_err(|source| BindingsError::read(temp.path(), source))?;
let committed = read_tree(path).map_err(|source| BindingsError::read(path, source))?;
if generated == committed {
Ok(())
} else {
Err(BindingsError::Drift {
path: path.to_path_buf(),
})
}
}
fn exporter(&self, validate_block: Option<String>) -> Exporter {
let sets = self.sets.clone();
let events = self.events.clone();
let method_case = self.method_case;
let router = self.router.clone();
Exporter::from(Typescript::new())
.framework_prelude(HEADER)
.layout(self.layout)
.framework_runtime(move |exporter| {
render_runtime(
&sets,
&events,
method_case,
router.as_deref(),
validate_block.as_deref(),
&exporter,
)
})
}
fn validate_block(&self) -> Result<Option<String>, BindingsError> {
#[cfg(feature = "validate")]
if self.validate {
let sets: Vec<crate::validate::SchemaSet<'_>> = self
.sets
.iter()
.map(|set| crate::validate::SchemaSet {
namespace: set.namespace,
procedures: &set.procedures,
})
.collect();
let schemas = crate::validate::build_command_schemas(self.types.clone(), &sets)?;
return Ok(Some(render_validate_block(&schemas)));
}
Ok(None)
}
}
impl Default for Bindings {
fn default() -> Self {
Self::new()
}
}
fn render_runtime(
sets: &[Set],
events: &[EventGroup],
method_case: MethodCase,
router: Option<&str>,
validate_block: Option<&str>,
exporter: &FrameworkExporter,
) -> Result<Cow<'static, str>, Error> {
let validate = validate_block.is_some();
let mut out = String::new();
if !sets.is_empty() {
let any_channels = sets
.iter()
.flat_map(|set| &set.procedures)
.any(|proc| !proc.channels.is_empty());
out.push_str(if any_channels {
IMPORT_INVOKE_CHANNEL
} else {
IMPORT_INVOKE
});
}
if !events.is_empty() {
out.push_str(IMPORT_LISTEN);
}
if let Some(block) = validate_block {
out.push('\n');
out.push_str(block);
}
for set in sets {
out.push('\n');
out.push_str(&render_object(set, method_case, validate, exporter)?);
}
for error in collect_errors(sets) {
out.push('\n');
out.push_str(&render_error(error));
}
for group in events {
out.push('\n');
out.push_str(&render_union(group, exporter)?);
}
if !events.is_empty() {
out.push('\n');
out.push_str(&render_events(events, exporter)?);
}
if let Some(name) = router
&& (!sets.is_empty() || !events.is_empty())
{
out.push('\n');
out.push_str(&render_router(name, sets, events));
}
Ok(Cow::Owned(out))
}
fn render_object(
set: &Set,
method_case: MethodCase,
validate: bool,
exporter: &FrameworkExporter,
) -> Result<String, Error> {
let mut methods = Vec::with_capacity(set.procedures.len());
for proc in &set.procedures {
let count = proc.args.len() + proc.channels.len();
let mut params = Vec::with_capacity(count);
let mut fields = Vec::with_capacity(count);
for (wire, ty) in &proc.args {
let param = to_camel(wire);
params.push(format!("{param}: {}", render_ty(exporter, ty)?));
if ¶m == wire {
fields.push((*wire).to_string());
} else {
fields.push(format!("{wire}: {param}"));
}
}
for (wire, ty) in &proc.channels {
let param = to_camel(wire);
params.push(format!("{param}: Channel<{}>", render_ty(exporter, ty)?));
if ¶m == wire {
fields.push((*wire).to_string());
} else {
fields.push(format!("{wire}: {param}"));
}
}
let ret = render_ty(exporter, &proc.output)?;
let wire = match set.namespace {
Some(ns) => format!("{ns}.{}", proc.name),
None => proc.name.to_string(),
};
let args_obj = if count == 0 {
"{}".to_string()
} else {
format!("{{ {} }}", fields.join(", "))
};
let call = if count == 0 {
format!("invoke(\"{wire}\")")
} else {
format!("invoke(\"{wire}\", {args_obj})")
};
let guard = if validate {
format!(" __ttipcValidate(\"{wire}\", {args_obj});\n")
} else {
String::new()
};
let method = match method_case {
MethodCase::Camel => to_camel(proc.name),
MethodCase::Snake => proc.name.to_string(),
};
let throws = match &proc.error {
Some(error) => format!(" /** @throws {{{}}} */\n", error.name),
None => String::new(),
};
methods.push(format!(
"{throws} {method}({params}): Promise<{ret}> {{\n{guard} return {call};\n }}",
params = params.join(", "),
));
}
Ok(format!(
"export const {} = {{\n{},\n}};\n",
set.object,
methods.join(",\n\n"),
))
}
#[cfg(feature = "validate")]
fn render_validate_block(schemas: &[(String, serde_json::Value)]) -> String {
let mut entries = String::new();
for (wire, schema) in schemas {
let key = serde_json::to_string(wire).expect("a wire name serializes");
let value = serde_json::to_string(schema).expect("a schema serializes");
entries.push_str(" ");
entries.push_str(&key);
entries.push_str(": ");
entries.push_str(&value);
entries.push_str(",\n");
}
format!("const __ttipcSchemas: Record<string, any> = {{\n{entries}}};\n\n{VALIDATOR_TS}")
}
#[cfg(feature = "validate")]
const VALIDATOR_TS: &str = r#"function __ttipcDefs(root: any): Record<string, any> {
return (root.$defs ?? root.definitions ?? {}) as Record<string, any>;
}
function __ttipcCheck(value: unknown, schema: any, root: any, path: string): string | null {
if (schema === true || schema === undefined || schema === null) return null;
if (typeof schema.$ref === "string") {
const name = schema.$ref.split("/").pop() as string;
return __ttipcCheck(value, __ttipcDefs(root)[name], root, path);
}
if (schema.oneOf || schema.anyOf || schema.allOf || Array.isArray(schema.type)) {
return null;
}
if (Array.isArray(schema.enum) && !schema.enum.some((v: unknown) => v === value)) {
return `${path} is not an allowed value`;
}
const type: unknown = schema.type;
if (type === "object" || schema.properties || schema.required) {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return `${path} must be an object`;
}
const obj = value as Record<string, unknown>;
for (const key of (schema.required ?? []) as string[]) {
if (!(key in obj)) return `${path}.${key} is required`;
}
const props = schema.properties ?? {};
for (const key of Object.keys(props)) {
if (key in obj) {
const err = __ttipcCheck(obj[key], props[key], root, `${path}.${key}`);
if (err !== null) return err;
}
}
return null;
}
if (type === "array") {
if (!Array.isArray(value)) return `${path} must be an array`;
if (schema.items) {
for (let i = 0; i < value.length; i++) {
const err = __ttipcCheck(value[i], schema.items, root, `${path}[${i}]`);
if (err !== null) return err;
}
}
return null;
}
if (type === "string" && typeof value !== "string") return `${path} must be a string`;
if ((type === "integer" || type === "number") && typeof value !== "number") {
return `${path} must be a number`;
}
if (type === "integer" && typeof value === "number" && !Number.isInteger(value)) {
return `${path} must be an integer`;
}
if (type === "boolean" && typeof value !== "boolean") return `${path} must be a boolean`;
if (type === "null" && value !== null) return `${path} must be null`;
return null;
}
function __ttipcValidate(command: string, args: unknown): void {
const schema = __ttipcSchemas[command];
if (schema === undefined) return;
const error = __ttipcCheck(args, schema, schema, "arguments");
if (error !== null) {
throw new Error(`invalid arguments for ${command}: ${error}`);
}
}
"#;
fn render_router(name: &str, sets: &[Set], events: &[EventGroup]) -> String {
let has_events = |ns: &str| events.iter().any(|group| group.group == ns);
let mut entries = Vec::with_capacity(sets.len() + events.len());
for set in sets {
if has_events(set.object) {
entries.push(format!(
" {obj}: {{ ...{obj}, event: {{ on: events.{obj}.listen }} }}",
obj = set.object,
));
} else {
entries.push(format!(" {}", set.object));
}
}
for group in events {
if !sets.iter().any(|set| set.object == group.group) {
entries.push(format!(
" {grp}: {{ event: {{ on: events.{grp}.listen }} }}",
grp = group.group,
));
}
}
format!(
"export const {name} = () => ({{\n{},\n}});\n",
entries.join(",\n")
)
}
fn to_camel(snake: &str) -> String {
let mut out = String::with_capacity(snake.len());
let mut upper_next = false;
for ch in snake.chars() {
if ch == '_' {
upper_next = true;
} else if upper_next {
out.extend(ch.to_uppercase());
upper_next = false;
} else {
out.push(ch);
}
}
out
}
fn collect_errors(sets: &[Set]) -> Vec<&ErrorType> {
let mut seen = HashSet::new();
let mut errors = Vec::new();
for set in sets {
for proc in &set.procedures {
if let Some(error) = &proc.error
&& !error.variants.is_empty()
&& seen.insert(error.name)
{
errors.push(error);
}
}
}
errors
}
fn render_error(error: &ErrorType) -> String {
let members: Vec<String> = error
.variants
.iter()
.map(|tag| format!("{{ type: \"{tag}\"; message: string }}"))
.collect();
format!("export type {} = {};\n", error.name, members.join(" | "))
}
fn render_ty(exporter: &FrameworkExporter, ty: &DataType) -> Result<String, Error> {
match ty {
DataType::Reference(reference) => exporter.reference(reference),
other => exporter.inline(other),
}
}
fn render_union(group: &EventGroup, exporter: &FrameworkExporter) -> Result<String, Error> {
let mut members = Vec::with_capacity(group.variants.len());
for variant in &group.variants {
let mut parts = vec![format!("type: \"{}\"", variant.variant)];
if let Some(data) = &variant.data {
parts.push(format!("data: {}", render_ty(exporter, data)?));
} else {
parts.extend(render_fields(variant, exporter)?);
}
members.push(format!("{{ {} }}", parts.join("; ")));
}
Ok(format!(
"export type {} = {};\n",
group.name,
members.join(" | ")
))
}
fn render_events(events: &[EventGroup], exporter: &FrameworkExporter) -> Result<String, Error> {
let mut groups = Vec::with_capacity(events.len());
for group in events {
let mut listeners = Vec::with_capacity(group.variants.len());
for variant in &group.variants {
let payload = render_payload(variant, exporter)?;
let forward = if variant.data.is_some() {
format!(
"(event) => callback({{ type: \"{}\", data: event.payload }})",
variant.variant,
)
} else if variant.fields.is_empty() {
format!("() => callback({{ type: \"{}\" }})", variant.variant)
} else {
format!(
"(event) => callback({{ type: \"{}\", ...event.payload }})",
variant.variant,
)
};
listeners.push(format!(
"listen<{payload}>(\"{name}\", {forward})",
name = variant.name,
));
}
let body = if let [only] = listeners.as_slice() {
format!(
" listen(callback: (event: {name}) => void): \
Promise<() => void> {{\n return {only};\n }},",
name = group.name,
)
} else {
let calls = listeners
.iter()
.map(|call| format!(" {call},"))
.collect::<Vec<_>>()
.join("\n");
format!(
" async listen(callback: (event: {name}) => void): \
Promise<() => void> {{\n const unlisten = await Promise.all([\n{calls}\n \
]);\n return () => unlisten.forEach((un) => un());\n }},",
name = group.name,
)
};
groups.push(format!(" {group}: {{\n{body}\n }}", group = group.group));
}
Ok(format!(
"export const events = {{\n{},\n}};\n",
groups.join(",\n\n"),
))
}
fn render_fields(
variant: &EventType,
exporter: &FrameworkExporter,
) -> Result<Vec<String>, Error> {
variant
.fields
.iter()
.map(|(name, ty)| Ok(format!("{name}: {}", render_ty(exporter, ty)?)))
.collect()
}
fn render_payload(variant: &EventType, exporter: &FrameworkExporter) -> Result<String, Error> {
if let Some(data) = &variant.data {
return render_ty(exporter, data);
}
let fields = render_fields(variant, exporter)?;
if fields.is_empty() {
Ok("null".to_string())
} else {
Ok(format!("{{ {} }}", fields.join("; ")))
}
}
#[derive(Debug, thiserror::Error)]
pub enum BindingsError {
#[error("rendering TypeScript failed: {0}")]
Render(#[from] Error),
#[cfg(feature = "validate")]
#[error(transparent)]
Validate(#[from] crate::validate::ValidatorError),
#[error("checking the client at {path:?} failed: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("the committed client at {path:?} is out of date; regenerate it")]
Drift {
path: PathBuf,
},
}
impl BindingsError {
fn read(path: &Path, source: std::io::Error) -> Self {
Self::Read {
path: path.to_path_buf(),
source,
}
}
}
struct TempDir(PathBuf);
impl TempDir {
fn new() -> std::io::Result<Self> {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"tauri_typed_ipc-check-{}-{unique}",
std::process::id()
));
std::fs::create_dir_all(&dir)?;
Ok(Self(dir))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn read_tree(root: &Path) -> std::io::Result<BTreeMap<PathBuf, String>> {
let mut files = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir)? {
let path = entry?.path();
if path.is_dir() {
stack.push(path);
} else {
let rel = path
.strip_prefix(root)
.expect("entry is under root")
.to_path_buf();
files.insert(rel, std::fs::read_to_string(&path)?);
}
}
}
Ok(files)
}
}