tauri-plugin-dotnet 0.1.1

Tauri plugin that connects the frontend to .NET services through a typed RPC bridge.
//! Generates the small throwaway console project `dotnet watch` actually runs.
//!
//! `dotnet watch` only rebuilds and watches what is in the project graph it is running (the target
//! project plus its `ProjectReference`s). The backend project has no reason to reference anything
//! sidecar-related, so this writes a tiny wrapper project next to it with a real `ProjectReference`
//! to the backend - purely so `dotnet watch`'s project-graph walk picks up the backend's own source
//! files - and a `Program.cs` that calls straight into
//! `Tauri.Plugin.DotNet.Hosting.Sidecar.SidecarRunner`, which the backend already depends on
//! transitively (every backend project references `Tauri.Plugin.DotNet`).
//!
//! Because the wrapper has a genuine compile-time reference to the backend, the backend assembly (and
//! its own dependencies) resolve through the wrapper's ordinary `deps.json`-driven probing - no
//! `Assembly.LoadFrom`, no shadow copy, and no custom resolver are needed for this path.

use std::path::{Path, PathBuf};

use crate::error::BridgeError;

const INIT_FAILED: &str = "HostInitFailed";

/// Ensures the wrapper project exists and is up to date, returning its `.csproj` path. Safe to call
/// on every start/restart; the files are only rewritten when their content would actually change, so
/// this does not spuriously invalidate `dotnet watch`'s own file-timestamp bookkeeping.
pub fn ensure(backend_csproj: &Path, assembly_name: &str, tfm: &str) -> Result<PathBuf, BridgeError> {
  let backend_dir = backend_csproj.parent().ok_or_else(|| {
    BridgeError::new(
      INIT_FAILED,
      format!("The backend project path {} has no parent directory.", backend_csproj.display()),
    )
  })?;

  // Under `obj/`, matching where the rest of the toolchain already puts generated, safe-to-delete
  // build state for this project.
  let wrapper_dir = backend_dir.join("obj").join("tauri-dotnet-watch-runner");
  std::fs::create_dir_all(&wrapper_dir).map_err(|e| {
    BridgeError::new(
      INIT_FAILED,
      format!("Failed to create the dotnet-watch runner folder at {}: {e}", wrapper_dir.display()),
    )
  })?;

  let csproj_path = wrapper_dir.join("TauriDotNetWatchRunner.csproj");
  let program_path = wrapper_dir.join("Program.cs");

  let csproj_contents = format!(
    r#"<Project Sdk="Microsoft.NET.Sdk">
  <!-- Generated by tauri-plugin-dotnet on every sidecar start; safe to delete, regenerated on demand. -->
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>{tfm}</TargetFramework>
    <!-- Program.cs is one fully-qualified line with no nullable-sensitive code, so neither
         Nullable nor ImplicitUsings buys anything here - deliberately left off.
         This project exists only so `dotnet watch` sees the backend in its project graph; it is
         never packaged or published on its own. -->
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="{backend_csproj}" />
  </ItemGroup>
</Project>
"#,
    tfm = xml_escape(tfm),
    backend_csproj = xml_escape(&backend_csproj.display().to_string()),
  );

  let program_contents = format!(
    "// Generated by tauri-plugin-dotnet; do not edit - regenerated on every sidecar start.\n\
     return await Tauri.Plugin.DotNet.Hosting.Sidecar.SidecarRunner.RunAsync(args, {assembly_name:?});\n"
  );

  write_if_changed(&csproj_path, &csproj_contents)?;
  write_if_changed(&program_path, &program_contents)?;

  Ok(csproj_path)
}

fn write_if_changed(path: &Path, contents: &str) -> Result<(), BridgeError> {
  if let Ok(existing) = std::fs::read_to_string(path) {
    if existing == contents {
      return Ok(());
    }
  }
  std::fs::write(path, contents)
    .map_err(|e| BridgeError::new(INIT_FAILED, format!("Failed to write {}: {e}", path.display())))
}

fn xml_escape(value: &str) -> String {
  value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn generates_a_project_referencing_the_backend() {
    let dir = std::env::temp_dir().join(format!("tdn-wrapper-test-{}", std::process::id()));
    std::fs::create_dir_all(&dir).unwrap();
    let backend_csproj = dir.join("MyApp.Backend.csproj");
    std::fs::write(&backend_csproj, "<Project />").unwrap();

    let wrapper_csproj = ensure(&backend_csproj, "MyApp.Backend", "net8.0").unwrap();

    assert!(wrapper_csproj.exists());
    let csproj_text = std::fs::read_to_string(&wrapper_csproj).unwrap();
    assert!(csproj_text.contains(&backend_csproj.display().to_string()));
    assert!(csproj_text.contains("net8.0"));

    let program_text = std::fs::read_to_string(wrapper_csproj.with_file_name("Program.cs")).unwrap();
    assert!(program_text.contains("SidecarRunner.RunAsync"));
    assert!(program_text.contains("\"MyApp.Backend\""));

    std::fs::remove_dir_all(&dir).ok();
  }

  #[test]
  fn does_not_rewrite_unchanged_files() {
    let dir = std::env::temp_dir().join(format!("tdn-wrapper-test-{}", std::process::id() as u64 + 1));
    std::fs::create_dir_all(&dir).unwrap();
    let backend_csproj = dir.join("MyApp.Backend.csproj");
    std::fs::write(&backend_csproj, "<Project />").unwrap();

    let wrapper_csproj = ensure(&backend_csproj, "MyApp.Backend", "net8.0").unwrap();
    let before = std::fs::metadata(&wrapper_csproj).unwrap().modified().unwrap();
    std::thread::sleep(std::time::Duration::from_millis(50));
    ensure(&backend_csproj, "MyApp.Backend", "net8.0").unwrap();
    let after = std::fs::metadata(&wrapper_csproj).unwrap().modified().unwrap();

    assert_eq!(before, after, "unchanged content must not touch the file's mtime");
    std::fs::remove_dir_all(&dir).ok();
  }
}