begin application build overhaul
This commit is contained in:
12
App.axaml
Normal file
12
App.axaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<Application xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="UnifiedFarmLauncher.App"
|
||||||
|
RequestedThemeVariant="Default">
|
||||||
|
<Application.Styles>
|
||||||
|
<FluentTheme />
|
||||||
|
</Application.Styles>
|
||||||
|
<Application.Resources>
|
||||||
|
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
|
|
||||||
30
App.axaml.cs
Normal file
30
App.axaml.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
15
Models/ConfigRoot.cs
Normal file
15
Models/ConfigRoot.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace UnifiedFarmLauncher.Models
|
||||||
|
{
|
||||||
|
public class ConfigRoot
|
||||||
|
{
|
||||||
|
[JsonPropertyName("workers")]
|
||||||
|
public List<WorkerConfig> Workers { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("globalSettings")]
|
||||||
|
public GlobalSettings GlobalSettings { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
16
Models/GlobalSettings.cs
Normal file
16
Models/GlobalSettings.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace UnifiedFarmLauncher.Models
|
||||||
|
{
|
||||||
|
public class GlobalSettings
|
||||||
|
{
|
||||||
|
[JsonPropertyName("sheepitJarUrls")]
|
||||||
|
public List<string> SheepItJarUrls { get; set; } = new()
|
||||||
|
{
|
||||||
|
"https://www.sheepit-renderfarm.com/media/applet/client-latest.php",
|
||||||
|
"https://www.sheepit-renderfarm.com/media/applet/client-latest.jar"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
17
Models/SshConfig.cs
Normal file
17
Models/SshConfig.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
23
Models/WorkerConfig.cs
Normal file
23
Models/WorkerConfig.cs
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
48
Models/WorkerTypeConfig.cs
Normal file
48
Models/WorkerTypeConfig.cs
Normal file
@@ -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<string> NetworkDrives { get; set; } = new();
|
||||||
|
|
||||||
|
[JsonPropertyName("networkPaths")]
|
||||||
|
public List<string> 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<string>();
|
||||||
|
if (SheepIt != null) types.Add("SheepIt");
|
||||||
|
if (Flamenco != null) types.Add("Flamenco");
|
||||||
|
return string.Join(", ", types);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
Program.cs
Normal file
19
Program.cs
Normal file
@@ -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<App>()
|
||||||
|
.UsePlatformDetect()
|
||||||
|
.WithInterFont()
|
||||||
|
.LogToTrace();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
116
Scripts/remote_worker_attach.ps1
Normal file
116
Scripts/remote_worker_attach.ps1
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
407
Scripts/remote_worker_controller.ps1
Normal file
407
Scripts/remote_worker_controller.ps1
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
41
Services/AttachService.cs
Normal file
41
Services/AttachService.cs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
128
Services/ConfigService.cs
Normal file
128
Services/ConfigService.cs
Normal file
@@ -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<ConfigRoot>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
307
Services/SshService.cs
Normal file
307
Services/SshService.cs
Normal file
@@ -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<string> Options { get; set; } = new();
|
||||||
|
public int? Port { get; set; }
|
||||||
|
public bool RequestPty { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SshService
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, string> _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>();
|
||||||
|
string? targetHost = null;
|
||||||
|
int? port = null;
|
||||||
|
bool requestPty = false;
|
||||||
|
|
||||||
|
var optionsWithArgs = new HashSet<string> { "-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<string> BuildSshArgs(SshConnectionParts parts, bool interactive)
|
||||||
|
{
|
||||||
|
var args = new List<string>
|
||||||
|
{
|
||||||
|
"-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<string> BuildScpArgs(SshConnectionParts parts)
|
||||||
|
{
|
||||||
|
var args = new List<string>
|
||||||
|
{
|
||||||
|
"-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<string> 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<string> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
344
Services/WorkerControllerService.cs
Normal file
344
Services/WorkerControllerService.cs
Normal file
@@ -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))}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
30
UnifiedFarmLauncher.csproj
Normal file
30
UnifiedFarmLauncher.csproj
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<Project Sdk="Avalonia.Desktop">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AvaloniaResource Include="Assets\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Avalonia" Version="11.0.0" />
|
||||||
|
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
|
||||||
|
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.0" />
|
||||||
|
<PackageReference Include="Avalonia.Theme.Fluent" Version="11.0.0" />
|
||||||
|
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0" />
|
||||||
|
<PackageReference Include="System.Text.Json" Version="8.0.0" />
|
||||||
|
<PackageReference Include="MsBox.Avalonia" Version="2.0.6" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Scripts\remote_worker_controller.ps1" />
|
||||||
|
<EmbeddedResource Include="Scripts\remote_worker_attach.ps1" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
|
|
||||||
99
ViewModels/MainWindowViewModel.cs
Normal file
99
ViewModels/MainWindowViewModel.cs
Normal file
@@ -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<WorkerConfig>();
|
||||||
|
LoadWorkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ObservableCollection<WorkerConfig> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
26
ViewModels/ViewModelBase.cs
Normal file
26
ViewModels/ViewModelBase.cs
Normal file
@@ -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<T>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
204
ViewModels/WorkerEditViewModel.cs
Normal file
204
ViewModels/WorkerEditViewModel.cs
Normal file
@@ -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<string>();
|
||||||
|
NetworkPaths = new ObservableCollection<string>();
|
||||||
|
|
||||||
|
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<string> NetworkDrives { get; }
|
||||||
|
public ObservableCollection<string> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
53
Views/MainWindow.axaml
Normal file
53
Views/MainWindow.axaml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="UnifiedFarmLauncher.Views.MainWindow"
|
||||||
|
Title="Unified Farm Launcher"
|
||||||
|
Width="1000" Height="700"
|
||||||
|
MinWidth="800" MinHeight="600">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="5" Grid.Row="0">
|
||||||
|
<Button Name="AddWorkerButton" Content="Add Worker" Margin="5" Width="120"/>
|
||||||
|
<Button Name="EditWorkerButton" Content="Edit Worker" Margin="5" Width="120"/>
|
||||||
|
<Button Name="DeleteWorkerButton" Content="Delete Worker" Margin="5" Width="120"/>
|
||||||
|
<Separator Margin="10,0"/>
|
||||||
|
<Button Name="StartWorkerButton" Content="Start" Margin="5" Width="80"/>
|
||||||
|
<Button Name="StopWorkerButton" Content="Stop" Margin="5" Width="80"/>
|
||||||
|
<Button Name="AttachWorkerButton" Content="Attach" Margin="5" Width="80"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Worker Type Filter -->
|
||||||
|
<TabControl Name="WorkerTypeTabs" Grid.Row="1" Margin="5,0">
|
||||||
|
<TabItem Header="All Workers">
|
||||||
|
<TextBlock Text="All Workers" IsVisible="False"/>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="SheepIt">
|
||||||
|
<TextBlock Text="SheepIt" IsVisible="False"/>
|
||||||
|
</TabItem>
|
||||||
|
<TabItem Header="Flamenco">
|
||||||
|
<TextBlock Text="Flamenco" IsVisible="False"/>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
|
||||||
|
<!-- Worker List -->
|
||||||
|
<DataGrid Name="WorkersGrid" Grid.Row="2" Margin="5"
|
||||||
|
AutoGenerateColumns="False"
|
||||||
|
IsReadOnly="True"
|
||||||
|
SelectionMode="Single"
|
||||||
|
GridLinesVisibility="All">
|
||||||
|
<DataGrid.Columns>
|
||||||
|
<DataGridTextColumn Header="Name" Binding="{Binding Name}" Width="150"/>
|
||||||
|
<DataGridCheckBoxColumn Header="Enabled" Binding="{Binding Enabled}" Width="80"/>
|
||||||
|
<DataGridTextColumn Header="SSH Host" Binding="{Binding Ssh.Host}" Width="150"/>
|
||||||
|
<DataGridTextColumn Header="SSH Port" Binding="{Binding Ssh.Port}" Width="80"/>
|
||||||
|
<DataGridTextColumn Header="Worker Types" Binding="{Binding WorkerTypes}" Width="*"/>
|
||||||
|
</DataGrid.Columns>
|
||||||
|
</DataGrid>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<StatusBar Grid.Row="3">
|
||||||
|
<TextBlock Name="StatusText" Text="{Binding StatusText}" Margin="5"/>
|
||||||
|
</StatusBar>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
|
||||||
211
Views/MainWindow.axaml.cs
Normal file
211
Views/MainWindow.axaml.cs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
90
Views/WorkerEditWindow.axaml
Normal file
90
Views/WorkerEditWindow.axaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
x:Class="UnifiedFarmLauncher.Views.WorkerEditWindow"
|
||||||
|
Title="Edit Worker"
|
||||||
|
Width="600" Height="700"
|
||||||
|
MinWidth="500" MinHeight="600">
|
||||||
|
<Grid RowDefinitions="Auto,*,Auto" Margin="10">
|
||||||
|
<!-- Tabs -->
|
||||||
|
<TabControl Grid.Row="1" Margin="0,10">
|
||||||
|
<!-- Basic Info Tab -->
|
||||||
|
<TabItem Header="Basic Info">
|
||||||
|
<StackPanel Margin="10" Spacing="10">
|
||||||
|
<TextBlock Text="Worker Name:"/>
|
||||||
|
<TextBox Name="NameTextBox" Text="{Binding Name}"/>
|
||||||
|
|
||||||
|
<CheckBox Name="EnabledCheckBox" Content="Enabled" IsChecked="{Binding Enabled}" Margin="0,10,0,0"/>
|
||||||
|
|
||||||
|
<TextBlock Text="SSH Host:" Margin="0,10,0,0"/>
|
||||||
|
<TextBox Name="SshHostTextBox" Text="{Binding SshHost}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="SSH Port:" Margin="0,10,0,0"/>
|
||||||
|
<NumericUpDown Name="SshPortNumeric" Value="{Binding SshPort}" Minimum="1" Maximum="65535"/>
|
||||||
|
|
||||||
|
<TextBlock Text="SSH Args:" Margin="0,10,0,0"/>
|
||||||
|
<TextBox Name="SshArgsTextBox" Text="{Binding SshArgs}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- SheepIt Tab -->
|
||||||
|
<TabItem Header="SheepIt">
|
||||||
|
<StackPanel Margin="10" Spacing="10">
|
||||||
|
<CheckBox Name="HasSheepItCheckBox" Content="Enable SheepIt Worker" IsChecked="{Binding HasSheepIt}" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
|
<TextBlock Text="GPU:" IsVisible="{Binding HasSheepIt}"/>
|
||||||
|
<ComboBox Name="GpuComboBox" IsVisible="{Binding HasSheepIt}" SelectedItem="{Binding SheepItGpu}">
|
||||||
|
<ComboBox.Items>
|
||||||
|
<ComboBoxItem Content="OPTIX_0"/>
|
||||||
|
<ComboBoxItem Content="CUDA_0"/>
|
||||||
|
<ComboBoxItem Content="OPENCL_0"/>
|
||||||
|
</ComboBox.Items>
|
||||||
|
</ComboBox>
|
||||||
|
|
||||||
|
<TextBlock Text="Username:" IsVisible="{Binding HasSheepIt}" Margin="0,10,0,0"/>
|
||||||
|
<TextBox Name="SheepItUsernameTextBox" Text="{Binding SheepItUsername}" IsVisible="{Binding HasSheepIt}"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Render Key:" IsVisible="{Binding HasSheepIt}" Margin="0,10,0,0"/>
|
||||||
|
<TextBox Name="SheepItRenderKeyTextBox" Text="{Binding SheepItRenderKey}" IsVisible="{Binding HasSheepIt}" PasswordChar="*"/>
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
|
<!-- Flamenco Tab -->
|
||||||
|
<TabItem Header="Flamenco">
|
||||||
|
<StackPanel Margin="10" Spacing="10">
|
||||||
|
<CheckBox Name="HasFlamencoCheckBox" Content="Enable Flamenco Worker" IsChecked="{Binding HasFlamenco}" Margin="0,0,0,10"/>
|
||||||
|
|
||||||
|
<TextBlock Text="Worker Path:" IsVisible="{Binding HasFlamenco}"/>
|
||||||
|
<Grid IsVisible="{Binding HasFlamenco}" ColumnDefinitions="*,Auto">
|
||||||
|
<TextBox Name="FlamencoPathTextBox" Grid.Column="0" Text="{Binding FlamencoWorkerPath}" Margin="0,0,5,0"/>
|
||||||
|
<Button Name="BrowseFlamencoPathButton" Grid.Column="1" Content="Browse..." Width="80"/>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="Network Drives:" IsVisible="{Binding HasFlamenco}" Margin="0,10,0,0"/>
|
||||||
|
<Grid IsVisible="{Binding HasFlamenco}" RowDefinitions="*,Auto" Margin="0,5,0,0">
|
||||||
|
<ListBox Name="NetworkDrivesListBox" Grid.Row="0" ItemsSource="{Binding NetworkDrives}" MaxHeight="100"/>
|
||||||
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,5,0,0">
|
||||||
|
<Button Name="AddDriveButton" Content="Add" Width="60" Margin="0,0,5,0"/>
|
||||||
|
<Button Name="RemoveDriveButton" Content="Remove" Width="60"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Text="Network Paths:" IsVisible="{Binding HasFlamenco}" Margin="0,10,0,0"/>
|
||||||
|
<Grid IsVisible="{Binding HasFlamenco}" RowDefinitions="*,Auto" Margin="0,5,0,0">
|
||||||
|
<ListBox Name="NetworkPathsListBox" Grid.Row="0" ItemsSource="{Binding NetworkPaths}" MaxHeight="100"/>
|
||||||
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="0,5,0,0">
|
||||||
|
<Button Name="AddPathButton" Content="Add" Width="60" Margin="0,0,5,0"/>
|
||||||
|
<Button Name="RemovePathButton" Content="Remove" Width="60"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</StackPanel>
|
||||||
|
</TabItem>
|
||||||
|
</TabControl>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Grid.Row="2" Spacing="10" Margin="0,10,0,0">
|
||||||
|
<Button Name="OkButton" Content="OK" Width="80" IsDefault="True"/>
|
||||||
|
<Button Name="CancelButton" Content="Cancel" Width="80" IsCancel="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
|
|
||||||
131
Views/WorkerEditWindow.axaml.cs
Normal file
131
Views/WorkerEditWindow.axaml.cs
Normal file
@@ -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<bool> ShowDialogAsync(Window parent)
|
||||||
|
{
|
||||||
|
await base.ShowDialog(parent);
|
||||||
|
return _result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
12
app.manifest
Normal file
12
app.manifest
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="UnifiedFarmLauncher"/>
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
</assembly>
|
||||||
|
|
||||||
Reference in New Issue
Block a user