diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 81b9771..8a3abb4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,11 @@ on:
jobs:
build-and-test:
- runs-on: windows-latest
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-latest, ubuntu-latest]
steps:
- name: Checkout Code
@@ -29,7 +33,8 @@ jobs:
run: dotnet build LocalLLMServerManager.slnx -c Release --no-restore
- name: Install Playwright Browsers
- run: pwsh LocalLLMServerManager.Tests/bin/Release/net10.0/playwright.ps1 install chromium
+ run: pwsh LocalLLMServerManager.Tests/bin/Release/net10.0/playwright.ps1 install --with-deps chromium
+ shell: pwsh
- name: Run Unit Tests & Collect Code Coverage
run: dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj -c Release --collect:"XPlat Code Coverage" --nologo
diff --git a/Endpoints/DiscoveryEndpoints.cs b/Endpoints/DiscoveryEndpoints.cs
index 9372190..9c6c1b1 100644
--- a/Endpoints/DiscoveryEndpoints.cs
+++ b/Endpoints/DiscoveryEndpoints.cs
@@ -130,6 +130,16 @@ public static void MapDiscoveryEndpoints(this WebApplication app)
results[nameof(request.AudioEngineExecutablePath)] = discoveryService.ValidatePath(request.AudioEngineExecutablePath, PathTargetType.Executable);
}
+ if (request.FFmpegExecutablePath != null)
+ {
+ results[nameof(request.FFmpegExecutablePath)] = discoveryService.ValidatePath(request.FFmpegExecutablePath, PathTargetType.Executable);
+ }
+
+ if (request.PythonExecutablePath != null)
+ {
+ results[nameof(request.PythonExecutablePath)] = discoveryService.ValidatePath(request.PythonExecutablePath, PathTargetType.Executable);
+ }
+
var allValid = results.Values.All(r => r.IsValid);
return Results.Ok(new ValidatePathsResponse(results, allValid));
});
diff --git a/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj b/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj
index c643efa..9b77993 100644
--- a/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj
+++ b/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj
@@ -4,9 +4,9 @@
net10.0
enable
enable
- 3.7.0
- 3.7.0.0
- 3.7.0.0
+ 3.8.0
+ 3.8.0.0
+ 3.8.0.0
true
diff --git a/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs b/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs
index 27544dc..65aca8d 100644
--- a/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs
+++ b/LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs
@@ -331,7 +331,9 @@ public async Task DetectAllToolsAsync_OnDefaultSearchRoots_DiscoversUniqueToolRo
{
Assert.NotEqual(results.ComfyUi.ExecutablePath, results.Forge.ExecutablePath);
Assert.NotEqual(results.ComfyUi.RootDirectory, results.Forge.RootDirectory);
+ Assert.NotNull(results.ComfyUi.ExecutablePath);
Assert.Contains("ComfyUI", results.ComfyUi.ExecutablePath, StringComparison.OrdinalIgnoreCase);
+ Assert.NotNull(results.Forge.ExecutablePath);
Assert.True(
results.Forge.ExecutablePath.Contains("SD_Forge", StringComparison.OrdinalIgnoreCase) ||
results.Forge.ExecutablePath.Contains("Forge", StringComparison.OrdinalIgnoreCase) ||
@@ -339,4 +341,105 @@ public async Task DetectAllToolsAsync_OnDefaultSearchRoots_DiscoversUniqueToolRo
);
}
}
+
+ [Fact]
+ public void DetectFFmpeg_WhenBinaryExistsInSearchRoot_DiscoversFFmpeg()
+ {
+ var binFolder = Path.Combine(_tempDirectory, "FFmpeg", "bin");
+ Directory.CreateDirectory(binFolder);
+ var binaryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ffmpeg.exe" : "ffmpeg";
+ var ffmpegFile = Path.Combine(binFolder, binaryName);
+ File.WriteAllText(ffmpegFile, "dummy");
+
+ var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
+ var result = service.DetectFFmpeg();
+
+ Assert.True(result.IsInstalled);
+ Assert.Equal(ffmpegFile, result.ExecutablePath);
+ Assert.Contains("Discovered FFmpeg", result.StatusMessage);
+ }
+
+ [Fact]
+ public void DetectFFmpeg_WhenMissing_ReturnsNotDetected()
+ {
+ var emptyFolder = Path.Combine(_tempDirectory, "EmptyFFmpegRoot");
+ Directory.CreateDirectory(emptyFolder);
+
+ var service = new ToolDiscoveryService(searchRoots: new[] { emptyFolder });
+ // If system has ffmpeg on PATH, it may find it, but if not it should return false or valid discovery
+ var result = service.DetectFFmpeg();
+ Assert.NotNull(result);
+ Assert.NotNull(result.StatusMessage);
+ }
+
+ [Fact]
+ public void DetectPythonEnvironment_WhenVirtualEnvExists_DiscoversPython()
+ {
+ var venvDir = Path.Combine(_tempDirectory, "test_env");
+ var scriptDir = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? Path.Combine(venvDir, "Scripts")
+ : Path.Combine(venvDir, "bin");
+ Directory.CreateDirectory(scriptDir);
+
+ var pyBinary = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3";
+ var pyFile = Path.Combine(scriptDir, pyBinary);
+ File.WriteAllText(pyFile, "dummy");
+
+ var service = new ToolDiscoveryService(searchRoots: new[] { venvDir });
+ var result = service.DetectPythonEnvironment();
+
+ Assert.True(result.IsInstalled);
+ Assert.Equal(pyFile, result.ExecutablePath);
+ Assert.Contains("Discovered Python", result.StatusMessage);
+ }
+
+ [Fact]
+ public void DetectForge_WithLinuxShellRunner_DiscoversForge()
+ {
+ var forgeFolder = Path.Combine(_tempDirectory, "stable-diffusion-webui");
+ Directory.CreateDirectory(forgeFolder);
+ var modelsFolder = Path.Combine(forgeFolder, "models");
+ Directory.CreateDirectory(modelsFolder);
+ var scriptPath = Path.Combine(forgeFolder, "webui.sh");
+ File.WriteAllText(scriptPath, "#!/usr/bin/env bash\npython launch.py");
+
+ var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
+ var result = service.DetectForge();
+
+ Assert.True(result.IsInstalled);
+ Assert.Equal(scriptPath, result.ExecutablePath);
+ Assert.Equal(forgeFolder, result.RootDirectory);
+ Assert.Equal(modelsFolder, result.ModelsDirectory);
+ }
+
+ [Fact]
+ public void DetectComfyUi_WithLinuxShellRunner_DiscoversComfy()
+ {
+ var comfyFolder = Path.Combine(_tempDirectory, "ComfyUI");
+ Directory.CreateDirectory(comfyFolder);
+ var modelsFolder = Path.Combine(comfyFolder, "models");
+ Directory.CreateDirectory(modelsFolder);
+ var scriptPath = Path.Combine(comfyFolder, "run.sh");
+ File.WriteAllText(scriptPath, "#!/usr/bin/env bash\npython main.py");
+
+ var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
+ var result = service.DetectComfyUi();
+
+ Assert.True(result.IsInstalled);
+ Assert.Equal(scriptPath, result.ExecutablePath);
+ Assert.Equal(comfyFolder, result.RootDirectory);
+ Assert.Equal(modelsFolder, result.ModelsDirectory);
+ }
+
+ [Fact]
+ public async Task DetectAllToolsAsync_IncludesFFmpegAndPython()
+ {
+ var service = new ToolDiscoveryService(searchRoots: new[] { _tempDirectory });
+ var result = await service.DetectAllToolsAsync();
+
+ Assert.NotNull(result);
+ Assert.NotNull(result.FFmpeg);
+ Assert.NotNull(result.PythonEnvironment);
+ Assert.NotNull(result.AudioEngine);
+ }
}
diff --git a/LocalLLMServerManager.csproj b/LocalLLMServerManager.csproj
index b79f5ae..3ad168c 100644
--- a/LocalLLMServerManager.csproj
+++ b/LocalLLMServerManager.csproj
@@ -11,9 +11,9 @@
MINOR — new user-facing features (bump per feature PR)
PATCH — bug fixes, dependency updates, doc-only changes
-->
- 3.7.0
- 3.7.0.0
- 3.7.0.0
+ 3.8.0
+ 3.8.0.0
+ 3.8.0.0
Assets\app-icon.ico
true
diff --git a/Program.cs b/Program.cs
index ca7aea2..ece24c0 100644
--- a/Program.cs
+++ b/Program.cs
@@ -23,11 +23,13 @@ public static void MainInternal(string[] args, bool runWeb = false)
{
if (args.Contains("--server") || args.Contains("--headless"))
{
+ Directory.SetCurrentDirectory(AppContext.BaseDirectory);
var app = CreateWebApplication(args, isServiceMode: false);
if (runWeb) app.Run();
}
else if (args.Contains("--service"))
{
+ Directory.SetCurrentDirectory(AppContext.BaseDirectory);
var app = CreateWebApplication(args, isServiceMode: true);
if (runWeb) app.Run();
}
@@ -135,7 +137,11 @@ public static bool IsSafePath(string path)
public static WebApplication CreateWebApplication(string[] args, bool isServiceMode = false, string url = "http://0.0.0.0:5246")
{
- var builder = WebApplication.CreateBuilder(args);
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ Args = args,
+ ContentRootPath = AppContext.BaseDirectory
+ });
builder.WebHost.UseUrls(url);
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
@@ -165,12 +171,16 @@ public static WebApplication CreateWebApplication(string[] args, bool isServiceM
}
catch { }
- if (isServiceMode)
+ if (isServiceMode && OperatingSystem.IsWindows())
{
builder.Host.UseWindowsService(options =>
{
options.ServiceName = "LocalLLMServerManager";
});
+ builder.Services.AddWindowsService(options =>
+ {
+ options.ServiceName = "LocalLLMServerManager";
+ });
}
var app = builder.Build();
diff --git a/README.md b/README.md
index 596bf2c..545f111 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Local LLM Server Manager
-> **v3.7.0** — A unified cross-platform application (.NET 10 + Avalonia UI & WebAssembly), System Tray app, background service/daemon, Model Context Protocol (MCP) AI API, visual orchestrator dashboard, and automated Playwright E2E testing framework to manage local Large Language Models (**Ollama**), Image Generation (**Stable Diffusion / Forge & ComfyUI**), **3D Mesh Generation (TRELLIS V2 & Hunyuan3D v2)**, **Video Generation (Wan 2.2, LTX-2.5, HunyuanVideo)**, and **Audio & Speech Generation (Kokoro TTS, Stable Audio Open 3.0, YuE)** on Windows, Linux, Mobile, and Web.
+> **v3.8.0** — A unified cross-platform application (.NET 10 + Avalonia UI & WebAssembly), System Tray app, background service/daemon, Model Context Protocol (MCP) AI API, visual orchestrator dashboard, and automated Playwright E2E testing framework to manage local Large Language Models (**Ollama**), Image Generation (**Stable Diffusion / Forge & ComfyUI**), **3D Mesh Generation (TRELLIS V2 & Hunyuan3D v2)**, **Video Generation (Wan 2.2, LTX-2.5, HunyuanVideo)**, and **Audio & Speech Generation (Kokoro TTS, Stable Audio Open 3.0, YuE)** on Windows, Linux, Mobile, and Web.
It features the official **`L³M²`** monochromatic brand identity, a high-contrast **Matte Carbon Design System**, a live **Dynamic Theming Engine** (Matte Carbon, OLED Black, Clean Light), integrated **`playwright-layout-inspector`** automated visual & layout audits, NVML CUDA real-time telemetry, **Hugging Face Hub** Multimodal discovery (GGUF, Text-to-Video, Image-to-Video, TTS, Text-to-Audio), **CivitAI** checkpoint downloads, **Multimodal Studio** with interactive 3D WebGL viewer, Video Player Preview, Audio Waveform Visualizer, a unified **Avalonia WebAssembly (WASM)** dashboard, **Modular Feature Packs** (`--with-video`, `--with-audio`), and an active **Model Context Protocol (MCP) Server** (`/mcp`).

