diff --git a/App.axaml b/App.axaml
new file mode 100644
index 0000000..54fb39a
--- /dev/null
+++ b/App.axaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/App.axaml.cs b/App.axaml.cs
new file mode 100644
index 0000000..7cc1602
--- /dev/null
+++ b/App.axaml.cs
@@ -0,0 +1,30 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using UnifiedFarmLauncher.ViewModels;
+using UnifiedFarmLauncher.Views;
+
+namespace UnifiedFarmLauncher
+{
+ public partial class App : Application
+ {
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow
+ {
+ DataContext = new MainWindowViewModel(),
+ };
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ }
+}
+
diff --git a/Models/ConfigRoot.cs b/Models/ConfigRoot.cs
new file mode 100644
index 0000000..c4eafe5
--- /dev/null
+++ b/Models/ConfigRoot.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace UnifiedFarmLauncher.Models
+{
+ public class ConfigRoot
+ {
+ [JsonPropertyName("workers")]
+ public List Workers { get; set; } = new();
+
+ [JsonPropertyName("globalSettings")]
+ public GlobalSettings GlobalSettings { get; set; } = new();
+ }
+}
+
diff --git a/Models/GlobalSettings.cs b/Models/GlobalSettings.cs
new file mode 100644
index 0000000..52459fa
--- /dev/null
+++ b/Models/GlobalSettings.cs
@@ -0,0 +1,16 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace UnifiedFarmLauncher.Models
+{
+ public class GlobalSettings
+ {
+ [JsonPropertyName("sheepitJarUrls")]
+ public List SheepItJarUrls { get; set; } = new()
+ {
+ "https://www.sheepit-renderfarm.com/media/applet/client-latest.php",
+ "https://www.sheepit-renderfarm.com/media/applet/client-latest.jar"
+ };
+ }
+}
+
diff --git a/Models/SshConfig.cs b/Models/SshConfig.cs
new file mode 100644
index 0000000..dc1465f
--- /dev/null
+++ b/Models/SshConfig.cs
@@ -0,0 +1,17 @@
+using System.Text.Json.Serialization;
+
+namespace UnifiedFarmLauncher.Models
+{
+ public class SshConfig
+ {
+ [JsonPropertyName("host")]
+ public string Host { get; set; } = string.Empty;
+
+ [JsonPropertyName("port")]
+ public int Port { get; set; } = 22;
+
+ [JsonPropertyName("args")]
+ public string Args { get; set; } = string.Empty;
+ }
+}
+
diff --git a/Models/WorkerConfig.cs b/Models/WorkerConfig.cs
new file mode 100644
index 0000000..697c7f3
--- /dev/null
+++ b/Models/WorkerConfig.cs
@@ -0,0 +1,23 @@
+using System.Text.Json.Serialization;
+
+namespace UnifiedFarmLauncher.Models
+{
+ public class WorkerConfig
+ {
+ [JsonPropertyName("id")]
+ public int Id { get; set; }
+
+ [JsonPropertyName("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonPropertyName("enabled")]
+ public bool Enabled { get; set; } = true;
+
+ [JsonPropertyName("ssh")]
+ public SshConfig Ssh { get; set; } = new();
+
+ [JsonPropertyName("workerTypes")]
+ public WorkerTypeConfig WorkerTypes { get; set; } = new();
+ }
+}
+
diff --git a/Models/WorkerTypeConfig.cs b/Models/WorkerTypeConfig.cs
new file mode 100644
index 0000000..cdb4238
--- /dev/null
+++ b/Models/WorkerTypeConfig.cs
@@ -0,0 +1,48 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json.Serialization;
+
+namespace UnifiedFarmLauncher.Models
+{
+ public class SheepItConfig
+ {
+ [JsonPropertyName("gpu")]
+ public string Gpu { get; set; } = string.Empty;
+
+ [JsonPropertyName("username")]
+ public string Username { get; set; } = string.Empty;
+
+ [JsonPropertyName("renderKey")]
+ public string RenderKey { get; set; } = string.Empty;
+ }
+
+ public class FlamencoConfig
+ {
+ [JsonPropertyName("workerPath")]
+ public string WorkerPath { get; set; } = string.Empty;
+
+ [JsonPropertyName("networkDrives")]
+ public List NetworkDrives { get; set; } = new();
+
+ [JsonPropertyName("networkPaths")]
+ public List NetworkPaths { get; set; } = new();
+ }
+
+ public class WorkerTypeConfig
+ {
+ [JsonPropertyName("sheepit")]
+ public SheepItConfig? SheepIt { get; set; }
+
+ [JsonPropertyName("flamenco")]
+ public FlamencoConfig? Flamenco { get; set; }
+
+ public override string ToString()
+ {
+ var types = new List();
+ if (SheepIt != null) types.Add("SheepIt");
+ if (Flamenco != null) types.Add("Flamenco");
+ return string.Join(", ", types);
+ }
+ }
+}
+
diff --git a/Program.cs b/Program.cs
new file mode 100644
index 0000000..7ab65b0
--- /dev/null
+++ b/Program.cs
@@ -0,0 +1,19 @@
+using Avalonia;
+using System;
+
+namespace UnifiedFarmLauncher
+{
+ internal class Program
+ {
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace();
+ }
+}
+
diff --git a/Scripts/remote_worker_attach.ps1 b/Scripts/remote_worker_attach.ps1
new file mode 100644
index 0000000..90e1c7e
--- /dev/null
+++ b/Scripts/remote_worker_attach.ps1
@@ -0,0 +1,116 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$WorkerName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$WorkerType,
+
+[string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'),
+
+ [switch]$CommandOnly,
+
+ [string]$Command
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Get-WorkerPaths {
+ param([string]$Root, [string]$Type, [string]$Name)
+
+ $instanceRoot = Join-Path -Path (Join-Path -Path $Root -ChildPath $Type) -ChildPath $Name
+ return [pscustomobject]@{
+ Metadata = Join-Path -Path $instanceRoot -ChildPath 'state\worker-info.json'
+ Command = Join-Path -Path $instanceRoot -ChildPath 'state\commands.txt'
+ Log = Join-Path -Path $instanceRoot -ChildPath 'logs\worker.log'
+ }
+}
+
+$paths = Get-WorkerPaths -Root $DataRoot -Type $WorkerType -Name $WorkerName
+
+if (-not (Test-Path $paths.Metadata)) {
+ Write-Host "No worker metadata found for $WorkerName ($WorkerType)." -ForegroundColor Red
+ exit 1
+}
+
+try {
+ $metadata = Get-Content -Path $paths.Metadata -Raw | ConvertFrom-Json
+}
+catch {
+ Write-Host "Unable to read worker metadata: $($_.Exception.Message)" -ForegroundColor Red
+ exit 1
+}
+
+if (Test-Path $paths.Log) {
+ # ensure log file exists but do not truncate
+ $null = (Get-Item $paths.Log)
+} else {
+ New-Item -Path $paths.Log -ItemType File -Force | Out-Null
+}
+
+if (-not (Test-Path $paths.Command)) {
+ New-Item -Path $paths.Command -ItemType File -Force | Out-Null
+}
+
+function Send-WorkerCommand {
+ param([string]$Value)
+
+ $clean = $Value.Trim()
+ if (-not $clean) {
+ return
+ }
+
+ Add-Content -Path $paths.Command -Value $clean -Encoding UTF8
+ Write-Host "Sent command '$clean' to $WorkerName." -ForegroundColor DarkGray
+}
+
+if ($CommandOnly) {
+ if (-not $Command) {
+ Write-Host "CommandOnly flag set but no command provided." -ForegroundColor Red
+ exit 1
+ }
+
+ Send-WorkerCommand -Value $Command
+ exit 0
+}
+
+Write-Host "Attaching to $WorkerName ($WorkerType) logs." -ForegroundColor Cyan
+Write-Host "Type commands and press Enter. Type 'detach' to exit session." -ForegroundColor Yellow
+
+$logJob = Start-Job -ScriptBlock {
+ param($LogPath)
+ Get-Content -Path $LogPath -Tail 50 -Wait
+} -ArgumentList $paths.Log
+
+try {
+ while ($true) {
+ $input = Read-Host "> "
+ if ($null -eq $input) {
+ continue
+ }
+
+ $normalized = $input.Trim()
+ if ($normalized.StartsWith(':')) {
+ $normalized = $normalized.TrimStart(':').Trim()
+ }
+
+ if ($normalized.Length -eq 0) {
+ continue
+ }
+
+ if ($normalized.ToLowerInvariant() -eq 'detach') {
+ break
+ }
+
+ Send-WorkerCommand -Value $input
+ }
+}
+finally {
+ if ($logJob) {
+ Stop-Job -Job $logJob -ErrorAction SilentlyContinue
+ Receive-Job -Job $logJob -ErrorAction SilentlyContinue | Out-Null
+ Remove-Job -Job $logJob -ErrorAction SilentlyContinue
+ }
+
+ Write-Host "Detached from worker $WorkerName." -ForegroundColor Cyan
+}
+
diff --git a/Scripts/remote_worker_controller.ps1 b/Scripts/remote_worker_controller.ps1
new file mode 100644
index 0000000..df86fce
--- /dev/null
+++ b/Scripts/remote_worker_controller.ps1
@@ -0,0 +1,407 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$WorkerName,
+
+ [Parameter(Mandatory = $true)]
+ [string]$WorkerType,
+
+ [string]$PayloadBase64,
+
+ [string]$PayloadBase64Path,
+
+[string]$DataRoot = (Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'),
+
+ [int]$MaxRestarts = 5,
+
+ [int]$RestartDelaySeconds = 10
+)
+
+$ErrorActionPreference = 'Stop'
+try {
+ if ($Host -and $Host.Runspace) {
+ [System.Management.Automation.Runspaces.Runspace]::DefaultRunspace = $Host.Runspace
+ }
+}
+catch {
+ # Ignore runspace assignment errors - not critical for non-interactive execution
+}
+
+# region Path setup
+$workerRoot = Join-Path -Path $DataRoot -ChildPath $WorkerType
+$instanceRoot = Join-Path -Path $workerRoot -ChildPath $WorkerName
+New-Item -ItemType Directory -Path $instanceRoot -Force | Out-Null
+
+$logsRoot = Join-Path -Path $instanceRoot -ChildPath 'logs'
+$stateRoot = Join-Path -Path $instanceRoot -ChildPath 'state'
+New-Item -ItemType Directory -Path $logsRoot -Force | Out-Null
+New-Item -ItemType Directory -Path $stateRoot -Force | Out-Null
+
+$logPath = Join-Path -Path $logsRoot -ChildPath 'worker.log'
+$metaPath = Join-Path -Path $stateRoot -ChildPath 'worker-info.json'
+$commandPath = Join-Path -Path $stateRoot -ChildPath 'commands.txt'
+$payloadPath = Join-Path -Path $stateRoot -ChildPath "payload.ps1"
+# endregion
+
+# region Logging
+try {
+ $logStream = [System.IO.FileStream]::new(
+ $logPath,
+ [System.IO.FileMode]::Append,
+ [System.IO.FileAccess]::Write,
+ [System.IO.FileShare]::ReadWrite
+ )
+ $logWriter = [System.IO.StreamWriter]::new($logStream, [System.Text.Encoding]::UTF8)
+ $logWriter.AutoFlush = $true
+}
+catch {
+ # If we can't open the log file, write error to metadata and exit
+ $errorMeta = [pscustomobject]@{
+ WorkerName = $WorkerName
+ WorkerType = $WorkerType
+ Status = 'error'
+ ControllerPid = $PID
+ WorkerPid = $null
+ Restarts = 0
+ LastExitCode = 1
+ LogPath = $logPath
+ CommandPath = $commandPath
+ PayloadPath = $payloadPath
+ UpdatedAtUtc = (Get-Date).ToUniversalTime()
+ ErrorMessage = "Failed to open log file: $($_.Exception.Message)"
+ }
+ $errorMeta | ConvertTo-Json -Depth 5 | Set-Content -Path $metaPath -Encoding UTF8 -ErrorAction SilentlyContinue
+ Write-Error "Controller failed to initialize: $($_.Exception.Message)"
+ exit 1
+}
+
+# Create C# event handler class that doesn't require PowerShell runspace
+if (-not ("UnifiedWorkers.ProcessLogHandler" -as [type])) {
+ $csharpCode = @'
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace UnifiedWorkers
+{
+ public sealed class ProcessLogHandler
+ {
+ private readonly TextWriter _writer;
+ private readonly string _prefix;
+ private readonly object _lock = new object();
+
+ public ProcessLogHandler(TextWriter writer, string prefix)
+ {
+ _writer = writer ?? throw new ArgumentNullException("writer");
+ _prefix = prefix ?? throw new ArgumentNullException("prefix");
+ }
+
+ public void OnDataReceived(object sender, DataReceivedEventArgs e)
+ {
+ if (e == null || string.IsNullOrEmpty(e.Data))
+ {
+ return;
+ }
+
+ lock (_lock)
+ {
+ try
+ {
+ var timestamp = DateTime.UtcNow.ToString("u");
+ _writer.WriteLine(string.Format("[{0} {1}] {2}", _prefix, timestamp, e.Data));
+ _writer.Flush();
+ }
+ catch
+ {
+ // Ignore write errors to prevent cascading failures
+ }
+ }
+ }
+ }
+}
+'@
+ Add-Type -TypeDefinition $csharpCode -ErrorAction Stop
+}
+
+function Write-LogLine {
+ param(
+ [string]$Prefix,
+ [string]$Message
+ )
+
+ if (-not $logWriter) { return }
+ $timestamp = (Get-Date).ToString('u')
+ $logWriter.WriteLine("[$Prefix $timestamp] $Message")
+}
+
+function Write-ControllerLog {
+ param([string]$Message)
+ Write-LogLine -Prefix 'CTRL' -Message $Message
+}
+
+function Write-FatalLog {
+ param([string]$Message)
+
+ try {
+ Write-ControllerLog $Message
+ }
+ catch {
+ $timestamp = (Get-Date).ToString('u')
+ $fallback = "[CTRL $timestamp] $Message"
+ try {
+ [System.IO.File]::AppendAllText($logPath, $fallback + [Environment]::NewLine, [System.Text.Encoding]::UTF8)
+ }
+ catch {
+ # last resort: write to host
+ Write-Error $fallback
+ }
+ }
+}
+# endregion
+
+# region Helpers
+
+function Resolve-PayloadBase64 {
+ if ($PayloadBase64) {
+ return $PayloadBase64.Trim()
+ }
+
+ if ($PayloadBase64Path) {
+ if (-not (Test-Path $PayloadBase64Path)) {
+ throw "Payload file '$PayloadBase64Path' not found."
+ }
+
+ $content = Get-Content -Path $PayloadBase64Path -Raw
+ if ([string]::IsNullOrWhiteSpace($content)) {
+ throw "Payload file '$PayloadBase64Path' is empty."
+ }
+
+ return $content.Trim()
+ }
+
+ throw "No payload data provided to controller."
+}
+
+function Write-Metadata {
+ param(
+ [string]$Status,
+ [nullable[int]]$WorkerPid = $null,
+ [nullable[int]]$ControllerPid = $PID,
+ [int]$Restarts = 0,
+ [nullable[int]]$LastExitCode = $null
+ )
+
+ $payload = [pscustomobject]@{
+ WorkerName = $WorkerName
+ WorkerType = $WorkerType
+ Status = $Status
+ ControllerPid = $ControllerPid
+ WorkerPid = $WorkerPid
+ Restarts = $Restarts
+ LastExitCode = $LastExitCode
+ LogPath = $logPath
+ CommandPath = $commandPath
+ PayloadPath = $payloadPath
+ UpdatedAtUtc = (Get-Date).ToUniversalTime()
+ }
+
+ $payload | ConvertTo-Json -Depth 5 | Set-Content -Path $metaPath -Encoding UTF8
+}
+
+function Get-PendingCommands {
+ if (-not (Test-Path $commandPath)) {
+ return @()
+ }
+
+ try {
+ $lines = Get-Content -Path $commandPath -ErrorAction Stop
+ Remove-Item -Path $commandPath -Force -ErrorAction SilentlyContinue
+ return $lines | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
+ }
+ catch {
+ return @()
+ }
+}
+# endregion
+
+try {
+ # record initial state before launching worker
+ Write-Metadata -Status 'initializing' -WorkerPid $null -ControllerPid $PID -Restarts 0
+
+ $resolvedPayloadBase64 = Resolve-PayloadBase64
+ $PayloadBase64 = $resolvedPayloadBase64
+
+ try {
+ # Write payload script to disk
+ # The payload is base64-encoded UTF-16 (Unicode), so decode it properly
+ Write-ControllerLog "Decoding payload base64 (length: $($resolvedPayloadBase64.Length))"
+ $payloadBytes = [Convert]::FromBase64String($resolvedPayloadBase64)
+ Write-ControllerLog "Decoded payload to $($payloadBytes.Length) bytes"
+
+ # Convert UTF-16 bytes back to string, then write as UTF-8 (PowerShell's preferred encoding)
+ $payloadText = [Text.Encoding]::Unicode.GetString($payloadBytes)
+ [IO.File]::WriteAllText($payloadPath, $payloadText, [Text.Encoding]::UTF8)
+ Write-ControllerLog "Payload written to $payloadPath ($($payloadText.Length) characters)"
+ }
+ catch {
+ Write-FatalLog "Unable to write payload: $($_.Exception.Message)"
+ if ($_.Exception.InnerException) {
+ Write-FatalLog "Inner exception: $($_.Exception.InnerException.Message)"
+ }
+ throw
+ }
+
+ $restartCount = 0
+ $controllerPid = $PID
+
+ while ($restartCount -le $MaxRestarts) {
+ try {
+ # Initialize worker process
+ $psi = [System.Diagnostics.ProcessStartInfo]::new()
+ $pwsh = Get-Command pwsh -ErrorAction SilentlyContinue
+ if ($pwsh) {
+ $psi.FileName = $pwsh.Source
+ }
+ else {
+ $psi.FileName = (Get-Command powershell -ErrorAction Stop).Source
+ }
+
+ $psi.Arguments = "-NoLogo -NoProfile -ExecutionPolicy Bypass -File `"$payloadPath`""
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardInput = $true
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.CreateNoWindow = $true
+
+ $workerProcess = New-Object System.Diagnostics.Process
+ $workerProcess.StartInfo = $psi
+
+ if (-not $workerProcess.Start()) {
+ throw "Failed to start worker process."
+ }
+
+ Write-ControllerLog "Worker process started with PID $($workerProcess.Id)"
+ Write-Metadata -Status 'running' -WorkerPid $workerProcess.Id -ControllerPid $controllerPid -Restarts $restartCount
+
+ # Check if process exited immediately
+ if ($workerProcess.HasExited) {
+ $exitCode = -1
+ try {
+ $exitCode = $workerProcess.ExitCode
+ }
+ catch {
+ Write-ControllerLog "Unable to read immediate exit code: $($_.Exception.Message)"
+ }
+ Write-ControllerLog "Worker process exited immediately after start with code $exitCode"
+ Write-Metadata -Status 'stopped' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount -LastExitCode $exitCode
+ if ($exitCode -eq 0) { break }
+ # Continue to restart logic below
+ }
+ else {
+ $stdoutHandler = [UnifiedWorkers.ProcessLogHandler]::new($logWriter, 'OUT')
+ $stderrHandler = [UnifiedWorkers.ProcessLogHandler]::new($logWriter, 'ERR')
+
+ $outputHandler = [System.Diagnostics.DataReceivedEventHandler]$stdoutHandler.OnDataReceived
+ $errorHandler = [System.Diagnostics.DataReceivedEventHandler]$stderrHandler.OnDataReceived
+
+ $workerProcess.add_OutputDataReceived($outputHandler)
+ $workerProcess.add_ErrorDataReceived($errorHandler)
+ $workerProcess.BeginOutputReadLine()
+ $workerProcess.BeginErrorReadLine()
+ Write-ControllerLog "Output handlers set up successfully"
+
+ # Give process a moment to start, then check again
+ Start-Sleep -Milliseconds 200
+ if ($workerProcess.HasExited) {
+ $exitCode = -1
+ try {
+ $exitCode = $workerProcess.ExitCode
+ }
+ catch {
+ Write-ControllerLog "Unable to read exit code after delay: $($_.Exception.Message)"
+ }
+ Write-ControllerLog "Worker process exited after 200ms delay with code $exitCode"
+ Write-Metadata -Status 'stopped' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount -LastExitCode $exitCode
+ if ($exitCode -eq 0) { break }
+ # Continue to restart logic below
+ }
+ else {
+ Write-ControllerLog "Worker process is running, entering monitoring loop"
+
+ while (-not $workerProcess.HasExited) {
+ $commands = Get-PendingCommands
+ foreach ($command in $commands) {
+ $trimmed = $command.Trim()
+ if (-not $trimmed) { continue }
+
+ Write-ControllerLog "Received command '$trimmed'"
+ try {
+ $workerProcess.StandardInput.WriteLine($trimmed)
+ $workerProcess.StandardInput.Flush()
+ }
+ catch {
+ Write-ControllerLog "Failed to forward command '$trimmed': $($_.Exception.Message)"
+ }
+
+ if ($trimmed -ieq 'quit') {
+ Write-ControllerLog "Quit command issued. Waiting for worker to exit."
+ }
+ }
+
+ Start-Sleep -Milliseconds 500
+ }
+ # End of monitoring loop - process has exited
+ Write-ControllerLog "Worker process exited, exiting monitoring loop"
+ }
+ }
+
+ # Wait for process to fully exit before reading exit code
+ $workerProcess.WaitForExit(1000)
+
+ $exitCode = -1
+ try {
+ $exitCode = $workerProcess.ExitCode
+ }
+ catch {
+ Write-ControllerLog "Unable to read worker exit code: $($_.Exception.Message)"
+ }
+
+ Write-ControllerLog "Worker exited with code $exitCode"
+ Write-Metadata -Status 'stopped' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount -LastExitCode $exitCode
+
+ if ($exitCode -eq 0) {
+ break
+ }
+ }
+ catch {
+ Write-ControllerLog "Controller error: $($_.Exception.Message)"
+ }
+
+ $restartCount++
+ if ($restartCount -gt $MaxRestarts) {
+ Write-ControllerLog "Maximum restart attempts reached. Controller stopping."
+ break
+ }
+
+ Write-ControllerLog "Restarting worker in $RestartDelaySeconds seconds (attempt $restartCount of $MaxRestarts)."
+ Start-Sleep -Seconds $RestartDelaySeconds
+ }
+
+ Write-Metadata -Status 'inactive' -WorkerPid $null -ControllerPid $controllerPid -Restarts $restartCount
+ Write-ControllerLog "Controller exiting."
+}
+catch {
+ Write-FatalLog "Fatal controller error: $($_.Exception.Message)"
+ if ($_.ScriptStackTrace) {
+ Write-FatalLog "Stack: $($_.ScriptStackTrace)"
+ }
+ throw
+}
+finally {
+ if ($logWriter) {
+ $logWriter.Dispose()
+ }
+ if ($logStream) {
+ $logStream.Dispose()
+ }
+}
+
diff --git a/Services/AttachService.cs b/Services/AttachService.cs
new file mode 100644
index 0000000..b648cd6
--- /dev/null
+++ b/Services/AttachService.cs
@@ -0,0 +1,41 @@
+using System.Diagnostics;
+using System.Threading.Tasks;
+using UnifiedFarmLauncher.Models;
+
+namespace UnifiedFarmLauncher.Services
+{
+ public class AttachService
+ {
+ private readonly SshService _sshService;
+ private readonly WorkerControllerService _controllerService;
+
+ public AttachService(SshService sshService, WorkerControllerService controllerService)
+ {
+ _sshService = sshService;
+ _controllerService = controllerService;
+ }
+
+ public async Task AttachToWorkerAsync(WorkerConfig worker, string workerType, bool commandOnly = false, string? command = null)
+ {
+ await _controllerService.DeployAttachHelperAsync(worker);
+
+ var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker);
+ var remoteHelper = $"{remoteBasePath.Replace("\\", "/")}/attach-helper.ps1";
+
+ var paramsBlock = $"-WorkerName \"{worker.Name}\" -WorkerType \"{workerType}\"";
+ if (commandOnly)
+ {
+ paramsBlock += " -CommandOnly";
+ }
+ if (!string.IsNullOrEmpty(command))
+ {
+ paramsBlock += $" -Command \"{command}\"";
+ }
+
+ var remoteCmd = $"powershell -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"{remoteHelper}\" {paramsBlock}";
+
+ _sshService.StartInteractiveSsh(worker, remoteCmd);
+ }
+ }
+}
+
diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs
new file mode 100644
index 0000000..6d121c7
--- /dev/null
+++ b/Services/ConfigService.cs
@@ -0,0 +1,128 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using UnifiedFarmLauncher.Models;
+
+namespace UnifiedFarmLauncher.Services
+{
+ public class ConfigService
+ {
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ WriteIndented = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly string _configPath;
+ private ConfigRoot? _config;
+
+ public ConfigService()
+ {
+ var appDataPath = GetAppDataPath();
+ Directory.CreateDirectory(appDataPath);
+ _configPath = Path.Combine(appDataPath, "workers.json");
+ }
+
+ private static string GetAppDataPath()
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ return Path.Combine(localAppData, "UnifiedFarmLauncher");
+ }
+
+ public ConfigRoot Load()
+ {
+ if (_config != null)
+ return _config;
+
+ if (!File.Exists(_configPath))
+ {
+ _config = new ConfigRoot();
+ Save(_config);
+ return _config;
+ }
+
+ try
+ {
+ var json = File.ReadAllText(_configPath);
+ _config = JsonSerializer.Deserialize(json, JsonOptions) ?? new ConfigRoot();
+ return _config;
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to load configuration from {_configPath}: {ex.Message}", ex);
+ }
+ }
+
+ public void Save(ConfigRoot? config = null)
+ {
+ config ??= _config ?? new ConfigRoot();
+ _config = config;
+
+ try
+ {
+ var json = JsonSerializer.Serialize(config, JsonOptions);
+ File.WriteAllText(_configPath, json);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to save configuration to {_configPath}: {ex.Message}", ex);
+ }
+ }
+
+ public void Reload()
+ {
+ _config = null;
+ Load();
+ }
+
+ public WorkerConfig? GetWorker(int id)
+ {
+ return Load().Workers.FirstOrDefault(w => w.Id == id);
+ }
+
+ public WorkerConfig? GetWorkerByName(string name)
+ {
+ return Load().Workers.FirstOrDefault(w => w.Name == name);
+ }
+
+ public void AddWorker(WorkerConfig worker)
+ {
+ var config = Load();
+ if (config.Workers.Any(w => w.Id == worker.Id || w.Name == worker.Name))
+ throw new InvalidOperationException($"Worker with ID {worker.Id} or name '{worker.Name}' already exists");
+
+ config.Workers.Add(worker);
+ Save(config);
+ }
+
+ public void UpdateWorker(WorkerConfig worker)
+ {
+ var config = Load();
+ var index = config.Workers.FindIndex(w => w.Id == worker.Id);
+ if (index < 0)
+ throw new InvalidOperationException($"Worker with ID {worker.Id} not found");
+
+ config.Workers[index] = worker;
+ Save(config);
+ }
+
+ public void DeleteWorker(int id)
+ {
+ var config = Load();
+ var worker = config.Workers.FirstOrDefault(w => w.Id == id);
+ if (worker == null)
+ throw new InvalidOperationException($"Worker with ID {id} not found");
+
+ config.Workers.Remove(worker);
+ Save(config);
+ }
+
+ public int GetNextWorkerId()
+ {
+ var config = Load();
+ return config.Workers.Count > 0 ? config.Workers.Max(w => w.Id) + 1 : 1;
+ }
+ }
+}
+
diff --git a/Services/SshService.cs b/Services/SshService.cs
new file mode 100644
index 0000000..1a32c2b
--- /dev/null
+++ b/Services/SshService.cs
@@ -0,0 +1,307 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+using UnifiedFarmLauncher.Models;
+
+namespace UnifiedFarmLauncher.Services
+{
+ public class SshConnectionParts
+ {
+ public string Host { get; set; } = string.Empty;
+ public List Options { get; set; } = new();
+ public int? Port { get; set; }
+ public bool RequestPty { get; set; }
+ }
+
+ public class SshService
+ {
+ private readonly Dictionary _workerBasePathCache = new();
+
+ private static string GetSshExecutable()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ // Try common Windows OpenSSH locations
+ var paths = new[]
+ {
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "OpenSSH", "ssh.exe"),
+ "ssh.exe" // In PATH
+ };
+ foreach (var path in paths)
+ {
+ if (File.Exists(path) || IsInPath("ssh.exe"))
+ return "ssh.exe";
+ }
+ return "ssh.exe";
+ }
+ return "ssh";
+ }
+
+ private static string GetScpExecutable()
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var paths = new[]
+ {
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "OpenSSH", "scp.exe"),
+ "scp.exe"
+ };
+ foreach (var path in paths)
+ {
+ if (File.Exists(path) || IsInPath("scp.exe"))
+ return "scp.exe";
+ }
+ return "scp.exe";
+ }
+ return "scp";
+ }
+
+ private static bool IsInPath(string executable)
+ {
+ try
+ {
+ var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = executable,
+ Arguments = "-V",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ });
+ process?.WaitForExit(1000);
+ return process?.ExitCode == 0 || process?.ExitCode == 1; // SSH/SCP typically exit with 1 for version info
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public SshConnectionParts ParseConnectionParts(string rawArgs, string defaultHost)
+ {
+ var parts = new SshConnectionParts { Host = defaultHost };
+ if (string.IsNullOrWhiteSpace(rawArgs))
+ return parts;
+
+ var tokens = rawArgs.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
+ var options = new List();
+ string? targetHost = null;
+ int? port = null;
+ bool requestPty = false;
+
+ var optionsWithArgs = new HashSet { "-i", "-o", "-c", "-D", "-E", "-F", "-I", "-J", "-L", "-l", "-m", "-O", "-Q", "-R", "-S", "-W", "-w" };
+
+ for (int i = 0; i < tokens.Length; i++)
+ {
+ var token = tokens[i];
+ if (token == "-t" || token == "-tt")
+ {
+ requestPty = true;
+ continue;
+ }
+
+ if (token == "-p" && i + 1 < tokens.Length)
+ {
+ if (int.TryParse(tokens[i + 1], out var portValue))
+ {
+ port = portValue;
+ i++;
+ }
+ continue;
+ }
+
+ if (token.StartsWith("-"))
+ {
+ options.Add(token);
+ if (optionsWithArgs.Contains(token) && i + 1 < tokens.Length)
+ {
+ options.Add(tokens[i + 1]);
+ i++;
+ }
+ continue;
+ }
+
+ if (targetHost == null)
+ {
+ targetHost = token;
+ continue;
+ }
+
+ options.Add(token);
+ }
+
+ parts.Host = targetHost ?? defaultHost;
+ parts.Options = options;
+ parts.Port = port;
+ parts.RequestPty = requestPty;
+ return parts;
+ }
+
+ public List BuildSshArgs(SshConnectionParts parts, bool interactive)
+ {
+ var args = new List
+ {
+ "-o", "ServerAliveInterval=60",
+ "-o", "ServerAliveCountMax=30"
+ };
+
+ if (interactive && parts.RequestPty)
+ {
+ args.Add("-t");
+ }
+ else if (!interactive)
+ {
+ args.Add("-T");
+ }
+
+ args.AddRange(parts.Options);
+
+ if (parts.Port.HasValue)
+ {
+ args.Add("-p");
+ args.Add(parts.Port.Value.ToString());
+ }
+
+ args.Add(parts.Host);
+ return args;
+ }
+
+ public List BuildScpArgs(SshConnectionParts parts)
+ {
+ var args = new List
+ {
+ "-o", "ServerAliveInterval=60",
+ "-o", "ServerAliveCountMax=30"
+ };
+
+ args.AddRange(parts.Options);
+
+ if (parts.Port.HasValue)
+ {
+ args.Add("-P");
+ args.Add(parts.Port.Value.ToString());
+ }
+
+ return args;
+ }
+
+ public async Task ExecuteRemoteCommandAsync(WorkerConfig worker, string command, bool interactive = false)
+ {
+ var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
+ var sshArgs = BuildSshArgs(parts, interactive);
+ sshArgs.Add(command);
+
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = GetSshExecutable(),
+ Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")),
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = !interactive
+ }
+ };
+
+ var output = new StringBuilder();
+ var error = new StringBuilder();
+
+ process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); };
+ process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); };
+
+ process.Start();
+ process.BeginOutputReadLine();
+ process.BeginErrorReadLine();
+
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0 && !interactive)
+ {
+ throw new InvalidOperationException($"SSH command failed with exit code {process.ExitCode}: {error}");
+ }
+
+ return output.ToString();
+ }
+
+ public async Task GetWorkerBasePathAsync(WorkerConfig worker)
+ {
+ if (_workerBasePathCache.TryGetValue(worker.Name, out var cached))
+ return cached;
+
+ var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
+ var scriptBlock = "$ProgressPreference='SilentlyContinue'; [Environment]::GetFolderPath('LocalApplicationData')";
+ var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(scriptBlock));
+ var remoteCmd = $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {encoded}";
+
+ var output = await ExecuteRemoteCommandAsync(worker, remoteCmd);
+ var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
+ var basePath = lines.LastOrDefault()?.Trim();
+
+ if (string.IsNullOrEmpty(basePath))
+ throw new InvalidOperationException($"Unable to read LocalAppData path on {worker.Name}.");
+
+ var finalPath = Path.Combine(basePath, "UnifiedWorkers");
+ _workerBasePathCache[worker.Name] = finalPath;
+ return finalPath;
+ }
+
+ public async Task CopyFileToRemoteAsync(WorkerConfig worker, string localPath, string remotePath)
+ {
+ var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
+ var scpArgs = BuildScpArgs(parts);
+ scpArgs.Add(localPath);
+ scpArgs.Add($"{parts.Host}:\"{remotePath.Replace("\\", "/")}\"");
+
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = GetScpExecutable(),
+ Arguments = string.Join(" ", scpArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")),
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ }
+ };
+
+ process.Start();
+ await process.WaitForExitAsync();
+
+ if (process.ExitCode != 0)
+ {
+ var error = await process.StandardError.ReadToEndAsync();
+ throw new InvalidOperationException($"SCP failed with exit code {process.ExitCode}: {error}");
+ }
+ }
+
+ public Process StartInteractiveSsh(WorkerConfig worker, string command)
+ {
+ var parts = ParseConnectionParts(worker.Ssh.Args, worker.Ssh.Host);
+ var sshArgs = BuildSshArgs(parts, true);
+ sshArgs.Add(command);
+
+ var process = new Process
+ {
+ StartInfo = new ProcessStartInfo
+ {
+ FileName = GetSshExecutable(),
+ Arguments = string.Join(" ", sshArgs.Select(arg => $"\"{arg.Replace("\"", "\\\"")}\"")),
+ UseShellExecute = true,
+ CreateNoWindow = false
+ }
+ };
+
+ process.Start();
+ return process;
+ }
+ }
+}
+
diff --git a/Services/WorkerControllerService.cs b/Services/WorkerControllerService.cs
new file mode 100644
index 0000000..0542370
--- /dev/null
+++ b/Services/WorkerControllerService.cs
@@ -0,0 +1,344 @@
+using System;
+using System.IO;
+using System.Reflection;
+using System.Text;
+using System.Threading.Tasks;
+using UnifiedFarmLauncher.Models;
+
+namespace UnifiedFarmLauncher.Services
+{
+ public class WorkerControllerService
+ {
+ private readonly SshService _sshService;
+ private readonly ConfigService _configService;
+ private byte[]? _controllerScriptBytes;
+ private byte[]? _attachHelperScriptBytes;
+
+ public WorkerControllerService(SshService sshService, ConfigService configService)
+ {
+ _sshService = sshService;
+ _configService = configService;
+ }
+
+ private byte[] GetControllerScriptBytes()
+ {
+ if (_controllerScriptBytes != null)
+ return _controllerScriptBytes;
+
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceName = "UnifiedFarmLauncher.Scripts.remote_worker_controller.ps1";
+
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ throw new InvalidOperationException($"Resource {resourceName} not found");
+
+ using var reader = new BinaryReader(stream);
+ _controllerScriptBytes = reader.ReadBytes((int)stream.Length);
+ return _controllerScriptBytes;
+ }
+
+ private byte[] GetAttachHelperScriptBytes()
+ {
+ if (_attachHelperScriptBytes != null)
+ return _attachHelperScriptBytes;
+
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceName = "UnifiedFarmLauncher.Scripts.remote_worker_attach.ps1";
+
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ throw new InvalidOperationException($"Resource {resourceName} not found");
+
+ using var reader = new BinaryReader(stream);
+ _attachHelperScriptBytes = reader.ReadBytes((int)stream.Length);
+ return _attachHelperScriptBytes;
+ }
+
+ public async Task DeployControllerAsync(WorkerConfig worker)
+ {
+ var controllerBase64 = Convert.ToBase64String(GetControllerScriptBytes());
+ var script = $@"
+$ProgressPreference = 'SilentlyContinue'
+$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
+New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null
+$controllerPath = Join-Path $dataRoot 'controller.ps1'
+[IO.File]::WriteAllBytes($controllerPath, [Convert]::FromBase64String('{controllerBase64}'))
+";
+
+ await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}");
+ }
+
+ public async Task DeployAttachHelperAsync(WorkerConfig worker)
+ {
+ var helperBase64 = Convert.ToBase64String(GetAttachHelperScriptBytes());
+ var script = $@"
+$ProgressPreference = 'SilentlyContinue'
+$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
+New-Item -ItemType Directory -Path $dataRoot -Force | Out-Null
+$attachPath = Join-Path $dataRoot 'attach-helper.ps1'
+[IO.File]::WriteAllBytes($attachPath, [Convert]::FromBase64String('{helperBase64}'))
+";
+
+ await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}");
+ }
+
+ public string GenerateSheepItPayload(WorkerConfig worker)
+ {
+ if (worker.WorkerTypes.SheepIt == null)
+ throw new InvalidOperationException("Worker does not have SheepIt configuration");
+
+ var config = _configService.Load();
+ var sheepIt = worker.WorkerTypes.SheepIt;
+ var safeKey = sheepIt.RenderKey.Replace("'", "''");
+ var safeUser = sheepIt.Username.Replace("'", "''");
+ var urls = config.GlobalSettings.SheepItJarUrls;
+ var urlLiteral = "@(" + string.Join(", ", Array.ConvertAll(urls.ToArray(), url => $"'{url}'")) + ")";
+
+ return $@"
+$ProgressPreference = 'SilentlyContinue'
+$ErrorActionPreference = 'Stop'
+
+# Write startup message to stderr so controller can capture it
+[Console]::Error.WriteLine('[SHEEPIT] Payload script starting...')
+
+try {{
+ $appData = [Environment]::GetFolderPath('ApplicationData')
+ $sheepDir = Join-Path $appData 'sheepit'
+ if (-not (Test-Path $sheepDir)) {{
+ New-Item -Path $sheepDir -ItemType Directory -Force | Out-Null
+ }}
+
+ $jarPath = Join-Path $sheepDir 'sheepit-client.jar'
+ $urls = {urlLiteral}
+ $headers = @{{ 'User-Agent' = 'Mozilla/5.0' }}
+
+ if (Test-Path $jarPath) {{
+ Write-Host ""SheepIt client already present at $jarPath. Skipping download."" -ForegroundColor Green
+ }}
+ else {{
+ $downloaded = $false
+
+ foreach ($url in $urls) {{
+ Write-Host ""Downloading SheepIt client from $url..."" -ForegroundColor Cyan
+ try {{
+ Invoke-WebRequest -Uri $url -OutFile $jarPath -UseBasicParsing -Headers $headers
+ $downloaded = $true
+ Write-Host ""Download complete."" -ForegroundColor Green
+ break
+ }}
+ catch {{
+ Write-Host (""Download failed from {{0}}: {{1}}"" -f $url, $_.Exception.Message) -ForegroundColor Yellow
+ }}
+ }}
+
+ if (-not $downloaded) {{
+ throw 'Unable to download SheepIt client from any known URL.'
+ }}
+ }}
+
+ [Console]::Error.WriteLine('[SHEEPIT] Starting Java with SheepIt client...')
+ Set-Location $sheepDir
+
+ $javaArgs = @('-XX:+IgnoreUnrecognizedVMOptions', '-jar', $jarPath,
+ '-ui', 'text', '--log-stdout', '--verbose',
+ '-gpu', '{sheepIt.Gpu}', '-login', '{safeUser}', '-password', '{safeKey}')
+
+ try {{
+ & java @javaArgs
+ }}
+ catch {{
+ Write-Host ('Java execution error: {{0}}' -f $_.Exception.Message) -ForegroundColor Red
+ Write-Host ""If the error persists, try reinstalling Java (Temurin 21 recommended)."" -ForegroundColor Yellow
+ [Console]::Error.WriteLine(""Java execution error: $($_.Exception.Message)"")
+ throw
+ }}
+}}
+catch {{
+ $errorMsg = ('Error: {{0}}' -f $_.Exception.Message)
+ $stackMsg = ('Stack trace: {{0}}' -f $_.ScriptStackTrace)
+ Write-Host $errorMsg -ForegroundColor Red
+ Write-Host $stackMsg -ForegroundColor DarkRed
+ [Console]::Error.WriteLine($errorMsg)
+ [Console]::Error.WriteLine($stackMsg)
+ exit 1
+}}
+";
+ }
+
+ public string GenerateFlamencoPayload(WorkerConfig worker)
+ {
+ if (worker.WorkerTypes.Flamenco == null)
+ throw new InvalidOperationException("Worker does not have Flamenco configuration");
+
+ var flamenco = worker.WorkerTypes.Flamenco;
+ var drives = string.Join(", ", Array.ConvertAll(flamenco.NetworkDrives.ToArray(), d => $"'{d}'"));
+ var paths = string.Join(", ", Array.ConvertAll(flamenco.NetworkPaths.ToArray(), p => $"'{p.Replace("\\", "\\\\")}'"));
+
+ return $@"
+$ProgressPreference = 'SilentlyContinue'
+$ErrorActionPreference = 'Stop'
+
+# Write startup message to stderr so controller can capture it
+[Console]::Error.WriteLine('[FLAMENCO] Payload script starting...')
+
+try {{
+ Write-Host ""Setting up network connections..."" -ForegroundColor Cyan
+
+ $drives = @({drives})
+ $networkPaths = @({paths})
+
+ # Disconnect all existing connections
+ Write-Host ""Disconnecting existing network connections..."" -ForegroundColor Yellow
+ foreach ($path in $networkPaths) {{ net use $path /delete /y 2>$null }}
+ foreach ($drive in $drives) {{ net use $drive /delete /y 2>$null }}
+ Write-Host ""All network connections cleared."" -ForegroundColor Green
+
+ # Connect to network shares (simplified - credentials should be stored securely)
+ Write-Host ""Establishing network connections..."" -ForegroundColor Cyan
+ # TODO: Add credential handling for network shares
+
+ # Start worker
+ Write-Host ""Starting Flamenco worker..."" -ForegroundColor Cyan
+ Set-Location '{flamenco.WorkerPath}'
+ if (Test-Path 'flamenco-worker.exe') {{
+ Write-Host ""Running flamenco-worker.exe..."" -ForegroundColor Green
+ $workerProcess = Start-Process -FilePath '.\flamenco-worker.exe' -NoNewWindow -PassThru -Wait
+ $exitCode = $workerProcess.ExitCode
+ Write-Host ""Flamenco worker process has terminated with exit code: $exitCode"" -ForegroundColor Yellow
+ exit $exitCode
+ }} else {{
+ Write-Host ""Error: flamenco-worker.exe not found in {flamenco.WorkerPath}"" -ForegroundColor Red
+ [Console]::Error.WriteLine(""Error: flamenco-worker.exe not found in {flamenco.WorkerPath}"")
+ exit 1
+ }}
+}}
+catch {{
+ $errorMsg = ('Error: {{0}}' -f $_.Exception.Message)
+ $stackMsg = ('Stack trace: {{0}}' -f $_.ScriptStackTrace)
+ Write-Host $errorMsg -ForegroundColor Red
+ Write-Host $stackMsg -ForegroundColor DarkRed
+ [Console]::Error.WriteLine($errorMsg)
+ [Console]::Error.WriteLine($stackMsg)
+ exit 1
+}}
+";
+ }
+
+ public async Task StartWorkerAsync(WorkerConfig worker, string workerType)
+ {
+ await DeployControllerAsync(worker);
+
+ string payloadScript;
+ if (workerType == "sheepit")
+ {
+ payloadScript = GenerateSheepItPayload(worker);
+ }
+ else if (workerType == "flamenco")
+ {
+ payloadScript = GenerateFlamencoPayload(worker);
+ }
+ else
+ {
+ throw new ArgumentException($"Unknown worker type: {workerType}", nameof(workerType));
+ }
+
+ var payloadBase64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(payloadScript));
+ var remoteBasePath = await _sshService.GetWorkerBasePathAsync(worker);
+
+ var ensureScript = $@"
+$ProgressPreference = 'SilentlyContinue'
+$params = ConvertFrom-Json ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{Convert.ToBase64String(Encoding.Unicode.GetBytes($@"{{""WorkerName"":""{worker.Name}"",""WorkerType"":""{workerType}"",""PayloadBase64"":""{payloadBase64}""}}"@))}')))
+$workerName = $params.WorkerName
+$workerType = $params.WorkerType
+$payloadBase64 = $params.PayloadBase64
+$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
+$instanceRoot = Join-Path (Join-Path $dataRoot $workerType) $workerName
+$logsRoot = Join-Path $instanceRoot 'logs'
+$stateRoot = Join-Path $instanceRoot 'state'
+New-Item -ItemType Directory -Path $logsRoot -Force | Out-Null
+New-Item -ItemType Directory -Path $stateRoot -Force | Out-Null
+$logPath = Join-Path $logsRoot 'worker.log'
+$commandPath = Join-Path $stateRoot 'commands.txt'
+$payloadPath = Join-Path $stateRoot 'payload.ps1'
+$payloadBase64Path = Join-Path $stateRoot 'payload.b64'
+if (-not (Test-Path $logPath)) {{ New-Item -Path $logPath -ItemType File -Force | Out-Null }}
+if (-not (Test-Path $commandPath)) {{ New-Item -Path $commandPath -ItemType File -Force | Out-Null }}
+[IO.File]::WriteAllText($payloadBase64Path, $payloadBase64, [System.Text.Encoding]::UTF8)
+$metaPath = Join-Path $instanceRoot 'state\worker-info.json'
+$controllerPath = Join-Path $dataRoot 'controller.ps1'
+
+if (-not (Test-Path $controllerPath)) {{
+ throw ""Controller missing at $controllerPath""
+}}
+
+$shouldStart = $true
+if (Test-Path $metaPath) {{
+ try {{
+ $meta = Get-Content $metaPath -Raw | ConvertFrom-Json
+ if ($meta.Status -eq 'running' -and $meta.WorkerPid) {{
+ if (Get-Process -Id $meta.WorkerPid -ErrorAction SilentlyContinue) {{
+ Write-Host ""Worker $workerName already running (PID $($meta.WorkerPid)).""
+ $shouldStart = $false
+ }}
+ }}
+ }} catch {{
+ Write-Host ""Failed to read metadata. Controller will restart worker."" -ForegroundColor Yellow
+ }}
+}}
+
+if ($shouldStart) {{
+ $initialMeta = [pscustomobject]@{{
+ WorkerName = $workerName
+ WorkerType = $workerType
+ Status = 'launching'
+ ControllerPid = $null
+ WorkerPid = $null
+ Restarts = 0
+ LastExitCode = $null
+ LogPath = $logPath
+ CommandPath = $commandPath
+ PayloadPath = $payloadPath
+ UpdatedAtUtc = (Get-Date).ToUniversalTime()
+ }} | ConvertTo-Json -Depth 5
+ $initialMeta | Set-Content -Path $metaPath -Encoding UTF8
+
+ $pwsh = Get-Command pwsh -ErrorAction SilentlyContinue
+ if ($pwsh) {{
+ $psExe = $pwsh.Source
+ }}
+ else {{
+ $psExe = (Get-Command powershell -ErrorAction Stop).Source
+ }}
+
+ $controllerArgs = @(
+ '-NoLogo','-NoProfile','-ExecutionPolicy','Bypass',
+ '-File',""$controllerPath"",
+ '-WorkerName',""$workerName"",
+ '-WorkerType',""$workerType"",
+ '-PayloadBase64Path',""$payloadBase64Path""
+ )
+
+ Start-Process -FilePath $psExe -ArgumentList $controllerArgs -WindowStyle Hidden | Out-Null
+ Write-Host ""Worker $workerName started under controller."" -ForegroundColor Green
+}}
+";
+
+ await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(ensureScript))}");
+ }
+
+ public async Task StopWorkerAsync(WorkerConfig worker, string workerType)
+ {
+ var script = $@"
+$ProgressPreference = 'SilentlyContinue'
+$dataRoot = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'UnifiedWorkers'
+$instanceRoot = Join-Path (Join-Path $dataRoot '{workerType}') '{worker.Name}'
+$commandPath = Join-Path $instanceRoot 'state\commands.txt'
+[IO.File]::WriteAllText($commandPath, 'quit', [System.Text.Encoding]::UTF8)
+Write-Host ""Quit command sent to worker {worker.Name}.""
+";
+
+ await _sshService.ExecuteRemoteCommandAsync(worker, $"powershell -NoLogo -NoProfile -NonInteractive -OutputFormat Text -ExecutionPolicy Bypass -EncodedCommand {Convert.ToBase64String(Encoding.Unicode.GetBytes(script))}");
+ }
+ }
+}
+
diff --git a/UnifiedFarmLauncher.csproj b/UnifiedFarmLauncher.csproj
new file mode 100644
index 0000000..6d32a24
--- /dev/null
+++ b/UnifiedFarmLauncher.csproj
@@ -0,0 +1,30 @@
+
+
+ WinExe
+ net8.0
+ enable
+ true
+ app.manifest
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..e4e18d2
--- /dev/null
+++ b/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,99 @@
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading.Tasks;
+using UnifiedFarmLauncher.Models;
+using UnifiedFarmLauncher.Services;
+
+namespace UnifiedFarmLauncher.ViewModels
+{
+ public class MainWindowViewModel : ViewModelBase
+ {
+ private readonly ConfigService _configService;
+ private WorkerConfig? _selectedWorker;
+ private string _statusText = "Ready";
+ private string _selectedWorkerType = "All";
+
+ public MainWindowViewModel()
+ {
+ _configService = new ConfigService();
+ Workers = new ObservableCollection();
+ LoadWorkers();
+ }
+
+ public ObservableCollection Workers { get; }
+
+ public WorkerConfig? SelectedWorker
+ {
+ get => _selectedWorker;
+ set
+ {
+ if (SetAndRaise(ref _selectedWorker, value))
+ {
+ UpdateStatusText();
+ }
+ }
+ }
+
+ public string StatusText
+ {
+ get => _statusText;
+ set => SetAndRaise(ref _statusText, value);
+ }
+
+ public string SelectedWorkerType
+ {
+ get => _selectedWorkerType;
+ set
+ {
+ if (SetAndRaise(ref _selectedWorkerType, value))
+ {
+ LoadWorkers();
+ }
+ }
+ }
+
+ public void LoadWorkers()
+ {
+ var config = _configService.Load();
+ Workers.Clear();
+
+ var workers = config.Workers;
+ if (SelectedWorkerType != "All")
+ {
+ workers = workers.Where(w =>
+ {
+ if (SelectedWorkerType == "SheepIt")
+ return w.WorkerTypes.SheepIt != null;
+ if (SelectedWorkerType == "Flamenco")
+ return w.WorkerTypes.Flamenco != null;
+ return true;
+ }).ToList();
+ }
+
+ foreach (var worker in workers)
+ {
+ Workers.Add(worker);
+ }
+
+ UpdateStatusText();
+ }
+
+ private void UpdateStatusText()
+ {
+ if (SelectedWorker == null)
+ {
+ StatusText = $"Total workers: {Workers.Count}";
+ }
+ else
+ {
+ StatusText = $"Selected: {SelectedWorker.Name} ({SelectedWorker.Ssh.Host}:{SelectedWorker.Ssh.Port})";
+ }
+ }
+
+ public void RefreshWorkers()
+ {
+ LoadWorkers();
+ }
+ }
+}
+
diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs
new file mode 100644
index 0000000..b4dc644
--- /dev/null
+++ b/ViewModels/ViewModelBase.cs
@@ -0,0 +1,26 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+
+namespace UnifiedFarmLauncher.ViewModels
+{
+ public class ViewModelBase : INotifyPropertyChanged
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ protected bool SetAndRaise(ref T field, T value, [CallerMemberName] string? propertyName = null)
+ {
+ if (Equals(field, value))
+ return false;
+
+ field = value;
+ OnPropertyChanged(propertyName);
+ return true;
+ }
+
+ protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+}
+
diff --git a/ViewModels/WorkerEditViewModel.cs b/ViewModels/WorkerEditViewModel.cs
new file mode 100644
index 0000000..83557db
--- /dev/null
+++ b/ViewModels/WorkerEditViewModel.cs
@@ -0,0 +1,204 @@
+using System.Collections.ObjectModel;
+using System.Linq;
+using UnifiedFarmLauncher.Models;
+using UnifiedFarmLauncher.Services;
+
+namespace UnifiedFarmLauncher.ViewModels
+{
+ public class WorkerEditViewModel : ViewModelBase
+ {
+ private readonly ConfigService _configService;
+ private readonly bool _isNew;
+ private int _id;
+ private string _name = string.Empty;
+ private bool _enabled = true;
+ private string _sshHost = string.Empty;
+ private int _sshPort = 22;
+ private string _sshArgs = string.Empty;
+ private string _sheepItGpu = "OPTIX_0";
+ private string _sheepItUsername = string.Empty;
+ private string _sheepItRenderKey = string.Empty;
+ private string _flamencoWorkerPath = string.Empty;
+ private bool _hasSheepIt;
+ private bool _hasFlamenco;
+
+ public WorkerEditViewModel(ConfigService configService, WorkerConfig? worker = null)
+ {
+ _configService = configService;
+ _isNew = worker == null;
+ NetworkDrives = new ObservableCollection();
+ NetworkPaths = new ObservableCollection();
+
+ if (worker != null)
+ {
+ LoadWorker(worker);
+ }
+ else
+ {
+ _id = _configService.GetNextWorkerId();
+ }
+ }
+
+ public int Id
+ {
+ get => _id;
+ set => SetAndRaise(ref _id, value);
+ }
+
+ public string Name
+ {
+ get => _name;
+ set => SetAndRaise(ref _name, value);
+ }
+
+ public bool Enabled
+ {
+ get => _enabled;
+ set => SetAndRaise(ref _enabled, value);
+ }
+
+ public string SshHost
+ {
+ get => _sshHost;
+ set => SetAndRaise(ref _sshHost, value);
+ }
+
+ public int SshPort
+ {
+ get => _sshPort;
+ set => SetAndRaise(ref _sshPort, value);
+ }
+
+ public string SshArgs
+ {
+ get => _sshArgs;
+ set => SetAndRaise(ref _sshArgs, value);
+ }
+
+ public bool HasSheepIt
+ {
+ get => _hasSheepIt;
+ set => SetAndRaise(ref _hasSheepIt, value);
+ }
+
+ public bool HasFlamenco
+ {
+ get => _hasFlamenco;
+ set => SetAndRaise(ref _hasFlamenco, value);
+ }
+
+ public string SheepItGpu
+ {
+ get => _sheepItGpu;
+ set => SetAndRaise(ref _sheepItGpu, value);
+ }
+
+ public string SheepItUsername
+ {
+ get => _sheepItUsername;
+ set => SetAndRaise(ref _sheepItUsername, value);
+ }
+
+ public string SheepItRenderKey
+ {
+ get => _sheepItRenderKey;
+ set => SetAndRaise(ref _sheepItRenderKey, value);
+ }
+
+ public string FlamencoWorkerPath
+ {
+ get => _flamencoWorkerPath;
+ set => SetAndRaise(ref _flamencoWorkerPath, value);
+ }
+
+ public ObservableCollection NetworkDrives { get; }
+ public ObservableCollection NetworkPaths { get; }
+
+ private void LoadWorker(WorkerConfig worker)
+ {
+ Id = worker.Id;
+ Name = worker.Name;
+ Enabled = worker.Enabled;
+ SshHost = worker.Ssh.Host;
+ SshPort = worker.Ssh.Port;
+ SshArgs = worker.Ssh.Args;
+
+ if (worker.WorkerTypes.SheepIt != null)
+ {
+ HasSheepIt = true;
+ SheepItGpu = worker.WorkerTypes.SheepIt.Gpu;
+ SheepItUsername = worker.WorkerTypes.SheepIt.Username;
+ SheepItRenderKey = worker.WorkerTypes.SheepIt.RenderKey;
+ }
+
+ if (worker.WorkerTypes.Flamenco != null)
+ {
+ HasFlamenco = true;
+ FlamencoWorkerPath = worker.WorkerTypes.Flamenco.WorkerPath;
+ NetworkDrives.Clear();
+ foreach (var drive in worker.WorkerTypes.Flamenco.NetworkDrives)
+ {
+ NetworkDrives.Add(drive);
+ }
+ NetworkPaths.Clear();
+ foreach (var path in worker.WorkerTypes.Flamenco.NetworkPaths)
+ {
+ NetworkPaths.Add(path);
+ }
+ }
+ }
+
+ public WorkerConfig ToWorkerConfig()
+ {
+ var worker = new WorkerConfig
+ {
+ Id = Id,
+ Name = Name,
+ Enabled = Enabled,
+ Ssh = new SshConfig
+ {
+ Host = SshHost,
+ Port = SshPort,
+ Args = SshArgs
+ },
+ WorkerTypes = new WorkerTypeConfig()
+ };
+
+ if (HasSheepIt)
+ {
+ worker.WorkerTypes.SheepIt = new SheepItConfig
+ {
+ Gpu = SheepItGpu,
+ Username = SheepItUsername,
+ RenderKey = SheepItRenderKey
+ };
+ }
+
+ if (HasFlamenco)
+ {
+ worker.WorkerTypes.Flamenco = new FlamencoConfig
+ {
+ WorkerPath = FlamencoWorkerPath,
+ NetworkDrives = NetworkDrives.ToList(),
+ NetworkPaths = NetworkPaths.ToList()
+ };
+ }
+
+ return worker;
+ }
+
+ public void Save()
+ {
+ var worker = ToWorkerConfig();
+ if (_isNew)
+ {
+ _configService.AddWorker(worker);
+ }
+ else
+ {
+ _configService.UpdateWorker(worker);
+ }
+ }
+ }
+}
+
diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml
new file mode 100644
index 0000000..9ede8e0
--- /dev/null
+++ b/Views/MainWindow.axaml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..31cf266
--- /dev/null
+++ b/Views/MainWindow.axaml.cs
@@ -0,0 +1,211 @@
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using UnifiedFarmLauncher.Models;
+using UnifiedFarmLauncher.Services;
+using UnifiedFarmLauncher.ViewModels;
+using Avalonia.Controls.Primitives;
+using MsBox.Avalonia;
+using MsBox.Avalonia.Enums;
+
+namespace UnifiedFarmLauncher.Views
+{
+ public partial class MainWindow : Window
+ {
+ private readonly ConfigService _configService = new();
+ private readonly SshService _sshService = new();
+ private readonly WorkerControllerService _controllerService;
+ private readonly AttachService _attachService;
+
+ public MainWindow()
+ {
+ InitializeComponent();
+ _controllerService = new WorkerControllerService(_sshService, _configService);
+ _attachService = new AttachService(_sshService, _controllerService);
+ DataContext = new MainWindowViewModel();
+ SetupEventHandlers();
+ }
+
+ private void InitializeComponent()
+ {
+ Avalonia.Markup.Xaml.AvaloniaXamlLoader.Load(this);
+ }
+
+ private void SetupEventHandlers()
+ {
+ AddWorkerButton.Click += AddWorkerButton_Click;
+ EditWorkerButton.Click += EditWorkerButton_Click;
+ DeleteWorkerButton.Click += DeleteWorkerButton_Click;
+ StartWorkerButton.Click += StartWorkerButton_Click;
+ StopWorkerButton.Click += StopWorkerButton_Click;
+ AttachWorkerButton.Click += AttachWorkerButton_Click;
+ WorkerTypeTabs.SelectionChanged += WorkerTypeTabs_SelectionChanged;
+ WorkersGrid.SelectionChanged += WorkersGrid_SelectionChanged;
+ }
+
+ private async void AddWorkerButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var dialog = new WorkerEditWindow();
+ if (await dialog.ShowDialogAsync(this))
+ {
+ ((MainWindowViewModel)DataContext!).RefreshWorkers();
+ }
+ }
+
+ private async void EditWorkerButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (WorkersGrid.SelectedItem is WorkerConfig worker)
+ {
+ var dialog = new WorkerEditWindow(worker);
+ if (await dialog.ShowDialogAsync(this))
+ {
+ ((MainWindowViewModel)DataContext!).RefreshWorkers();
+ }
+ }
+ }
+
+ private async void DeleteWorkerButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (WorkersGrid.SelectedItem is WorkerConfig worker)
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard("Delete Worker",
+ $"Are you sure you want to delete worker '{worker.Name}'?",
+ ButtonEnum.YesNo, Icon.Warning);
+ var result = await box.ShowAsync();
+
+ if (result == ButtonResult.Yes)
+ {
+ _configService.DeleteWorker(worker.Id);
+ ((MainWindowViewModel)DataContext!).RefreshWorkers();
+ }
+ }
+ }
+
+ private async void StartWorkerButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (WorkersGrid.SelectedItem is WorkerConfig worker)
+ {
+ try
+ {
+ string? workerType = null;
+ if (worker.WorkerTypes.SheepIt != null)
+ workerType = "sheepit";
+ else if (worker.WorkerTypes.Flamenco != null)
+ workerType = "flamenco";
+
+ if (workerType == null)
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard("Error",
+ "Worker has no configured worker type.",
+ ButtonEnum.Ok, Icon.Error);
+ await box.ShowAsync();
+ return;
+ }
+
+ await _controllerService.StartWorkerAsync(worker, workerType);
+ var successBox = MessageBoxManager.GetMessageBoxStandard("Start Worker",
+ $"Worker '{worker.Name}' started successfully.",
+ ButtonEnum.Ok, Icon.Success);
+ await successBox.ShowAsync();
+ ((MainWindowViewModel)DataContext!).RefreshWorkers();
+ }
+ catch (System.Exception ex)
+ {
+ var errorBox = MessageBoxManager.GetMessageBoxStandard("Error",
+ $"Failed to start worker: {ex.Message}",
+ ButtonEnum.Ok, Icon.Error);
+ await errorBox.ShowAsync();
+ }
+ }
+ }
+
+ private async void StopWorkerButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (WorkersGrid.SelectedItem is WorkerConfig worker)
+ {
+ try
+ {
+ string? workerType = null;
+ if (worker.WorkerTypes.SheepIt != null)
+ workerType = "sheepit";
+ else if (worker.WorkerTypes.Flamenco != null)
+ workerType = "flamenco";
+
+ if (workerType == null)
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard("Error",
+ "Worker has no configured worker type.",
+ ButtonEnum.Ok, Icon.Error);
+ await box.ShowAsync();
+ return;
+ }
+
+ await _controllerService.StopWorkerAsync(worker, workerType);
+ var successBox = MessageBoxManager.GetMessageBoxStandard("Stop Worker",
+ $"Stop command sent to worker '{worker.Name}'.",
+ ButtonEnum.Ok, Icon.Info);
+ await successBox.ShowAsync();
+ }
+ catch (System.Exception ex)
+ {
+ var errorBox = MessageBoxManager.GetMessageBoxStandard("Error",
+ $"Failed to stop worker: {ex.Message}",
+ ButtonEnum.Ok, Icon.Error);
+ await errorBox.ShowAsync();
+ }
+ }
+ }
+
+ private async void AttachWorkerButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (WorkersGrid.SelectedItem is WorkerConfig worker)
+ {
+ try
+ {
+ string? workerType = null;
+ if (worker.WorkerTypes.SheepIt != null)
+ workerType = "sheepit";
+ else if (worker.WorkerTypes.Flamenco != null)
+ workerType = "flamenco";
+
+ if (workerType == null)
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard("Error",
+ "Worker has no configured worker type.",
+ ButtonEnum.Ok, Icon.Error);
+ await box.ShowAsync();
+ return;
+ }
+
+ await _attachService.AttachToWorkerAsync(worker, workerType);
+ }
+ catch (System.Exception ex)
+ {
+ var errorBox = MessageBoxManager.GetMessageBoxStandard("Error",
+ $"Failed to attach to worker: {ex.Message}",
+ ButtonEnum.Ok, Icon.Error);
+ await errorBox.ShowAsync();
+ }
+ }
+ }
+
+ private void WorkerTypeTabs_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (WorkerTypeTabs.SelectedItem is TabItem tab)
+ {
+ var type = tab.Header?.ToString() ?? "All";
+ if (type == "All Workers") type = "All";
+ ((MainWindowViewModel)DataContext!).SelectedWorkerType = type;
+ }
+ }
+
+ private void WorkersGrid_SelectionChanged(object? sender, SelectionChangedEventArgs e)
+ {
+ if (DataContext is MainWindowViewModel vm)
+ {
+ vm.SelectedWorker = WorkersGrid.SelectedItem as WorkerConfig;
+ }
+ }
+ }
+}
+
diff --git a/Views/WorkerEditWindow.axaml b/Views/WorkerEditWindow.axaml
new file mode 100644
index 0000000..162e83f
--- /dev/null
+++ b/Views/WorkerEditWindow.axaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Views/WorkerEditWindow.axaml.cs b/Views/WorkerEditWindow.axaml.cs
new file mode 100644
index 0000000..265dce8
--- /dev/null
+++ b/Views/WorkerEditWindow.axaml.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Platform.Storage;
+using UnifiedFarmLauncher.Models;
+using UnifiedFarmLauncher.Services;
+using UnifiedFarmLauncher.ViewModels;
+using MsBox.Avalonia;
+using MsBox.Avalonia.Enums;
+
+namespace UnifiedFarmLauncher.Views
+{
+ public partial class WorkerEditWindow : Window
+ {
+ private readonly WorkerEditViewModel _viewModel;
+ private bool _result;
+
+ public WorkerEditWindow(WorkerConfig? worker = null)
+ {
+ InitializeComponent();
+ var configService = new ConfigService();
+ _viewModel = new WorkerEditViewModel(configService, worker);
+ DataContext = _viewModel;
+ SetupEventHandlers();
+ }
+
+ private void InitializeComponent()
+ {
+ Avalonia.Markup.Xaml.AvaloniaXamlLoader.Load(this);
+ }
+
+ private void SetupEventHandlers()
+ {
+ OkButton.Click += OkButton_Click;
+ CancelButton.Click += CancelButton_Click;
+ BrowseFlamencoPathButton.Click += BrowseFlamencoPathButton_Click;
+ AddDriveButton.Click += AddDriveButton_Click;
+ RemoveDriveButton.Click += RemoveDriveButton_Click;
+ AddPathButton.Click += AddPathButton_Click;
+ RemovePathButton.Click += RemovePathButton_Click;
+ }
+
+ private async void OkButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (string.IsNullOrWhiteSpace(_viewModel.Name))
+ {
+ var box = MessageBoxManager.GetMessageBoxStandard("Error",
+ "Worker name is required.",
+ ButtonEnum.Ok, Icon.Error);
+ await box.ShowAsync();
+ return;
+ }
+
+ try
+ {
+ _viewModel.Save();
+ _result = true;
+ Close();
+ }
+ catch (Exception ex)
+ {
+ var errorBox = MessageBoxManager.GetMessageBoxStandard("Error",
+ $"Failed to save worker: {ex.Message}",
+ ButtonEnum.Ok, Icon.Error);
+ await errorBox.ShowAsync();
+ }
+ }
+
+ private void CancelButton_Click(object? sender, RoutedEventArgs e)
+ {
+ _result = false;
+ Close();
+ }
+
+ private async void BrowseFlamencoPathButton_Click(object? sender, RoutedEventArgs e)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel?.StorageProvider.CanPickFolder == true)
+ {
+ var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
+ {
+ Title = "Select Flamenco Worker Path"
+ });
+
+ if (folders.Count > 0 && folders[0].TryGetLocalPath() is { } localPath)
+ {
+ _viewModel.FlamencoWorkerPath = localPath;
+ }
+ }
+ }
+
+ private async void AddDriveButton_Click(object? sender, RoutedEventArgs e)
+ {
+ // Simplified: use a simple input box
+ // In a full implementation, you'd use a proper input dialog
+ _viewModel.NetworkDrives.Add("A:");
+ }
+
+ private void RemoveDriveButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (NetworkDrivesListBox.SelectedItem is string drive)
+ {
+ _viewModel.NetworkDrives.Remove(drive);
+ }
+ }
+
+ private async void AddPathButton_Click(object? sender, RoutedEventArgs e)
+ {
+ // Simplified: use a simple input box
+ // In a full implementation, you'd use a proper input dialog
+ _viewModel.NetworkPaths.Add("\\\\SERVER\\share");
+ }
+
+ private void RemovePathButton_Click(object? sender, RoutedEventArgs e)
+ {
+ if (NetworkPathsListBox.SelectedItem is string path)
+ {
+ _viewModel.NetworkPaths.Remove(path);
+ }
+ }
+
+ public async Task ShowDialogAsync(Window parent)
+ {
+ await base.ShowDialog(parent);
+ return _result;
+ }
+ }
+}
+
diff --git a/app.manifest b/app.manifest
new file mode 100644
index 0000000..10bec72
--- /dev/null
+++ b/app.manifest
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+