feat: add dependency manager and setup wizard
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<DependencyStatus> {
|
||||||
|
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<InstallEvent>,
|
||||||
|
) -> 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ mod commands;
|
|||||||
mod models;
|
mod models;
|
||||||
mod services;
|
mod services;
|
||||||
|
|
||||||
|
use commands::dependencies;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -9,6 +11,10 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
dependencies::check_dependencies,
|
||||||
|
dependencies::install_dependency,
|
||||||
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,131 @@
|
|||||||
// Implemented in Task 2
|
use crate::models::DependencyStatus;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn check_tool_exists(name: &str) -> Option<String> {
|
||||||
|
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<String> {
|
||||||
|
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<DependencyStatus> {
|
||||||
|
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<Vec<String>, 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,15 @@
|
|||||||
import Timeline from '$lib/components/Timeline.svelte';
|
import Timeline from '$lib/components/Timeline.svelte';
|
||||||
import ClipList from '$lib/components/ClipList.svelte';
|
import ClipList from '$lib/components/ClipList.svelte';
|
||||||
import StatusBar from '$lib/components/StatusBar.svelte';
|
import StatusBar from '$lib/components/StatusBar.svelte';
|
||||||
|
import SetupWizard from '$lib/components/SetupWizard.svelte';
|
||||||
|
|
||||||
|
let showSetupWizard = $state(true);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if showSetupWizard}
|
||||||
|
<SetupWizard onComplete={() => (showSetupWizard = false)} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<header class="toolbar">
|
<header class="toolbar">
|
||||||
<UrlInput />
|
<UrlInput />
|
||||||
|
|||||||
39
src/lib/bindings/dependencies.ts
Normal file
39
src/lib/bindings/dependencies.ts
Normal file
@@ -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<DependencyStatus[]> {
|
||||||
|
return invoke<DependencyStatus[]>('check_dependencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function installDependency(
|
||||||
|
name: string,
|
||||||
|
onOutput: (line: string) => void,
|
||||||
|
onFinished: (success: boolean) => void
|
||||||
|
): Promise<void> {
|
||||||
|
const onEvent = new Channel<InstallEvent>();
|
||||||
|
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 });
|
||||||
|
}
|
||||||
227
src/lib/components/SetupWizard.svelte
Normal file
227
src/lib/components/SetupWizard.svelte
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
checkDependencies,
|
||||||
|
installDependency,
|
||||||
|
type DependencyStatus,
|
||||||
|
} from '$lib/bindings/dependencies';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onComplete,
|
||||||
|
}: {
|
||||||
|
onComplete: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let statuses = $state<DependencyStatus[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let installingIndex = $state<number | null>(null);
|
||||||
|
let installLog = $state<string[]>([]);
|
||||||
|
let installError = $state<string | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
runChecks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function runChecks() {
|
||||||
|
loading = true;
|
||||||
|
statuses = await checkDependencies();
|
||||||
|
loading = false;
|
||||||
|
|
||||||
|
const allMet = statuses.every((s) => s.installed);
|
||||||
|
if (allMet) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function install(index: number) {
|
||||||
|
const dep = statuses[index];
|
||||||
|
installingIndex = index;
|
||||||
|
installLog = [];
|
||||||
|
installError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await installDependency(
|
||||||
|
dep.name,
|
||||||
|
(line) => {
|
||||||
|
installLog = [...installLog, line];
|
||||||
|
},
|
||||||
|
(success) => {
|
||||||
|
if (!success) {
|
||||||
|
installError = 'Installation failed. Check the log for details.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await runChecks();
|
||||||
|
} catch (e) {
|
||||||
|
installError = String(e);
|
||||||
|
} finally {
|
||||||
|
installingIndex = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
const criticalMet = statuses[0]?.installed && statuses[1]?.installed;
|
||||||
|
if (criticalMet) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let criticalMet = $derived(statuses[0]?.installed && statuses[1]?.installed);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="wizard-overlay">
|
||||||
|
<div class="wizard">
|
||||||
|
<h2>Checking dependencies…</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="wizard-overlay">
|
||||||
|
<div class="wizard">
|
||||||
|
<h2>Setup</h2>
|
||||||
|
<p class="subtitle">
|
||||||
|
Video Clipper requires a few tools to work. Let's make sure they're installed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="dep-list">
|
||||||
|
{#each statuses as dep, i}
|
||||||
|
<li class="dep-row">
|
||||||
|
<span class="dep-status">
|
||||||
|
{#if dep.installed}
|
||||||
|
✅
|
||||||
|
{:else if installingIndex === i}
|
||||||
|
⏳
|
||||||
|
{:else}
|
||||||
|
❌
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="dep-info">
|
||||||
|
<strong>{dep.name}</strong>
|
||||||
|
{#if dep.version}
|
||||||
|
<span class="version">{dep.version}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if !dep.installed && installingIndex === null}
|
||||||
|
<button onclick={() => install(i)}>Install</button>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if installLog.length > 0}
|
||||||
|
<div class="log-area">
|
||||||
|
{#each installLog as line}
|
||||||
|
<div class="log-line">{line}</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if installError}
|
||||||
|
<div class="error">{installError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="wizard-actions">
|
||||||
|
{#if criticalMet}
|
||||||
|
<button class="primary" onclick={skip}>
|
||||||
|
{statuses.every((s) => s.installed) ? 'Continue' : 'Skip optional & Continue'}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="hint">ffmpeg and yt-dlp are required to continue.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wizard-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 24px 32px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-list {
|
||||||
|
list-style: none;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dep-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-area {
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wizard-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user