aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/crates
diff options
context:
space:
mode:
author苏向夜 <46275354+fu050409@users.noreply.github.com>2026-02-25 02:06:07 +0800
committerGitHub <noreply@github.com>2026-02-25 02:06:07 +0800
commit78ac61904d78d558d092eff08c9f261cbdb187e8 (patch)
tree96f68d1f1554ee3a0532793afaaa52b0c73dcbec /crates
parent8ff3af6cb908fd824b512379dd21ed4f595ab6bb (diff)
parent329734b23957b84cde2af459fa61c7385fb5b5f1 (diff)
downloadDropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.tar.gz
DropOut-78ac61904d78d558d092eff08c9f261cbdb187e8.zip
feat(ui): partial react rewrite (#77)
## Summary by Sourcery Export backend data structures to TypeScript for the new React-based UI and update CI to build additional targets. New Features: - Generate TypeScript definitions for core backend structs and enums used by the UI. - Now use our own Azure app(_DropOut_) to finish the authorize process. Enhancements: - Annotate existing Rust models with ts-rs metadata to control exported TypeScript shapes, including tagged enums and opaque JSON fields. Build: - Add ts-rs as a dependency for generating TypeScript bindings from Rust types. CI: - Extend the Semifold CI workflow to run on the dev branch and build additional Linux musl and Windows GNU targets using cross where needed.
Diffstat (limited to 'crates')
-rw-r--r--crates/macros/Cargo.toml18
-rw-r--r--crates/macros/src/attr.rs10
-rw-r--r--crates/macros/src/lib.rs270
3 files changed, 298 insertions, 0 deletions
diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml
new file mode 100644
index 0000000..729b557
--- /dev/null
+++ b/crates/macros/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "dropout-macros"
+version = "0.1.0"
+edition = "2021"
+description = "Proc-macro crate providing #[dropout::api] for generating Tauri TypeScript bindings"
+license = "MIT OR Apache-2.0"
+publish = false
+
+[lib]
+proc-macro = true
+
+[dependencies]
+darling = { version = "0.23.0", features = ["serde"] }
+heck = "0.4"
+inventory = "0.3"
+proc-macro2 = "1.0"
+quote = "1.0"
+syn = { version = "2.0", features = ["full"] }
diff --git a/crates/macros/src/attr.rs b/crates/macros/src/attr.rs
new file mode 100644
index 0000000..ef1df86
--- /dev/null
+++ b/crates/macros/src/attr.rs
@@ -0,0 +1,10 @@
+use darling::FromMeta;
+
+#[derive(Default, FromMeta)]
+#[darling(default)]
+#[darling(derive_syn_parse)]
+pub struct MacroArgs {
+ pub export_to: Option<String>,
+ pub export_to_path: Option<String>,
+ pub import_from: Option<String>,
+}
diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs
new file mode 100644
index 0000000..d32ab69
--- /dev/null
+++ b/crates/macros/src/lib.rs
@@ -0,0 +1,270 @@
+use heck::ToLowerCamelCase;
+use proc_macro::TokenStream;
+use quote::quote;
+use std::collections::BTreeSet;
+use syn::{parse_macro_input, FnArg, Ident, ItemFn, Pat, PathArguments, ReturnType, Type};
+
+use crate::attr::MacroArgs;
+
+mod attr;
+
+const TAURI_NATIVE_TYPES: &[&str] = &["Window", "State", "AppHandle"];
+fn is_tauri_native(ty: &Type) -> bool {
+ // Unwrap reference
+ let mut t = ty;
+ if let Type::Reference(r) = t {
+ t = &*r.elem;
+ }
+
+ if let Type::Path(p) = t {
+ if let Some(seg) = p.path.segments.last() {
+ let ident = seg.ident.to_string();
+ if TAURI_NATIVE_TYPES.contains(&ident.as_ref()) {
+ return true;
+ }
+ }
+ }
+ false
+}
+
+fn extract_ident_from_type(ty: &Type) -> Option<String> {
+ // Peel references, arrays, etc. Only handle Path types
+ let mut t = ty;
+ if let Type::Reference(r) = t {
+ t = &*r.elem;
+ }
+
+ if let Type::Path(p) = t {
+ // Handle Option<T>, Result, etc.
+ if let Some(seg) = p.path.segments.last() {
+ let ident = seg.ident.to_string();
+ match ident.as_str() {
+ "Option" | "Vec" => {
+ // extract generic arg (use helper)
+ if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) {
+ return extract_ident_from_type(inner);
+ }
+ }
+ // For multi-segment like core::java::JavaDownloadInfo we return last segment ident
+ _ => return Some(ident),
+ }
+ }
+ }
+ None
+}
+
+fn first_type_arg_from_pathargs(pa: &PathArguments) -> Option<&Type> {
+ // Given PathArguments (e.g. from a PathSegment), return the first GenericArgument::Type if present.
+ if let PathArguments::AngleBracketed(ab) = pa {
+ for arg in ab.args.iter() {
+ if let syn::GenericArgument::Type(ty) = arg {
+ return Some(ty);
+ }
+ }
+ }
+ None
+}
+
+fn rust_type_to_ts(ty: &Type) -> (String, bool) {
+ // returns (ts_type, is_struct_like)
+ // is_struct_like signals that this type probably needs import from `import_from`
+ // Simple mapping: String -> string, primitives -> number, bool -> boolean, others -> ident
+ let mut t = ty;
+ // Unwrap references
+ if let Type::Reference(r) = t {
+ t = &*r.elem;
+ }
+
+ if let Type::Tuple(tuple) = t {
+ if tuple.elems.is_empty() {
+ return ("void".to_string(), false);
+ }
+ }
+
+ if let Type::Path(p) = t {
+ if let Some(seg) = p.path.segments.last() {
+ let ident = seg.ident.to_string();
+ // handle Option<T>
+ if ident == "Option" {
+ if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) {
+ let (inner_ts, inner_struct) = rust_type_to_ts(inner);
+ // make optional, represent as type | null
+ return (format!("{} | null", inner_ts), inner_struct);
+ }
+ }
+ return match ident.as_str() {
+ "String" => ("string".to_string(), false),
+ "str" => ("string".to_string(), false),
+ "bool" => ("boolean".to_string(), false),
+ "u8" | "u16" | "u32" | "u64" | "u128" | "i8" | "i16" | "i32" | "i64" | "i128"
+ | "usize" | "isize" | "f32" | "f64" => ("number".to_string(), false),
+ "Vec" => {
+ // Vec<T> -> T[]
+ if let Some(inner) = first_type_arg_from_pathargs(&seg.arguments) {
+ let (inner_ts, inner_struct) = rust_type_to_ts(inner);
+ return (format!("{}[]", inner_ts), inner_struct);
+ }
+ ("unknown[]".to_string(), false)
+ }
+ other => {
+ // treat as struct/complex type
+ (other.to_string(), true)
+ }
+ };
+ }
+ }
+ ("unknown".to_string(), false)
+}
+
+fn get_return_ts(ty: &ReturnType) -> (String, BTreeSet<String>) {
+ // returns (promise_ts_type, set_of_structs_to_import)
+ let mut imports = BTreeSet::new();
+ match ty {
+ ReturnType::Default => ("Promise<void>".to_string(), imports),
+ ReturnType::Type(_, boxed) => {
+ // look for Result<T, E> commonly
+ let t = &**boxed;
+ if let Type::Path(p) = t {
+ if let Some(seg) = p.path.segments.last() {
+ let ident = seg.ident.to_string();
+ if ident == "Result" {
+ if let Some(ok_ty) = first_type_arg_from_pathargs(&seg.arguments) {
+ let (ts, is_struct) = rust_type_to_ts(ok_ty);
+ if is_struct {
+ if let Some(name) = extract_ident_from_type(ok_ty) {
+ imports.insert(name);
+ }
+ }
+ return (format!("Promise<{}>", ts), imports);
+ }
+ } else {
+ // not Result - map directly
+ let (ts, is_struct) = rust_type_to_ts(t);
+ if is_struct {
+ if let Some(name) = extract_ident_from_type(t) {
+ imports.insert(name);
+ }
+ }
+ return (format!("Promise<{}>", ts), imports);
+ }
+ }
+ }
+ // fallback
+ ("Promise<unknown>".to_string(), imports)
+ }
+ }
+}
+
+#[proc_macro_attribute]
+pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream {
+ // Parse attribute args via `darling` crate
+ let meta: MacroArgs = match syn::parse(attr) {
+ Ok(meta) => meta,
+ Err(err) => return err.into_compile_error().into(),
+ };
+ let input_fn = parse_macro_input!(item as ItemFn);
+ let import_from = meta.import_from;
+
+ // Analyze function
+ let fn_name_ident: Ident = input_fn.sig.ident.clone();
+ let fn_name = fn_name_ident.to_string();
+ let ts_fn_name = fn_name.to_lower_camel_case();
+
+ // Collect parameters (ignore State/Window)
+ let mut param_names: Vec<String> = Vec::new();
+ let mut param_defs: Vec<String> = Vec::new();
+ let mut import_types: BTreeSet<String> = BTreeSet::new();
+
+ for input in input_fn.sig.inputs.iter() {
+ match input {
+ FnArg::Receiver(_) => {
+ // skip self
+ }
+ FnArg::Typed(pt) => {
+ // Get parameter identifier
+ let pat = &*pt.pat;
+ let param_ident = if let Pat::Ident(pi) = pat {
+ Some(pi.ident.to_string())
+ } else {
+ // ignore complex patterns
+ continue;
+ };
+
+ // Check if type should be ignored (State, Window)
+ if is_tauri_native(&*pt.ty) {
+ continue;
+ }
+
+ // Map type
+ let (ts_type, is_struct) = rust_type_to_ts(&*pt.ty);
+ if is_struct {
+ if let Some(name) = extract_ident_from_type(&*pt.ty) {
+ import_types.insert(name);
+ }
+ }
+
+ if let Some(pn) = param_ident {
+ // Convert param name to camelCase - keep existing but ensure camelCase for TS
+ // We'll convert snake_case param names to camelCase
+ let ts_param_name = pn.to_lower_camel_case();
+ param_names.push(ts_param_name.clone());
+ param_defs.push(format!("{}: {}", ts_param_name, ts_type));
+ }
+ }
+ }
+ }
+
+ // Return type
+ let (return_ts_promise, return_imports) = get_return_ts(&input_fn.sig.output);
+ import_types.extend(return_imports);
+
+ // Prepare test mod name
+ let test_mod_name = Ident::new(
+ &format!("__dropout_export_tests_{}", fn_name),
+ fn_name_ident.span(),
+ );
+ // Prepare test function name
+ let test_fn_name = Ident::new(
+ &format!("tauri_export_bindings_{}", fn_name),
+ fn_name_ident.span(),
+ );
+
+ // Build tokens
+ let original_fn = &input_fn;
+ let return_ts_promise_lit =
+ syn::LitStr::new(&return_ts_promise, proc_macro2::Span::call_site());
+ let import_from_lit = match &import_from {
+ Some(s) => syn::Ident::new(&format!("Some({})", s), proc_macro2::Span::call_site()),
+ None => syn::Ident::new("None", proc_macro2::Span::call_site()),
+ };
+
+ let register_stmt = quote! {
+ ::inventory::submit! {
+ crate::utils::api::ApiInfo {
+ fn_name: #fn_name,
+ ts_fn_name: #ts_fn_name,
+ param_names: &[#(#param_names),*],
+ param_defs: &[#(#param_defs),*],
+ return_ts_promise: #return_ts_promise_lit,
+ import_types: &[#(#import_types),*],
+ import_from: #import_from_lit,
+ }
+ }
+ };
+
+ let gen = quote! {
+ #original_fn
+
+ #[cfg(test)]
+ mod #test_mod_name {
+ use crate::utils::api::*;
+
+ #[test]
+ fn #test_fn_name() {
+ #register_stmt
+ }
+ }
+ };
+
+ gen.into()
+}