1use std::{
47 path::{Component, Path, PathBuf},
48 sync::Arc,
49 time::Duration,
50};
51
52use serde::Deserialize;
53use serde_json::Value;
54use tauri::{
55 plugin::{Builder as PluginBuilder, TauriPlugin},
56 AppHandle, Emitter, Manager, RunEvent, Runtime,
57};
58
59pub mod bridge;
60pub mod runtime;
61
62mod commands;
63mod error;
64
65pub use error::{codes, Error, Result};
66
67use bridge::{EventSink, Payload, DEFAULT_MAX_FRAME_SIZE};
68use runtime::{ProcessConfig, Supervisor, SupervisorConfig};
69
70const DEFAULT_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
71const DEFAULT_INVOKE_TIMEOUT: Duration = Duration::from_secs(30);
72const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
73
74#[derive(Debug, Default, Deserialize)]
79#[serde(rename_all = "camelCase", deny_unknown_fields)]
80pub struct Config {
81 pub python: Option<String>,
83 pub script: Option<PathBuf>,
85 pub bundle: Option<String>,
87 pub args: Option<Vec<String>>,
89 pub invoke_timeout_ms: Option<u64>,
91 pub shutdown_timeout_ms: Option<u64>,
93 pub restart_on_crash: Option<bool>,
95}
96
97pub struct PyRuntime {
99 supervisor: Arc<Supervisor>,
100}
101
102impl PyRuntime {
103 pub fn supervisor(&self) -> &Arc<Supervisor> {
105 &self.supervisor
106 }
107}
108
109pub trait PyRuntimeExt<R: Runtime> {
111 fn py_runtime(&self) -> &PyRuntime;
117}
118
119impl<R: Runtime, T: Manager<R>> PyRuntimeExt<R> for T {
120 fn py_runtime(&self) -> &PyRuntime {
121 self.state::<PyRuntime>().inner()
122 }
123}
124
125struct TauriSink<R: Runtime>(AppHandle<R>);
127
128fn payload_to_value(payload: Payload) -> Value {
130 match payload {
131 Payload::None => Value::Null,
132 Payload::Json(value) => value,
133 Payload::Raw(bytes) => Value::from(bytes),
134 }
135}
136
137impl<R: Runtime> EventSink for TauriSink<R> {
138 fn emit(&self, event: &str, target: Option<&str>, payload: Payload) {
139 let name = format!("py://{event}");
140 let value = payload_to_value(payload);
141 let result = match target {
142 Some(label) => self.0.emit_to(label, &name, value),
143 None => self.0.emit(&name, value),
144 };
145 if let Err(e) = result {
146 log::warn!(target: "py-runtime", "failed to emit {name}: {e}");
147 }
148 }
149
150 fn exited(&self, code: Option<i32>) {
151 if let Err(e) = self.0.emit("py://exited", serde_json::json!({ "code": code })) {
152 log::warn!(target: "py-runtime", "failed to emit py://exited: {e}");
153 }
154 }
155}
156
157fn resource_relative(script: &Path) -> PathBuf {
159 script
160 .components()
161 .map(|c| match c {
162 Component::ParentDir => Component::Normal("_up_".as_ref()),
163 other => other,
164 })
165 .collect()
166}
167
168fn resolve_script(
171 script: &Path,
172 cwd: Option<&Path>,
173 resource_dir: Option<&Path>,
174 dev: bool,
175) -> std::result::Result<PathBuf, String> {
176 let mut candidates = Vec::new();
177 if script.is_absolute() {
178 candidates.push(script.to_path_buf());
179 } else {
180 if dev {
181 if let Some(cwd) = cwd {
182 candidates.push(cwd.join(script));
183 }
184 }
185 if let Some(dir) = resource_dir {
186 candidates.push(dir.join(resource_relative(script)));
187 }
188 }
189 if let Some(found) = candidates.iter().find(|p| p.is_file()) {
190 return Ok(found.clone());
191 }
192 let tried = if candidates.is_empty() {
193 "no candidate directories available".to_string()
194 } else {
195 candidates.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
196 };
197 Err(format!("py-runtime: Python entry script '{}' not found (tried: {tried})", script.display()))
198}
199
200fn bundle_executable(name: &str) -> String {
202 if cfg!(windows) {
203 format!("{name}.exe")
204 } else {
205 name.to_string()
206 }
207}
208
209fn resolve_bundle(name: &str, resource_dir: Option<&Path>) -> std::result::Result<PathBuf, String> {
211 let dir = resource_dir.ok_or("py-runtime: resource directory unavailable, cannot locate the packaged Python backend")?;
212 let path = dir.join(name).join(bundle_executable(name));
213 if path.is_file() {
214 Ok(path)
215 } else {
216 Err(format!(
217 "py-runtime: packaged Python backend not found at {}; run `tauri-runtime build` and add it to bundle.resources",
218 path.display()
219 ))
220 }
221}
222
223struct Environment<'a> {
225 cwd: Option<&'a Path>,
226 resource_dir: Option<&'a Path>,
227 dev: bool,
228 app_name: String,
229 app_version: String,
230}
231
232fn venv_python(script_dir: &Path) -> Option<PathBuf> {
234 let path = if cfg!(windows) {
235 script_dir.join(".venv").join("Scripts").join("python.exe")
236 } else {
237 script_dir.join(".venv").join("bin").join("python")
238 };
239 path.is_file().then_some(path)
240}
241
242fn default_python() -> &'static str {
243 if cfg!(windows) {
244 "python"
245 } else {
246 "python3"
247 }
248}
249
250#[derive(Debug, Default)]
270pub struct Builder {
271 python: Option<String>,
272 script: Option<PathBuf>,
273 bundle: Option<String>,
274 args: Option<Vec<String>>,
275 envs: Vec<(String, String)>,
276 invoke_timeout: Option<Duration>,
277 shutdown_timeout: Option<Duration>,
278 handshake_timeout: Option<Duration>,
279 restart_on_crash: Option<bool>,
280}
281
282impl Builder {
283 pub fn new() -> Self {
285 Self::default()
286 }
287
288 pub fn python(mut self, program: impl Into<String>) -> Self {
294 self.python = Some(program.into());
295 self
296 }
297
298 pub fn script(mut self, path: impl Into<PathBuf>) -> Self {
306 self.script = Some(path.into());
307 self
308 }
309
310 pub fn bundle(mut self, name: impl Into<String>) -> Self {
316 self.bundle = Some(name.into());
317 self
318 }
319
320 pub fn args<I, S>(mut self, args: I) -> Self
322 where
323 I: IntoIterator<Item = S>,
324 S: Into<String>,
325 {
326 self.args = Some(args.into_iter().map(Into::into).collect());
327 self
328 }
329
330 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
334 self.envs.push((key.into(), value.into()));
335 self
336 }
337
338 pub fn invoke_timeout(mut self, timeout: Duration) -> Self {
340 self.invoke_timeout = Some(timeout);
341 self
342 }
343
344 pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
346 self.shutdown_timeout = Some(timeout);
347 self
348 }
349
350 pub fn handshake_timeout(mut self, timeout: Duration) -> Self {
352 self.handshake_timeout = Some(timeout);
353 self
354 }
355
356 pub fn restart_on_crash(mut self, enabled: bool) -> Self {
360 self.restart_on_crash = Some(enabled);
361 self
362 }
363
364 fn resolve<R: Runtime>(self, file: Option<&Config>, app: &AppHandle<R>) -> std::result::Result<SupervisorConfig, String> {
365 let cwd = std::env::current_dir().ok();
366 let resource_dir = app.path().resource_dir().ok();
367 let package = app.package_info();
368 self.into_config(
369 file,
370 &Environment {
371 cwd: cwd.as_deref(),
372 resource_dir: resource_dir.as_deref(),
373 dev: cfg!(debug_assertions),
374 app_name: package.name.clone(),
375 app_version: package.version.to_string(),
376 },
377 )
378 }
379
380 fn into_config(self, file: Option<&Config>, env: &Environment) -> std::result::Result<SupervisorConfig, String> {
382 let empty = Config::default();
383 let file = file.unwrap_or(&empty);
384 let extra = self.args.or_else(|| file.args.clone()).unwrap_or_default();
385 let (program, args, cwd) = match self.bundle.or_else(|| file.bundle.clone()) {
386 Some(name) if !env.dev => {
388 let exe = resolve_bundle(&name, env.resource_dir)?;
389 let cwd = exe.parent().map(Path::to_path_buf);
390 (exe, extra, cwd)
391 }
392 _ => {
393 let script = self
394 .script
395 .or_else(|| file.script.clone())
396 .ok_or("py-runtime: no Python entry script configured; call Builder::script or set plugins.py-runtime.script")?;
397 let script = resolve_script(&script, env.cwd, env.resource_dir, env.dev)?;
398 let mut args = vec![script.to_string_lossy().into_owned()];
399 args.extend(extra);
400 let script_dir = script.parent().map(Path::to_path_buf);
401 let python = match self.python.or_else(|| file.python.clone()) {
402 Some(python) => PathBuf::from(python),
403 None => script_dir
404 .as_deref()
405 .and_then(venv_python)
406 .unwrap_or_else(|| default_python().into()),
407 };
408 (python, args, script_dir)
409 }
410 };
411 Ok(SupervisorConfig {
412 process: ProcessConfig { program, args, envs: self.envs, cwd },
413 handshake_timeout: self.handshake_timeout.unwrap_or(DEFAULT_HANDSHAKE_TIMEOUT),
414 invoke_timeout: self
415 .invoke_timeout
416 .or(file.invoke_timeout_ms.map(Duration::from_millis))
417 .unwrap_or(DEFAULT_INVOKE_TIMEOUT),
418 shutdown_timeout: self
419 .shutdown_timeout
420 .or(file.shutdown_timeout_ms.map(Duration::from_millis))
421 .unwrap_or(DEFAULT_SHUTDOWN_TIMEOUT),
422 restart_on_crash: self.restart_on_crash.or(file.restart_on_crash).unwrap_or(false),
423 max_frame_size: DEFAULT_MAX_FRAME_SIZE,
424 app_name: env.app_name.clone(),
425 app_version: env.app_version.clone(),
426 })
427 }
428
429 pub fn build<R: Runtime>(self) -> TauriPlugin<R, Option<Config>> {
435 PluginBuilder::<R, Option<Config>>::new("py-runtime")
436 .invoke_handler(tauri::generate_handler![
437 commands::invoke,
438 commands::cancel,
439 commands::emit,
440 commands::status
441 ])
442 .setup(move |app, api| {
443 let config = self.resolve(api.config().as_ref(), app)?;
444 let supervisor = Supervisor::new(config, Arc::new(TauriSink(app.clone())));
445 supervisor.start();
446 app.manage(PyRuntime { supervisor });
447 Ok(())
448 })
449 .on_event(|app, event| {
450 if let RunEvent::Exit = event {
451 if let Some(runtime) = app.try_state::<PyRuntime>() {
452 let supervisor = runtime.supervisor.clone();
453 tauri::async_runtime::block_on(async move { supervisor.shutdown().await });
455 }
456 }
457 })
458 .build()
459 }
460}
461
462pub fn init<R: Runtime>() -> TauriPlugin<R, Option<Config>> {
464 Builder::new().build()
465}
466
467#[cfg(test)]
468mod tests {
469 use super::*;
470 use std::sync::atomic::{AtomicUsize, Ordering};
471
472 struct TempDir(PathBuf);
473
474 impl TempDir {
475 fn new() -> Self {
476 static COUNTER: AtomicUsize = AtomicUsize::new(0);
477 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
478 let dir = std::env::temp_dir().join(format!("py-runtime-test-{}-{n}", std::process::id()));
479 let _ = std::fs::remove_dir_all(&dir);
480 std::fs::create_dir_all(&dir).unwrap();
481 Self(dir)
482 }
483
484 fn touch(&self, rel: &str) -> PathBuf {
485 let path = self.0.join(rel);
486 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
487 std::fs::write(&path, "").unwrap();
488 path
489 }
490 }
491
492 impl Drop for TempDir {
493 fn drop(&mut self) {
494 let _ = std::fs::remove_dir_all(&self.0);
495 }
496 }
497
498 fn env<'a>(cwd: Option<&'a Path>, resource_dir: Option<&'a Path>, dev: bool) -> Environment<'a> {
499 Environment { cwd, resource_dir, dev, app_name: "app".into(), app_version: "1.0.0".into() }
500 }
501
502 #[test]
503 fn absolute_script_is_used_as_is() {
504 let tmp = TempDir::new();
505 let script = tmp.touch("main.py");
506 assert_eq!(resolve_script(&script, None, None, false).unwrap(), script);
507 }
508
509 #[test]
510 fn missing_absolute_script_is_an_error() {
511 let tmp = TempDir::new();
512 let script = tmp.0.join("nope.py");
513 let err = resolve_script(&script, None, None, true).unwrap_err();
514 assert!(err.contains(&script.display().to_string()), "{err}");
515 }
516
517 #[test]
518 fn dev_relative_script_found_via_cwd() {
519 let tmp = TempDir::new();
520 let expected = tmp.touch("python/main.py");
521 let cwd = tmp.0.join("src-tauri");
522 std::fs::create_dir_all(&cwd).unwrap();
523 let resources = tmp.0.join("resources");
524 let found = resolve_script(Path::new("../python/main.py"), Some(&cwd), Some(&resources), true).unwrap();
525 assert_eq!(found.canonicalize().unwrap(), expected.canonicalize().unwrap());
526 }
527
528 #[test]
529 fn release_relative_script_found_in_resource_dir_with_up_mapping() {
530 let tmp = TempDir::new();
531 let cwd = tmp.0.join("src-tauri");
532 tmp.touch("python/main.py");
533 let expected = tmp.touch("resources/_up_/python/main.py");
534 let resources = tmp.0.join("resources");
535 let found = resolve_script(Path::new("../python/main.py"), Some(&cwd), Some(&resources), false).unwrap();
536 assert_eq!(found, expected);
537 }
538
539 #[test]
540 fn dev_falls_back_to_resource_dir() {
541 let tmp = TempDir::new();
542 let expected = tmp.touch("resources/main.py");
543 let cwd = tmp.0.join("cwd");
544 let resources = tmp.0.join("resources");
545 let found = resolve_script(Path::new("main.py"), Some(&cwd), Some(&resources), true).unwrap();
546 assert_eq!(found, expected);
547 }
548
549 #[test]
550 fn missing_relative_script_lists_tried_paths() {
551 let tmp = TempDir::new();
552 let cwd = tmp.0.join("cwd");
553 let resources = tmp.0.join("resources");
554 let err = resolve_script(Path::new("../py/main.py"), Some(&cwd), Some(&resources), true).unwrap_err();
555 assert!(err.contains(&cwd.join("../py/main.py").display().to_string()), "{err}");
556 assert!(err.contains(&resources.join("_up_").join("py").join("main.py").display().to_string()), "{err}");
557 }
558
559 #[test]
560 fn config_deserializes_camel_case_keys() {
561 let config: Config = serde_json::from_value(serde_json::json!({
562 "python": "py",
563 "script": "main.py",
564 "args": ["-v"],
565 "invokeTimeoutMs": 5,
566 "shutdownTimeoutMs": 6,
567 "restartOnCrash": true
568 }))
569 .unwrap();
570 assert_eq!(config.python.as_deref(), Some("py"));
571 assert_eq!(config.script, Some(PathBuf::from("main.py")));
572 assert_eq!(config.args, Some(vec!["-v".to_string()]));
573 assert_eq!(config.invoke_timeout_ms, Some(5));
574 assert_eq!(config.shutdown_timeout_ms, Some(6));
575 assert_eq!(config.restart_on_crash, Some(true));
576 }
577
578 #[test]
579 fn config_rejects_unknown_keys() {
580 let err = serde_json::from_value::<Config>(serde_json::json!({ "scirpt": "main.py" })).unwrap_err();
581 assert!(err.to_string().contains("scirpt"), "{err}");
582 }
583
584 #[test]
585 fn builder_values_override_file_config() {
586 let tmp = TempDir::new();
587 let builder_script = tmp.touch("builder.py");
588 tmp.touch("file.py");
589 let file = Config {
590 python: Some("file-python".into()),
591 script: Some("file.py".into()),
592 bundle: None,
593 args: Some(vec!["file-arg".into()]),
594 invoke_timeout_ms: Some(1000),
595 shutdown_timeout_ms: Some(2000),
596 restart_on_crash: Some(true),
597 };
598 let config = Builder::new()
599 .python("builder-python")
600 .script(&builder_script)
601 .args(["builder-arg"])
602 .invoke_timeout(Duration::from_secs(7))
603 .restart_on_crash(false)
604 .into_config(Some(&file), &env(Some(&tmp.0), None, true))
605 .unwrap();
606 assert_eq!(config.process.program, PathBuf::from("builder-python"));
607 assert_eq!(config.process.args, vec![builder_script.to_string_lossy().into_owned(), "builder-arg".into()]);
608 assert_eq!(config.process.cwd.as_deref(), Some(tmp.0.as_path()));
609 assert_eq!(config.invoke_timeout, Duration::from_secs(7));
610 assert_eq!(config.shutdown_timeout, Duration::from_millis(2000));
611 assert!(!config.restart_on_crash);
612 assert_eq!(config.app_name, "app");
613 }
614
615 #[test]
616 fn file_config_and_defaults_apply_when_builder_is_empty() {
617 let tmp = TempDir::new();
618 let script = tmp.touch("file.py");
619 let file = Config { script: Some("file.py".into()), invoke_timeout_ms: Some(1000), ..Default::default() };
620 let config = Builder::new().into_config(Some(&file), &env(Some(&tmp.0), None, true)).unwrap();
621 assert_eq!(config.process.program, PathBuf::from(default_python()));
622 assert_eq!(config.process.args, vec![tmp.0.join("file.py").to_string_lossy().into_owned()]);
623 assert_eq!(config.process.cwd.as_deref(), script.parent());
624 assert_eq!(config.invoke_timeout, Duration::from_millis(1000));
625 assert_eq!(config.shutdown_timeout, DEFAULT_SHUTDOWN_TIMEOUT);
626 assert_eq!(config.handshake_timeout, DEFAULT_HANDSHAKE_TIMEOUT);
627 assert!(!config.restart_on_crash);
628 }
629
630 #[test]
631 fn missing_script_is_a_config_error() {
632 let err = Builder::new().into_config(None, &env(None, None, true)).unwrap_err();
633 assert!(err.contains("no Python entry script"), "{err}");
634 }
635
636 fn venv_rel() -> &'static str {
637 if cfg!(windows) {
638 ".venv/Scripts/python.exe"
639 } else {
640 ".venv/bin/python"
641 }
642 }
643
644 #[test]
645 fn script_dir_venv_is_used_when_python_unset() {
646 let tmp = TempDir::new();
647 let script = tmp.touch("main.py");
648 let python = tmp.touch(venv_rel());
649 let config = Builder::new().script(&script).into_config(None, &env(None, None, true)).unwrap();
650 assert_eq!(config.process.program, python);
651 }
652
653 #[test]
654 fn explicit_python_wins_over_venv() {
655 let tmp = TempDir::new();
656 let script = tmp.touch("main.py");
657 tmp.touch(venv_rel());
658 let config = Builder::new()
659 .script(&script)
660 .python("custom-python")
661 .into_config(None, &env(None, None, true))
662 .unwrap();
663 assert_eq!(config.process.program, PathBuf::from("custom-python"));
664 }
665
666 fn exe(name: &str) -> String {
667 if cfg!(windows) {
668 format!("{name}.exe")
669 } else {
670 name.to_string()
671 }
672 }
673
674 #[test]
675 fn release_with_bundle_runs_packaged_executable() {
676 let tmp = TempDir::new();
677 let resources = tmp.0.join("resources");
678 let expected = tmp.touch(&format!("resources/py-backend/{}", exe("py-backend")));
679 let config = Builder::new()
680 .bundle("py-backend")
681 .args(["--flag"])
682 .into_config(None, &env(None, Some(&resources), false))
683 .unwrap();
684 assert_eq!(config.process.program, expected);
685 assert_eq!(config.process.args, vec!["--flag".to_string()]);
686 assert_eq!(config.process.cwd.as_deref(), expected.parent());
687 }
688
689 #[test]
690 fn dev_with_bundle_still_runs_script() {
691 let tmp = TempDir::new();
692 let script = tmp.touch("main.py");
693 let config = Builder::new()
694 .bundle("py-backend")
695 .script(&script)
696 .into_config(None, &env(None, None, true))
697 .unwrap();
698 assert_eq!(config.process.args, vec![script.to_string_lossy().into_owned()]);
699 }
700
701 #[test]
702 fn missing_bundle_lists_tried_path() {
703 let tmp = TempDir::new();
704 let resources = tmp.0.join("resources");
705 let err = Builder::new()
706 .bundle("py-backend")
707 .into_config(None, &env(None, Some(&resources), false))
708 .unwrap_err();
709 assert!(err.contains(&resources.join("py-backend").join(exe("py-backend")).display().to_string()), "{err}");
710 }
711
712 #[test]
713 fn bundle_from_file_config() {
714 let tmp = TempDir::new();
715 let resources = tmp.0.join("resources");
716 let expected = tmp.touch(&format!("resources/app/{}", exe("app")));
717 let file: Config = serde_json::from_value(serde_json::json!({ "bundle": "app" })).unwrap();
718 let config = Builder::new().into_config(Some(&file), &env(None, Some(&resources), false)).unwrap();
719 assert_eq!(config.process.program, expected);
720 }
721}