diff --git a/src-tauri/src/commands/dependencies.rs b/src-tauri/src/commands/dependencies.rs index 118b365..0d7a629 100644 --- a/src-tauri/src/commands/dependencies.rs +++ b/src-tauri/src/commands/dependencies.rs @@ -1 +1,53 @@ -// Dependency check and install commands — implemented in Task 2 +use serde::Serialize; +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; +use tauri::ipc::Channel; + +use crate::models::DependencyStatus; +use crate::services::dependency_manager; + +#[tauri::command] +pub fn check_dependencies() -> Vec { + dependency_manager::get_all_dependency_statuses() +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "event", content = "data")] +pub enum InstallEvent { + Output { line: String }, + Finished { success: bool }, +} + +#[tauri::command] +pub async fn install_dependency( + name: String, + on_event: Channel, +) -> Result<(), String> { + let args = dependency_manager::get_install_args(&name)?; + let (cmd, cmd_args) = args.split_first().ok_or("Empty install command")?; + + let mut child = Command::new(cmd) + .args(cmd_args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start install: {e}"))?; + + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + for line in reader.lines().map_while(Result::ok) { + let _ = on_event.send(InstallEvent::Output { line }); + } + } + + let status = child.wait().map_err(|e| format!("Install failed: {e}"))?; + let _ = on_event.send(InstallEvent::Finished { + success: status.success(), + }); + + if status.success() { + Ok(()) + } else { + Err("Install command exited with non-zero status".to_string()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 309d5d3..b6de1fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ mod commands; mod models; mod services; +use commands::dependencies; + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -9,6 +11,10 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_process::init()) + .invoke_handler(tauri::generate_handler![ + dependencies::check_dependencies, + dependencies::install_dependency, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src-tauri/src/services/dependency_manager.rs b/src-tauri/src/services/dependency_manager.rs index 8fdbf19..2d533a9 100644 --- a/src-tauri/src/services/dependency_manager.rs +++ b/src-tauri/src/services/dependency_manager.rs @@ -1 +1,131 @@ -// Implemented in Task 2 +use crate::models::DependencyStatus; +use std::process::Command; + +pub fn check_tool_exists(name: &str) -> Option { + let output = Command::new("which").arg(name).output().ok()?; + + if output.status.success() { + let version_output = Command::new(name).arg("--version").output().ok()?; + let version = String::from_utf8_lossy(&version_output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + Some(version) + } else { + None + } +} + +pub fn check_js_runtime() -> Option { + check_tool_exists("deno").or_else(|| check_tool_exists("node")) +} + +pub fn check_pot_plugin() -> bool { + let output = Command::new("yt-dlp") + .args(["-v", "--simulate", "https://www.youtube.com/watch?v=dQw4w9WgXcQ"]) + .output(); + + match output { + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + stderr.contains("GetPOT") + } + Err(_) => false, + } +} + +pub fn get_all_dependency_statuses() -> Vec { + let ffmpeg = check_tool_exists("ffmpeg"); + let ytdlp = check_tool_exists("yt-dlp"); + let js_runtime = check_js_runtime(); + let pot_plugin = if ytdlp.is_some() { + check_pot_plugin() + } else { + false + }; + + vec![ + DependencyStatus { + name: "ffmpeg".to_string(), + installed: ffmpeg.is_some(), + version: ffmpeg, + install_command: "brew install ffmpeg".to_string(), + }, + DependencyStatus { + name: "yt-dlp".to_string(), + installed: ytdlp.is_some(), + version: ytdlp, + install_command: "brew install yt-dlp".to_string(), + }, + DependencyStatus { + name: "JavaScript runtime (deno/node)".to_string(), + installed: js_runtime.is_some(), + version: js_runtime, + install_command: "brew install deno".to_string(), + }, + DependencyStatus { + name: "bgutil-ytdlp-pot-provider".to_string(), + installed: pot_plugin, + version: None, + install_command: "python3 -m pip install -U bgutil-ytdlp-pot-provider".to_string(), + }, + ] +} + +pub fn get_install_args(dep_name: &str) -> Result, String> { + match dep_name { + "ffmpeg" => Ok(vec![ + "brew".into(), "install".into(), "ffmpeg".into(), + ]), + "yt-dlp" => Ok(vec![ + "brew".into(), "install".into(), "yt-dlp".into(), + ]), + "JavaScript runtime (deno/node)" => Ok(vec![ + "brew".into(), "install".into(), "deno".into(), + ]), + "bgutil-ytdlp-pot-provider" => Ok(vec![ + "python3".into(), "-m".into(), "pip".into(), "install".into(), "-U".into(), "bgutil-ytdlp-pot-provider".into(), + ]), + _ => Err(format!("Unknown dependency: {dep_name}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_install_args_ffmpeg() { + let args = get_install_args("ffmpeg").unwrap(); + assert_eq!(args, vec!["brew", "install", "ffmpeg"]); + } + + #[test] + fn test_get_install_args_ytdlp() { + let args = get_install_args("yt-dlp").unwrap(); + assert_eq!(args, vec!["brew", "install", "yt-dlp"]); + } + + #[test] + fn test_get_install_args_pot_provider() { + let args = get_install_args("bgutil-ytdlp-pot-provider").unwrap(); + assert_eq!(args, vec!["python3", "-m", "pip", "install", "-U", "bgutil-ytdlp-pot-provider"]); + } + + #[test] + fn test_get_install_args_unknown() { + assert!(get_install_args("unknown-tool").is_err()); + } + + #[test] + fn test_dependency_statuses_has_four_entries() { + let statuses = get_all_dependency_statuses(); + assert_eq!(statuses.len(), 4); + assert_eq!(statuses[0].name, "ffmpeg"); + assert_eq!(statuses[1].name, "yt-dlp"); + assert_eq!(statuses[2].name, "JavaScript runtime (deno/node)"); + assert_eq!(statuses[3].name, "bgutil-ytdlp-pot-provider"); + } +} diff --git a/src/App.svelte b/src/App.svelte index 481b1c8..655abc3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -5,8 +5,15 @@ import Timeline from '$lib/components/Timeline.svelte'; import ClipList from '$lib/components/ClipList.svelte'; import StatusBar from '$lib/components/StatusBar.svelte'; + import SetupWizard from '$lib/components/SetupWizard.svelte'; + + let showSetupWizard = $state(true); +{#if showSetupWizard} + (showSetupWizard = false)} /> +{/if} +
diff --git a/src/lib/bindings/dependencies.ts b/src/lib/bindings/dependencies.ts new file mode 100644 index 0000000..8bddc17 --- /dev/null +++ b/src/lib/bindings/dependencies.ts @@ -0,0 +1,39 @@ +import { invoke, Channel } from '@tauri-apps/api/core'; + +export interface DependencyStatus { + name: string; + installed: boolean; + version: string | null; + installCommand: string; +} + +type InstallEvent = + | { event: 'output'; data: { line: string } } + | { event: 'finished'; data: { success: boolean } }; + +export async function checkDependencies(): Promise { + return invoke('check_dependencies'); +} + +export async function installDependency( + name: string, + onOutput: (line: string) => void, + onFinished: (success: boolean) => void +): Promise { + const onEvent = new Channel(); + onEvent.onmessage = (message) => { + switch (message.event) { + case 'output': + onOutput(message.data.line); + break; + case 'finished': + onFinished(message.data.success); + break; + default: { + const _exhaustive: never = message; + throw new Error(`Unhandled install event: ${(_exhaustive as InstallEvent).event}`); + } + } + }; + await invoke('install_dependency', { name, onEvent }); +} diff --git a/src/lib/components/SetupWizard.svelte b/src/lib/components/SetupWizard.svelte new file mode 100644 index 0000000..953aa41 --- /dev/null +++ b/src/lib/components/SetupWizard.svelte @@ -0,0 +1,227 @@ + + +{#if loading} +
+
+

Checking dependencies…

+
+
+{:else} +
+
+

Setup

+

+ Video Clipper requires a few tools to work. Let's make sure they're installed. +

+ +
    + {#each statuses as dep, i} +
  • + + {#if dep.installed} + ✅ + {:else if installingIndex === i} + ⏳ + {:else} + ❌ + {/if} + + + {dep.name} + {#if dep.version} + {dep.version} + {/if} + + {#if !dep.installed && installingIndex === null} + + {/if} +
  • + {/each} +
+ + {#if installLog.length > 0} +
+ {#each installLog as line} +
{line}
+ {/each} +
+ {/if} + + {#if installError} +
{installError}
+ {/if} + +
+ {#if criticalMet} + + {:else} +

ffmpeg and yt-dlp are required to continue.

+ {/if} +
+
+
+{/if} + +