@@ -32,7 +32,7 @@ The application features a dark Fluent Avalonia UI theme (`#0F172A`) organized i
| [====================================------------------------------------------------] |
| 8,192 tokens |
+-----------------------------------------------------------------------------------------+
-| LocalLLMServerManager v3.7.0 -- Unified WASM & Desktop UI System Tray Enabled 🟢 |
+| LocalLLMServerManager v3.8.0 -- Unified WASM & Desktop UI System Tray Enabled 🟢 |
+-----------------------------------------------------------------------------------------+
```
@@ -354,6 +354,7 @@ We use **MAJOR.MINOR.PATCH** (SemVer):
| `3.5.0` | Flexible tool path configuration, multi-drive auto-discovery service (`IToolDiscoveryService`), `POST /api/tools/detect`, official Model Context Protocol (MCP) server endpoint (`/mcp`) with 8 AI automation tools, and graceful in-place update support across Windows Inno Setup and shell installers |
| `3.6.0` | Monochromatic L³M² brand identity, Matte Carbon Design System, live Dynamic Theming Engine (Matte Carbon, OLED Black, Clean Light), and playwright-layout-inspector visual audits |
| `3.7.0` | Multimodal Video & Audio Studio (Wan 2.2, LTX-2.5, HunyuanVideo, Kokoro TTS, Stable Audio Open 3.0, YuE), interactive Video Player and Audio Waveform controls, Multimodal Hugging Face Discovery filters, 3 new MCP AI Tools (`generate_video`, `synthesize_speech`, `generate_audio`), OpenAI-compatible `/v1/audio/speech`, and Modular Feature Packs (`--with-video`, `--with-audio`) |
+| `3.8.0` | Cross-Platform Tool Discovery (FFmpeg hardware encoder detection: NVENC, Intel QSV, VAAPI, AMD AMF; Kokoro Python environment inspection; Linux paths & shell runners), Dual-OS GitHub Actions CI Matrix (`[windows-latest, ubuntu-latest]`), Windows Service directory handling & Linux headless guard, and enhanced Windows & Linux installers with automated Firewall rule creation and LAN/MCP endpoint summaries |
---
diff --git a/Services/IToolDiscoveryService.cs b/Services/IToolDiscoveryService.cs
index 92f0bb7..e39aaf2 100644
--- a/Services/IToolDiscoveryService.cs
+++ b/Services/IToolDiscoveryService.cs
@@ -9,6 +9,8 @@ public interface IToolDiscoveryService
DiscoveredToolInfo DetectComfyUi();
DiscoveredToolInfo DetectForge();
DiscoveredToolInfo DetectAudioEngine();
+ DiscoveredToolInfo DetectFFmpeg();
+ DiscoveredToolInfo DetectPythonEnvironment();
PathValidationResult ValidatePath(string? path, PathTargetType targetType);
}
@@ -27,7 +29,9 @@ public record DiscoveredToolsResult(
DiscoveredToolInfo Forge,
string SuggestedThreeDPath,
string SuggestedWorkflowsPath,
- DiscoveredToolInfo AudioEngine = default!
+ DiscoveredToolInfo AudioEngine = default!,
+ DiscoveredToolInfo FFmpeg = default!,
+ DiscoveredToolInfo PythonEnvironment = default!
);
public enum PathTargetType
@@ -50,7 +54,9 @@ public record ValidatePathsRequest(
string? ComfyUiExecutablePath = null,
string? ForgeExecutablePath = null,
string? OllamaExecutablePath = null,
- string? AudioEngineExecutablePath = null
+ string? AudioEngineExecutablePath = null,
+ string? FFmpegExecutablePath = null,
+ string? PythonExecutablePath = null
);
public record ValidatePathsResponse(
diff --git a/Services/ToolDiscoveryService.cs b/Services/ToolDiscoveryService.cs
index dc67cd8..2d94eb6 100644
--- a/Services/ToolDiscoveryService.cs
+++ b/Services/ToolDiscoveryService.cs
@@ -25,13 +25,17 @@ public async Task DetectAllToolsAsync()
var comfyTask = Task.Run(DetectComfyUi);
var forgeTask = Task.Run(DetectForge);
var audioTask = Task.Run(DetectAudioEngine);
+ var ffmpegTask = Task.Run(DetectFFmpeg);
+ var pythonTask = Task.Run(DetectPythonEnvironment);
- await Task.WhenAll(ollamaTask, comfyTask, forgeTask, audioTask);
+ await Task.WhenAll(ollamaTask, comfyTask, forgeTask, audioTask, ffmpegTask, pythonTask);
var ollama = await ollamaTask;
var comfy = await comfyTask;
var forge = await forgeTask;
var audio = await audioTask;
+ var ffmpeg = await ffmpegTask;
+ var python = await pythonTask;
var suggested3D = comfy.ModelsDirectory ?? forge.ModelsDirectory ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AI", "3D");
var suggestedWorkflows = comfy.WorkflowsDirectory ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "AI", "Workflows");
@@ -42,7 +46,9 @@ public async Task DetectAllToolsAsync()
Forge: forge,
SuggestedThreeDPath: suggested3D,
SuggestedWorkflowsPath: suggestedWorkflows,
- AudioEngine: audio
+ AudioEngine: audio,
+ FFmpeg: ffmpeg,
+ PythonEnvironment: python
);
}
@@ -206,6 +212,8 @@ public DiscoveredToolInfo DetectComfyUi()
"run_directml.bat",
"run_cpu.bat",
"run.bat",
+ "run.sh",
+ "start.sh",
"main.py",
"Comfy Desktop.exe",
"comfy-desktop.exe",
@@ -316,7 +324,11 @@ public DiscoveredToolInfo DetectForge()
{
"webui-user.bat",
"webui.bat",
+ "webui.sh",
+ "webui-user.sh",
"run.bat",
+ "run.sh",
+ "start.sh",
"launch.py",
"update.bat"
};
@@ -499,6 +511,363 @@ public DiscoveredToolInfo DetectAudioEngine()
);
}
+ public DiscoveredToolInfo DetectFFmpeg()
+ {
+ var binaryName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ffmpeg.exe" : "ffmpeg";
+ string? exePath = null;
+
+ // 1. Check custom search roots & common portable locations first
+ foreach (var root in _searchRoots)
+ {
+ var candidates = new[]
+ {
+ Path.Combine(root, binaryName),
+ Path.Combine(root, "bin", binaryName),
+ Path.Combine(root, "FFmpeg", "bin", binaryName),
+ Path.Combine(root, "ffmpeg", "bin", binaryName),
+ Path.Combine(root, "ffmpeg", binaryName),
+ Path.Combine(root, "Microsoft", "WinGet", "Links", binaryName)
+ };
+
+ foreach (var candidate in candidates)
+ {
+ if (File.Exists(candidate))
+ {
+ exePath = candidate;
+ break;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(exePath))
+ break;
+ }
+
+ // 2. Check PATH
+ if (string.IsNullOrEmpty(exePath))
+ {
+ exePath = FindExecutableOnPath(binaryName);
+ }
+
+ // 3. Check running processes
+ if (string.IsNullOrEmpty(exePath))
+ {
+ try
+ {
+ var processes = Process.GetProcessesByName("ffmpeg");
+ if (processes.Length > 0)
+ {
+ try
+ {
+ var procPath = processes[0].MainModule?.FileName;
+ if (!string.IsNullOrEmpty(procPath) && File.Exists(procPath))
+ {
+ exePath = procPath;
+ }
+ }
+ catch
+ {
+ // Ignore process module inspection errors
+ }
+ }
+ }
+ catch
+ {
+ // Ignore process enumeration failures
+ }
+ }
+
+ // 4. Check OS-specific standard directories
+ if (string.IsNullOrEmpty(exePath))
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var progFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+
+ var winCandidates = new[]
+ {
+ !string.IsNullOrEmpty(localAppData) ? Path.Combine(localAppData, "Microsoft", "WinGet", "Links", "ffmpeg.exe") : null,
+ !string.IsNullOrEmpty(progFiles) ? Path.Combine(progFiles, "FFmpeg", "bin", "ffmpeg.exe") : null,
+ !string.IsNullOrEmpty(userProfile) ? Path.Combine(userProfile, "scoop", "shims", "ffmpeg.exe") : null,
+ @"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
+ @"C:\FFmpeg\bin\ffmpeg.exe"
+ };
+
+ foreach (var candidate in winCandidates)
+ {
+ if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
+ {
+ exePath = candidate;
+ break;
+ }
+ }
+ }
+ else
+ {
+ var posixCandidates = new[]
+ {
+ "/usr/bin/ffmpeg",
+ "/usr/local/bin/ffmpeg",
+ "/snap/bin/ffmpeg",
+ "/opt/ffmpeg/bin/ffmpeg"
+ };
+
+ foreach (var candidate in posixCandidates)
+ {
+ if (File.Exists(candidate))
+ {
+ exePath = candidate;
+ break;
+ }
+ }
+ }
+ }
+
+ if (string.IsNullOrEmpty(exePath))
+ {
+ return new DiscoveredToolInfo(
+ IsInstalled: false,
+ ExecutablePath: null,
+ RootDirectory: null,
+ ModelsDirectory: null,
+ WorkflowsDirectory: null,
+ StatusMessage: "FFmpeg not detected"
+ );
+ }
+
+ var hardwareAccelerators = new List();
+ try
+ {
+ using var proc = new Process();
+ proc.StartInfo = new ProcessStartInfo
+ {
+ FileName = exePath,
+ Arguments = "-encoders",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ proc.Start();
+ var output = proc.StandardOutput.ReadToEnd();
+ if (proc.WaitForExit(1500))
+ {
+ if (output.Contains("nvenc", StringComparison.OrdinalIgnoreCase)) hardwareAccelerators.Add("NVENC (CUDA)");
+ if (output.Contains("qsv", StringComparison.OrdinalIgnoreCase)) hardwareAccelerators.Add("Intel QuickSync (QSV)");
+ if (output.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) hardwareAccelerators.Add("VAAPI");
+ if (output.Contains("amf", StringComparison.OrdinalIgnoreCase)) hardwareAccelerators.Add("AMD AMF");
+ }
+ }
+ catch
+ {
+ // Ignore execution failure
+ }
+
+ var hwText = hardwareAccelerators.Count > 0 ? $" (Hardware: {string.Join(", ", hardwareAccelerators)})" : "";
+
+ return new DiscoveredToolInfo(
+ IsInstalled: true,
+ ExecutablePath: exePath,
+ RootDirectory: Path.GetDirectoryName(exePath),
+ ModelsDirectory: null,
+ WorkflowsDirectory: null,
+ StatusMessage: $"Discovered FFmpeg at {exePath}{hwText}"
+ );
+ }
+
+ public DiscoveredToolInfo DetectPythonEnvironment()
+ {
+ var pythonNames = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? new[] { "python.exe", "python3.exe", "python" }
+ : new[] { "python3", "python" };
+
+ string? exePath = null;
+
+ // 1. Check custom search roots & standard virtualenvs first
+ foreach (var root in _searchRoots)
+ {
+ var candidates = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? new[]
+ {
+ Path.Combine(root, "python.exe"),
+ Path.Combine(root, "Scripts", "python.exe"),
+ Path.Combine(root, "venv", "Scripts", "python.exe"),
+ Path.Combine(root, ".venv", "Scripts", "python.exe"),
+ Path.Combine(root, "python_embeded", "python.exe"),
+ Path.Combine(root, "python", "python.exe")
+ }
+ : new[]
+ {
+ Path.Combine(root, "bin", "python3"),
+ Path.Combine(root, "bin", "python"),
+ Path.Combine(root, "venv", "bin", "python3"),
+ Path.Combine(root, "venv", "bin", "python"),
+ Path.Combine(root, ".venv", "bin", "python3"),
+ Path.Combine(root, ".venv", "bin", "python"),
+ Path.Combine(root, "python3"),
+ Path.Combine(root, "python")
+ };
+
+ foreach (var candidate in candidates)
+ {
+ if (File.Exists(candidate))
+ {
+ exePath = candidate;
+ break;
+ }
+ }
+
+ if (!string.IsNullOrEmpty(exePath))
+ break;
+ }
+
+ // 2. Check PATH
+ if (string.IsNullOrEmpty(exePath))
+ {
+ foreach (var name in pythonNames)
+ {
+ exePath = FindExecutableOnPath(name);
+ if (!string.IsNullOrEmpty(exePath))
+ break;
+ }
+ }
+
+ // 3. Check running processes
+ if (string.IsNullOrEmpty(exePath))
+ {
+ try
+ {
+ var processes = Process.GetProcessesByName("python")
+ .Concat(Process.GetProcessesByName("python3"))
+ .Concat(Process.GetProcessesByName("uvicorn"));
+
+ foreach (var proc in processes)
+ {
+ try
+ {
+ var procPath = proc.MainModule?.FileName;
+ if (!string.IsNullOrEmpty(procPath) && File.Exists(procPath))
+ {
+ exePath = procPath;
+ break;
+ }
+ }
+ catch
+ {
+ // Ignore process module inspection errors
+ }
+ }
+ }
+ catch
+ {
+ // Ignore process enumeration failures
+ }
+ }
+
+ // 4. Check OS-specific standard locations
+ if (string.IsNullOrEmpty(exePath))
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ var progFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+
+ var candidates = new List();
+ if (!string.IsNullOrEmpty(localAppData))
+ {
+ var pyDir = Path.Combine(localAppData, "Programs", "Python");
+ if (Directory.Exists(pyDir))
+ {
+ candidates.AddRange(Directory.GetFiles(pyDir, "python.exe", SearchOption.AllDirectories));
+ }
+ }
+
+ if (!string.IsNullOrEmpty(progFiles))
+ {
+ var pyDir = Path.Combine(progFiles, "Python");
+ if (Directory.Exists(pyDir))
+ {
+ candidates.AddRange(Directory.GetFiles(pyDir, "python.exe", SearchOption.AllDirectories));
+ }
+ }
+
+ exePath = candidates.FirstOrDefault(File.Exists);
+ }
+ else
+ {
+ var posixCandidates = new[]
+ {
+ "/usr/bin/python3",
+ "/usr/local/bin/python3",
+ "/usr/bin/python",
+ "/opt/venv/bin/python3",
+ "/opt/venv/bin/python"
+ };
+
+ foreach (var candidate in posixCandidates)
+ {
+ if (File.Exists(candidate))
+ {
+ exePath = candidate;
+ break;
+ }
+ }
+ }
+ }
+
+ if (string.IsNullOrEmpty(exePath))
+ {
+ return new DiscoveredToolInfo(
+ IsInstalled: false,
+ ExecutablePath: null,
+ RootDirectory: null,
+ ModelsDirectory: null,
+ WorkflowsDirectory: null,
+ StatusMessage: "Python environment not detected"
+ );
+ }
+
+ var missingAudioPackages = new List();
+ try
+ {
+ using var proc = new Process();
+ proc.StartInfo = new ProcessStartInfo
+ {
+ FileName = exePath,
+ Arguments = "-c \"import sys; [print(m) for m in ['kokoro_onnx','soundfile','fastapi','uvicorn','openai'] if __import__('importlib.util').util.find_spec(m) is None]\"",
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ proc.Start();
+ var output = proc.StandardOutput.ReadToEnd();
+ if (proc.WaitForExit(2000))
+ {
+ var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ missingAudioPackages.AddRange(lines);
+ }
+ }
+ catch
+ {
+ // Ignore inspection failures
+ }
+
+ var pkgStatus = missingAudioPackages.Count == 0
+ ? " (Audio TTS packages installed)"
+ : $" (Missing audio packages: {string.Join(", ", missingAudioPackages)})";
+
+ return new DiscoveredToolInfo(
+ IsInstalled: true,
+ ExecutablePath: exePath,
+ RootDirectory: Path.GetDirectoryName(exePath),
+ ModelsDirectory: null,
+ WorkflowsDirectory: null,
+ StatusMessage: $"Discovered Python at {exePath}{pkgStatus}"
+ );
+ }
+
public PathValidationResult ValidatePath(string? path, PathTargetType targetType)
{
if (string.IsNullOrWhiteSpace(path))
@@ -643,8 +1012,8 @@ private static List GetDefaultSearchRoots()
}
catch
{
- roots.Add(@"C:\");
- roots.Add(@"C:\AI");
+ roots.Add(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\" : "/");
+ roots.Add(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? @"C:\AI" : "/AI");
}
// User profile & AppData
@@ -653,6 +1022,7 @@ private static List GetDefaultSearchRoots()
{
roots.Add(userProfile);
roots.Add(Path.Combine(userProfile, "AI"));
+ roots.Add(Path.Combine(userProfile, ".local", "share"));
}
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
@@ -666,6 +1036,7 @@ private static List GetDefaultSearchRoots()
{
roots.Add(Path.Combine(localAppData, "AI"));
roots.Add(Path.Combine(localAppData, "Programs"));
+ roots.Add(Path.Combine(localAppData, "Microsoft", "WinGet", "Links"));
}
var progFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
@@ -680,6 +1051,19 @@ private static List GetDefaultSearchRoots()
roots.Add(progFilesX86);
}
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ roots.Add("/opt");
+ roots.Add("/opt/AI");
+ roots.Add("/srv");
+ roots.Add("/srv/AI");
+ roots.Add("/data");
+ roots.Add("/data/AI");
+ roots.Add("/mnt");
+ roots.Add("/usr/local");
+ roots.Add("/var/lib");
+ }
+
return roots.Where(Directory.Exists).ToList();
}
}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index ecbc774..393f8a4 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -1,6 +1,6 @@
# LocalLLMServerManager — System Architecture & Component Design
-> **v3.7.0 Architecture Specification & Mermaid Diagrams**
+> **v3.8.0 Architecture Specification & Mermaid Diagrams**
This document provides a visual and structural blueprint of **LocalLLMServerManager**, detailing its component decomposition, MVVM hierarchy, Minimal API route modules, Dependency Injection lifecycle, Model Context Protocol (MCP) Multimodal AI integration, VRAM orchestration flow, Modular Feature Pack management, WebAssembly static asset pipeline, Playwright E2E testing layer, and Docker containerization architecture.
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index e280d76..8f551e9 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -1,6 +1,6 @@
# Local LLM Server Manager — Detailed User Guide
-Welcome to the **Local LLM Server Manager (v3.7.0)**. This guide will walk you through the main tabs of the dashboard, showing you how to manage your local AI engines (Ollama, Stable Diffusion / Forge, ComfyUI, and Kokoro TTS), configure your settings, and successfully generate text, images, 3D models, video, and speech.
+Welcome to the **Local LLM Server Manager (v3.8.0)**. This guide will walk you through the main tabs of the dashboard, showing you how to manage your local AI engines (Ollama, Stable Diffusion / Forge, ComfyUI, and Kokoro TTS), configure your settings, and successfully generate text, images, 3D models, video, and speech.
---
diff --git a/docs/superpowers/plans/2026-08-25-cross-platform-tool-discovery-and-installers.md b/docs/superpowers/plans/2026-08-25-cross-platform-tool-discovery-and-installers.md
new file mode 100644
index 0000000..dda9813
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-25-cross-platform-tool-discovery-and-installers.md
@@ -0,0 +1,70 @@
+# Cross-Platform Tool Discovery, FFmpeg/Python Detection, Installers, and Linux Validation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Codify FFmpeg and Python audio tool detection/installation, enhance Linux and Windows search paths and runners, add firewall and service automation to installers, configure local WSL2 Linux testing, and establish a dual-OS CI matrix.
+
+**Architecture:** Extend `IToolDiscoveryService` and `ToolDiscoveryService` with `DetectFFmpeg()` and `DetectPythonEnvironment()` supporting Windows and Linux (`/opt`, `/srv`, POSIX paths, shell runners `webui.sh`, `run.sh`). Update `install.ps1`, `installer.iss`, and `install_linux.sh` to automate firewall exceptions, service startup, and dependency installation. Set up .NET in WSL2 for local Linux execution and dual-OS GitHub Actions CI.
+
+**Tech Stack:** C# / .NET 10.0, ASP.NET Core, Avalonia UI, PowerShell, Bash, Inno Setup, WSL2 Ubuntu, GitHub Actions.
+
+---
+
+### Task 1: Setup .NET SDK in Local WSL2 Ubuntu Environment
+
+**Files:**
+- WSL2 Environment: `wsl -d Ubuntu`
+
+- [ ] **Step 1: Install .NET 10/8 SDK or required packages in WSL2 Ubuntu**
+- [ ] **Step 2: Verify `dotnet --info` and `dotnet build` from WSL2 against `/mnt/c/Users/Alias/repos/LocalLLMServerManager`**
+
+---
+
+### Task 2: Enhance Tool Discovery for FFmpeg, Python Audio, and Linux Paths
+
+**Files:**
+- Modify: `Services/IToolDiscoveryService.cs`
+- Modify: `Services/ToolDiscoveryService.cs`
+- Modify: `Endpoints/DiscoveryEndpoints.cs`
+- Test: `LocalLLMServerManager.Tests/ToolDiscoveryServiceTests.cs`
+
+- [ ] **Step 1: Write unit tests for `DetectFFmpeg()` and `DetectPythonEnvironment()` across Windows and Linux search paths**
+- [ ] **Step 2: Update `IToolDiscoveryService.cs` and `ToolDiscoveryService.cs` with `DetectFFmpeg()`, `DetectPythonEnvironment()`, Linux search roots (`/opt`, `/srv`, `/data`, `~/.local/share`), and shell runners (`webui.sh`, `webui-user.sh`, `run.sh`, `start.sh`)**
+- [ ] **Step 3: Update `DiscoveryEndpoints.cs` to expose `ffmpeg` and `pythonEnvironment` in `/api/system/tools/detect`**
+- [ ] **Step 4: Run unit tests on Windows and ensure all tests pass**
+
+---
+
+### Task 3: Enhance Windows & Linux Installer Scripts
+
+**Files:**
+- Modify: `scripts/install.ps1`
+- Modify: `scripts/installer.iss`
+- Modify: `scripts/install_linux.sh`
+
+- [ ] **Step 1: Update `scripts/install.ps1` to support `-Firewall` (and interactive prompt), auto-start Windows Service with .NET host binPath, check/install `Gyan.FFmpeg` via winget, check/install Python audio packages when `-WithAudio` is selected, and print LAN connection URLs (`http://10.0.0.21:5246`)**
+- [ ] **Step 2: Update `scripts/installer.iss` with a `firewall` task adding/removing inbound TCP 5246 rules and auto-start service task**
+- [ ] **Step 3: Update `scripts/install_linux.sh` with FFmpeg apt/dnf installation, Python audio package installation, UFW/firewalld port 5246 opening, and systemd service generation**
+
+---
+
+### Task 4: Dual-OS CI Matrix in GitHub Actions
+
+**Files:**
+- Modify: `.github/workflows/ci.yml`
+
+- [ ] **Step 1: Update `.github/workflows/ci.yml` with `strategy.matrix.os: [windows-latest, ubuntu-latest]` and OS-appropriate step execution for Playwright and tests**
+- [ ] **Step 2: Verify workflow syntax and consistency**
+
+---
+
+### Task 5: Live Verification & Testing (Windows & WSL2 Linux)
+
+**Files:**
+- Execute in Windows: `dotnet test`, `npm run lint`, `npx tsc --noEmit`
+- Execute in WSL2: `wsl -d Ubuntu -- bash -c "cd /mnt/c/Users/Alias/repos/LocalLLMServerManager && dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj -c Release"`
+
+- [ ] **Step 1: Run complete unit test suite in Windows**
+- [ ] **Step 2: Run complete unit test suite natively inside WSL2 Linux**
+- [ ] **Step 3: Run `npm run lint` and `npx tsc --noEmit`**
+- [ ] **Step 4: Commit and push changes to `main`**
diff --git a/eslint.config.mjs b/eslint.config.mjs
index ece112d..cdf9410 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -17,6 +17,9 @@ export default tseslint.config(
'**/LocalLLMServerManager.Web/**',
'**/test-results/**',
'**/playwright-report/**',
+ '**/.dotnet/**',
+ '**/C:*/**',
+ '**/C*/**',
],
},
js.configs.recommended,
diff --git a/scripts/install.ps1 b/scripts/install.ps1
index ad36ade..fb38548 100644
--- a/scripts/install.ps1
+++ b/scripts/install.ps1
@@ -5,7 +5,9 @@ param(
[switch]$InstallService,
[switch]$Force,
[switch]$WithVideo,
- [switch]$WithAudio
+ [switch]$WithAudio,
+ [switch]$Firewall,
+ [switch]$NonInteractive
)
$ErrorActionPreference = "Stop"
@@ -24,11 +26,15 @@ Write-Host ""
# 1. Determine installation directory
$DefaultInstallDir = Join-Path $env:SystemDrive "LocalLLMServerManager"
if ([string]::IsNullOrWhiteSpace($InstallDir)) {
- $UserInstallDir = Read-Host "Enter installation directory [Default: $DefaultInstallDir]"
- if ([string]::IsNullOrWhiteSpace($UserInstallDir)) {
+ if ($PSBoundParameters.ContainsKey('NonInteractive') -or $Force) {
$InstallDir = $DefaultInstallDir
} else {
- $InstallDir = $UserInstallDir
+ $UserInstallDir = Read-Host "Enter installation directory [Default: $DefaultInstallDir]"
+ if ([string]::IsNullOrWhiteSpace($UserInstallDir)) {
+ $InstallDir = $DefaultInstallDir
+ } else {
+ $InstallDir = $UserInstallDir
+ }
}
}
@@ -40,7 +46,7 @@ if (-not (Test-Path $InstallDir)) {
Write-Host "Installing to: $InstallDir" -ForegroundColor Green
# 2. Service configuration decision
-if (-not $PSBoundParameters.ContainsKey('InstallService')) {
+if (-not $PSBoundParameters.ContainsKey('InstallService') -and -not $PSBoundParameters.ContainsKey('NonInteractive') -and -not $Force) {
$InstallServiceInput = Read-Host "Do you want to install as a background Windows Service? (Y/N) [Default: N]"
if ($InstallServiceInput -eq "Y" -or $InstallServiceInput -eq "y") {
$InstallService = $true
@@ -74,7 +80,34 @@ if ($RunningProcesses) {
Start-Sleep -Milliseconds 500
}
-# 4. Preserve existing settings.json so user configuration is never lost
+# 4. Check & Install Prerequisites (FFmpeg)
+Write-Host "Checking FFmpeg prerequisite..." -ForegroundColor Cyan
+$FFmpegCmd = Get-Command ffmpeg -ErrorAction SilentlyContinue
+if (-not $FFmpegCmd) {
+ Write-Warning "FFmpeg is not detected on system PATH."
+ $InstallFFmpeg = $false
+ if ($PSBoundParameters.ContainsKey('NonInteractive') -or $Force) {
+ $InstallFFmpeg = $true
+ } else {
+ $FFmpegInput = Read-Host "Would you like to install FFmpeg via winget (official Gyan.FFmpeg package)? (Y/N) [Default: Y]"
+ if ([string]::IsNullOrWhiteSpace($FFmpegInput) -or $FFmpegInput -eq "Y" -or $FFmpegInput -eq "y") {
+ $InstallFFmpeg = $true
+ }
+ }
+ if ($InstallFFmpeg) {
+ Write-Host "Installing FFmpeg using WinGet..." -ForegroundColor Yellow
+ try {
+ winget install Gyan.FFmpeg --accept-package-agreements --accept-source-agreements --silent | Out-Null
+ Write-Host "FFmpeg installed successfully!" -ForegroundColor Green
+ } catch {
+ Write-Warning "Winget installation of FFmpeg encountered an issue: $_"
+ }
+ }
+} else {
+ Write-Host "FFmpeg detected: $($FFmpegCmd.Source)" -ForegroundColor Green
+}
+
+# 5. Preserve existing settings.json so user configuration is never lost
$SettingsFile = Join-Path $InstallDir "settings.json"
$SettingsBackup = $null
if (Test-Path $SettingsFile) {
@@ -83,17 +116,17 @@ if (Test-Path $SettingsFile) {
Copy-Item -Path $SettingsFile -Destination $SettingsBackup -Force
}
-# 5. Build and Publish the application
+# 6. Build and Publish the application
Write-Host "Compiling and publishing application in Release mode..." -ForegroundColor Yellow
$ProjectDir = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$ProjectPath = Join-Path $ProjectDir "LocalLLMServerManager.csproj"
if (Test-Path $ProjectPath) {
- dotnet publish "$ProjectPath" -c Release -o "$InstallDir" --nologo
+ dotnet publish "$ProjectPath" -c Release -r win-x64 --self-contained false -o "$InstallDir" --nologo
} else {
- dotnet publish -c Release -o "$InstallDir" --nologo
+ dotnet publish -c Release -r win-x64 --self-contained false -o "$InstallDir" --nologo
}
-# 6. Restore preserved settings.json
+# 7. Restore preserved settings.json
if ($SettingsBackup -and (Test-Path $SettingsBackup)) {
Write-Host "Restoring preserved settings.json..." -ForegroundColor Green
Copy-Item -Path $SettingsBackup -Destination $SettingsFile -Force
@@ -102,7 +135,7 @@ if ($SettingsBackup -and (Test-Path $SettingsBackup)) {
$ExePath = Join-Path $InstallDir "LocalLLMServerManager.exe"
-# 7. Configure / Restart Windows Service
+# 8. Configure / Restart Windows Service
if ($InstallService) {
$ExistingService = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
if ($ExistingService) {
@@ -130,14 +163,34 @@ if ($InstallService) {
Write-Host " $InstallDir\LocalLLMServerManager.exe" -ForegroundColor Cyan
}
-# 8. Configure System Tray Auto-Start on User Logon
+# 9. Configure Windows Defender Firewall Rule for Port 5246
+$ConfigureFirewall = $Firewall
+if (-not $PSBoundParameters.ContainsKey('Firewall') -and -not $PSBoundParameters.ContainsKey('NonInteractive') -and -not $Force) {
+ $FirewallInput = Read-Host "Configure Windows Defender Firewall rule for port 5246 (allow LAN & MCP router access)? (Y/N) [Default: Y]"
+ if ([string]::IsNullOrWhiteSpace($FirewallInput) -or $FirewallInput -eq "Y" -or $FirewallInput -eq "y") {
+ $ConfigureFirewall = $true
+ }
+}
+
+if ($ConfigureFirewall) {
+ if (Test-Administrator) {
+ Write-Host "Configuring Windows Defender Firewall inbound rule on TCP port 5246..." -ForegroundColor Yellow
+ netsh.exe advfirewall firewall delete rule name="LocalLLM Server Manager" | Out-Null
+ netsh.exe advfirewall firewall add rule name="LocalLLM Server Manager" dir=in action=allow protocol=TCP localport=5246 | Out-Null
+ Write-Host "Firewall rule created for port 5246." -ForegroundColor Green
+ } else {
+ Write-Warning "Administrator privileges required to add firewall rules. Skipping firewall configuration."
+ }
+}
+
+# 10. Configure System Tray Auto-Start on User Logon
Write-Host "Configuring System Tray App to auto-start on logon..." -ForegroundColor Yellow
Set-ItemProperty -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" `
-Name "LocalLLMServerManagerTray" `
-Value "`"$ExePath`"" -ErrorAction SilentlyContinue
Write-Host "System Tray auto-start configured!" -ForegroundColor Green
-# 9. Feature Pack Optional Installation
+# 11. Feature Pack Optional Installation
if ($WithVideo) {
Write-Host "Installing Video Generation Feature Pack..." -ForegroundColor Cyan
$videoWorkflowDir = Join-Path $InstallDir "Workflows\Video"
@@ -152,15 +205,45 @@ if ($WithAudio) {
$audioModelsDir = Join-Path $InstallDir "models\audio"
if (-not (Test-Path $kokoroDir)) { New-Item -ItemType Directory -Path $kokoroDir -Force | Out-Null }
if (-not (Test-Path $audioModelsDir)) { New-Item -ItemType Directory -Path $audioModelsDir -Force | Out-Null }
+
+ $PyCmd = Get-Command python -ErrorAction SilentlyContinue
+ if (-not $PyCmd) {
+ $PyCmd = Get-Command python3 -ErrorAction SilentlyContinue
+ }
+ if ($PyCmd) {
+ Write-Host "Installing/verifying Python audio packages (kokoro-onnx, soundfile, fastapi, uvicorn, openai)..." -ForegroundColor Yellow
+ try {
+ & $PyCmd.Source -m pip install kokoro-onnx soundfile fastapi uvicorn openai --quiet
+ Write-Host "Python audio packages installed successfully." -ForegroundColor Green
+ } catch {
+ Write-Warning "Failed to install Python audio packages: $_"
+ }
+ } else {
+ Write-Warning "Python not found on PATH. Please install Python 3.10+ for Kokoro TTS audio support."
+ }
}
-# 10. Relaunch Tray Application if it was running
+# 12. Relaunch Tray Application if it was running
if ($HadRunningProcesses) {
Write-Host "Relaunching LocalLLMServerManager tray application..." -ForegroundColor Yellow
Start-Process -FilePath $ExePath -ErrorAction SilentlyContinue
}
+# 13. Detect Primary LAN IP address for summary
+$LanIp = "10.0.0.21"
+try {
+ $DetectedIp = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceAlias -notmatch "vEthernet|Loopback|WSL" -and $_.IPAddress -notmatch "^127\.|^169\.254\." } | Select-Object -First 1).IPAddress
+ if (-not [string]::IsNullOrWhiteSpace($DetectedIp)) {
+ $LanIp = $DetectedIp
+ }
+} catch { }
+
Write-Host ""
-Write-Host "Installation Complete!" -ForegroundColor Green
-Write-Host "The dashboard is available at http://localhost:5246" -ForegroundColor Green
+Write-Host "==========================================" -ForegroundColor Cyan
+Write-Host " Installation Complete! " -ForegroundColor Green
+Write-Host "==========================================" -ForegroundColor Cyan
+Write-Host "Local Dashboard: http://localhost:5246" -ForegroundColor Green
+Write-Host "Local MCP Endpoint: http://localhost:5246/mcp" -ForegroundColor Green
+Write-Host "Network Dashboard: http://${LanIp}:5246" -ForegroundColor Cyan
+Write-Host "Network MCP: http://${LanIp}:5246/mcp" -ForegroundColor Cyan
Write-Host "==========================================" -ForegroundColor Cyan
diff --git a/scripts/install_linux.sh b/scripts/install_linux.sh
index d3b5337..3d05b04 100755
--- a/scripts/install_linux.sh
+++ b/scripts/install_linux.sh
@@ -77,7 +77,22 @@ fi
chmod +x "${INSTALL_DIR}/LocalLLMServerManager"
-# 5. Restore preserved settings.json
+# 5. Check & Install Prerequisites (FFmpeg)
+echo "--> Checking FFmpeg prerequisite..."
+if ! command -v ffmpeg >/dev/null 2>&1; then
+ echo "--> FFmpeg not found. Attempting package installation..."
+ if command -v apt-get >/dev/null 2>&1; then
+ apt-get update && apt-get install -y ffmpeg || true
+ elif command -v dnf >/dev/null 2>&1; then
+ dnf install -y ffmpeg || true
+ elif command -v pacman >/dev/null 2>&1; then
+ pacman -Sy --noconfirm ffmpeg || true
+ fi
+else
+ echo "--> FFmpeg detected: $(command -v ffmpeg)"
+fi
+
+# 6. Restore preserved settings.json
if [ -n "${SETTINGS_BACKUP}" ] && [ -f "${SETTINGS_BACKUP}" ]; then
echo "--> Restoring preserved settings.json..."
cp "${SETTINGS_BACKUP}" "${SETTINGS_FILE}"
@@ -85,25 +100,55 @@ if [ -n "${SETTINGS_BACKUP}" ] && [ -f "${SETTINGS_BACKUP}" ]; then
chmod 666 "${SETTINGS_FILE}" 2>/dev/null || true
fi
-# 6. Create symlink in /usr/local/bin
+# 7. Create symlink in /usr/local/bin
echo "--> Creating symlink in /usr/local/bin..."
ln -sf "${INSTALL_DIR}/LocalLLMServerManager" "${BIN_LINK}"
-# 7. Install Desktop launcher if file exists
+# 8. Install Desktop launcher if file exists
if [ -f "${SCRIPT_DIR}/localllmmanager.desktop" ]; then
echo "--> Installing Desktop launcher..."
cp "${SCRIPT_DIR}/localllmmanager.desktop" "${DESKTOP_FILE}"
chmod 644 "${DESKTOP_FILE}"
fi
-# 8. Install systemd service unit if file exists
+# 9. Install systemd service unit
if [ -f "${SCRIPT_DIR}/localllmmanager.service" ]; then
echo "--> Installing systemd service..."
cp "${SCRIPT_DIR}/localllmmanager.service" "${SERVICE_FILE}"
chmod 644 "${SERVICE_FILE}"
+else
+ echo "--> Creating systemd service unit..."
+ cat << 'EOF' > "${SERVICE_FILE}"
+[Unit]
+Description=Local LLM Server Manager
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/localllmmanager --service
+Restart=always
+RestartSec=5
+User=root
+WorkingDirectory=/usr/local/share/LocalLLMServerManager
+
+[Install]
+WantedBy=multi-user.target
+EOF
+ chmod 644 "${SERVICE_FILE}"
+fi
+
+# 10. Configure Firewall for Port 5246
+echo "--> Configuring firewall for port 5246..."
+if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
+ ufw allow 5246/tcp comment 'LocalLLM Server Manager' || true
+ echo "--> UFW port 5246/tcp allowed."
+elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld 2>/dev/null; then
+ firewall-cmd --add-port=5246/tcp --permanent || true
+ firewall-cmd --reload || true
+ echo "--> firewalld port 5246/tcp allowed."
fi
-# 9. Optional Feature Pack Installation
+# 11. Optional Feature Pack Installation
if [ "${WITH_VIDEO}" -eq 1 ]; then
echo "--> Installing Video Generation Feature Pack..."
mkdir -p "${INSTALL_DIR}/Workflows/Video"
@@ -113,9 +158,13 @@ if [ "${WITH_AUDIO}" -eq 1 ]; then
echo "--> Installing Audio & Kokoro TTS Feature Pack..."
mkdir -p "${INSTALL_DIR}/kokoro-fastapi"
mkdir -p "${INSTALL_DIR}/models/audio"
+ if command -v python3 >/dev/null 2>&1; then
+ echo "--> Installing Python audio packages (kokoro-onnx, soundfile, fastapi, uvicorn, openai)..."
+ python3 -m pip install --break-system-packages kokoro-onnx soundfile fastapi uvicorn openai || python3 -m pip install kokoro-onnx soundfile fastapi uvicorn openai || true
+ fi
fi
-# 10. Reload systemd daemon and restart service if it was previously running
+# 12. Reload systemd daemon and restart service if it was previously running
echo "--> Reloading systemd daemon..."
systemctl daemon-reload
@@ -124,11 +173,18 @@ if [ "${SERVICE_WAS_ACTIVE}" -eq 1 ] || systemctl is-enabled --quiet "${SERVICE_
systemctl restart "${SERVICE_NAME}" || true
fi
+# 13. Summary
+HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"
+[ -z "${HOST_IP}" ] && HOST_IP="10.0.0.21"
+
echo ""
echo "=========================================="
echo " Installation Complete!"
echo "=========================================="
+echo "Local Dashboard: http://localhost:5246"
+echo "Local MCP Endpoint: http://localhost:5246/mcp"
+echo "Network Dashboard: http://${HOST_IP}:5246"
+echo "Network MCP: http://${HOST_IP}:5246/mcp"
echo "To start native desktop app: localllmmanager"
-echo "To enable background systemd service: sudo systemctl enable --now localllmmanager"
-echo "Web Dashboard will run at: http://localhost:5246"
+echo "To enable background service: sudo systemctl enable --now localllmmanager"
echo "=========================================="
diff --git a/scripts/installer.iss b/scripts/installer.iss
index 5bac2f0..208b469 100644
--- a/scripts/installer.iss
+++ b/scripts/installer.iss
@@ -1,6 +1,6 @@
-; Script generated for Inno Setup - LocalLLMServerManager v3.7.0
+; Script generated for Inno Setup - LocalLLMServerManager v3.8.0
#define MyAppName "Local LLM Server Manager"
-#define MyAppVersion "3.7.0"
+#define MyAppVersion "3.8.0"
#define MyAppPublisher "LocalLLMServerManager Team"
#define MyAppURL "https://github.com/spelech/LocalLLMServerManager"
#define MyAppExeName "LocalLLMServerManager.exe"
@@ -44,6 +44,7 @@ Name: "ext_audio"; Description: "Kokoro / Audio Engine Pack (FastAPI TTS server
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
Name: "autostart"; Description: "Auto-start System Tray App on user login"; GroupDescription: "System Integration"
Name: "windowsservice"; Description: "Install background Windows Service (starts automatically on system boot)"; GroupDescription: "System Integration"; Flags: checkedonce
+Name: "firewall"; Description: "Add Windows Defender Firewall rule for port 5246 (allow LAN & MCP Router access)"; GroupDescription: "Network & Access"; Flags: checkedonce
[Dirs]
Name: "{app}\Workflows\Video"; Components: ext_video
@@ -73,6 +74,9 @@ Filename: "sc.exe"; Parameters: "create LocalLLMServerManager binPath= """"{app}
Filename: "sc.exe"; Parameters: "config LocalLLMServerManager binPath= """"{app}\{#MyAppExeName}"" --service"" start= auto displayName= ""Local LLM Server Manager"""; Tasks: windowsservice; Flags: runhidden
Filename: "sc.exe"; Parameters: "description LocalLLMServerManager ""Orchestrates GPU VRAM between Ollama and Forge, and manages local model weights."""; Tasks: windowsservice; Flags: runhidden
Filename: "net.exe"; Parameters: "start LocalLLMServerManager"; Tasks: windowsservice; Flags: runhidden
+; Add Windows Defender Firewall inbound rule on TCP port 5246
+Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""LocalLLM Server Manager"""; Tasks: firewall; Flags: runhidden
+Filename: "netsh.exe"; Parameters: "advfirewall firewall add rule name=""LocalLLM Server Manager"" dir=in action=allow protocol=TCP localport=5246"; Tasks: firewall; Flags: runhidden
; Launch Tray App after installation completes
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
@@ -80,6 +84,8 @@ Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang
; Stop and remove Windows Service on uninstall
Filename: "net.exe"; Parameters: "stop LocalLLMServerManager"; Flags: runhidden
Filename: "sc.exe"; Parameters: "delete LocalLLMServerManager"; Flags: runhidden
+; Remove Windows Defender Firewall rule on uninstall
+Filename: "netsh.exe"; Parameters: "advfirewall firewall delete rule name=""LocalLLM Server Manager"""; Flags: runhidden
Filename: "taskkill.exe"; Parameters: "/F /IM {#MyAppExeName} /T"; Flags: runhidden
[Code]