mirror of
https://github.com/SabreTools/MPF.git
synced 2026-02-14 05:36:10 +00:00
581 lines
24 KiB
C#
581 lines
24 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using MPF.Core.Data;
|
|
using MPF.Core.Modules;
|
|
using MPF.Core.Utilities;
|
|
using SabreTools.RedumpLib;
|
|
using SabreTools.RedumpLib.Data;
|
|
|
|
namespace MPF.Core
|
|
{
|
|
/// <summary>
|
|
/// Represents the state of all settings to be used during dumping
|
|
/// </summary>
|
|
public class DumpEnvironment
|
|
{
|
|
#region Output paths
|
|
|
|
/// <summary>
|
|
/// Base output file path to write files to
|
|
/// </summary>
|
|
public string OutputPath { get; private set; }
|
|
|
|
#endregion
|
|
|
|
#region UI information
|
|
|
|
/// <summary>
|
|
/// Drive object representing the current drive
|
|
/// </summary>
|
|
public Drive? Drive { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Currently selected system
|
|
/// </summary>
|
|
public RedumpSystem? System { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Currently selected media type
|
|
/// </summary>
|
|
public MediaType? Type { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Currently selected dumping program
|
|
/// </summary>
|
|
public InternalProgram InternalProgram { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Options object representing user-defined options
|
|
/// </summary>
|
|
public Data.Options Options { get; private set; }
|
|
|
|
/// <summary>
|
|
/// Parameters object representing what to send to the internal program
|
|
/// </summary>
|
|
public BaseParameters? Parameters { get; private set; }
|
|
|
|
#endregion
|
|
|
|
#region Event Handlers
|
|
|
|
/// <summary>
|
|
/// Generic way of reporting a message
|
|
/// </summary>
|
|
#if NET20 || NET35 || NET40
|
|
public EventHandler<BaseParameters.StringEventArgs>? ReportStatus;
|
|
#else
|
|
public EventHandler<string>? ReportStatus;
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// Queue of items that need to be logged
|
|
/// </summary>
|
|
private ProcessingQueue<string>? outputQueue;
|
|
|
|
/// <summary>
|
|
/// Event handler for data returned from a process
|
|
/// </summary>
|
|
#if NET20 || NET35 || NET40
|
|
private void OutputToLog(object? proc, BaseParameters.StringEventArgs args) => outputQueue?.Enqueue(args.Value);
|
|
#else
|
|
private void OutputToLog(object? proc, string args) => outputQueue?.Enqueue(args);
|
|
#endif
|
|
|
|
/// <summary>
|
|
/// Process the outputs in the queue
|
|
/// </summary>
|
|
#if NET20 || NET35 || NET40
|
|
private void ProcessOutputs(string nextOutput) => ReportStatus?.Invoke(this, new BaseParameters.StringEventArgs { Value = nextOutput });
|
|
#else
|
|
private void ProcessOutputs(string nextOutput) => ReportStatus?.Invoke(this, nextOutput);
|
|
#endif
|
|
|
|
#endregion
|
|
|
|
/// <summary>
|
|
/// Constructor for a full DumpEnvironment object from user information
|
|
/// </summary>
|
|
/// <param name="options"></param>
|
|
/// <param name="outputPath"></param>
|
|
/// <param name="drive"></param>
|
|
/// <param name="system"></param>
|
|
/// <param name="type"></param>
|
|
/// <param name="internalProgram"></param>
|
|
/// <param name="parameters"></param>
|
|
public DumpEnvironment(Data.Options options,
|
|
string outputPath,
|
|
Drive? drive,
|
|
RedumpSystem? system,
|
|
MediaType? type,
|
|
InternalProgram? internalProgram,
|
|
string? parameters)
|
|
{
|
|
// Set options object
|
|
Options = options;
|
|
|
|
// Output paths
|
|
OutputPath = InfoTool.NormalizeOutputPaths(outputPath, false);
|
|
|
|
// UI information
|
|
Drive = drive;
|
|
System = system ?? options.DefaultSystem;
|
|
Type = type ?? MediaType.NONE;
|
|
InternalProgram = internalProgram ?? options.InternalProgram;
|
|
|
|
// Dumping program
|
|
SetParameters(parameters);
|
|
}
|
|
|
|
#region Public Functionality
|
|
|
|
/// <summary>
|
|
/// Set the parameters object based on the internal program and parameters string
|
|
/// </summary>
|
|
/// <param name="parameters">String representation of the parameters</param>
|
|
public void SetParameters(string? parameters)
|
|
{
|
|
Parameters = InternalProgram switch
|
|
{
|
|
// Dumping support
|
|
InternalProgram.Aaru => new Modules.Aaru.Parameters(parameters) { ExecutablePath = Options.AaruPath },
|
|
InternalProgram.DiscImageCreator => new Modules.DiscImageCreator.Parameters(parameters) { ExecutablePath = Options.DiscImageCreatorPath },
|
|
InternalProgram.Redumper => new Modules.Redumper.Parameters(parameters) { ExecutablePath = Options.RedumperPath },
|
|
|
|
// Verification support only
|
|
InternalProgram.CleanRip => new Modules.CleanRip.Parameters(parameters) { ExecutablePath = null },
|
|
InternalProgram.DCDumper => null, // TODO: Create correct parameter type when supported
|
|
InternalProgram.PS3CFW => new Modules.PS3CFW.Parameters(parameters) { ExecutablePath = null },
|
|
InternalProgram.UmdImageCreator => new Modules.UmdImageCreator.Parameters(parameters) { ExecutablePath = null },
|
|
|
|
// If no dumping program found, set to null
|
|
InternalProgram.NONE => null,
|
|
|
|
// This should never happen, but it needs a fallback
|
|
_ => new Modules.Redumper.Parameters(parameters) { ExecutablePath = Options.RedumperPath },
|
|
};
|
|
|
|
// Set system and type
|
|
if (Parameters != null)
|
|
{
|
|
Parameters.System = System;
|
|
Parameters.Type = Type;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the full parameter string for either DiscImageCreator or Aaru
|
|
/// </summary>
|
|
/// <param name="driveSpeed">Nullable int representing the drive speed</param>
|
|
/// <returns>String representing the params, null on error</returns>
|
|
public string? GetFullParameters(int? driveSpeed)
|
|
{
|
|
// Populate with the correct params for inputs (if we're not on the default option)
|
|
if (System != null && Type != MediaType.NONE)
|
|
{
|
|
// If drive letter is invalid, skip this
|
|
if (Drive == null)
|
|
return null;
|
|
|
|
// Set the proper parameters
|
|
Parameters = InternalProgram switch
|
|
{
|
|
InternalProgram.Aaru => new Modules.Aaru.Parameters(System, Type, Drive.Name, OutputPath, driveSpeed, Options),
|
|
InternalProgram.DiscImageCreator => new Modules.DiscImageCreator.Parameters(System, Type, Drive.Name, OutputPath, driveSpeed, Options),
|
|
InternalProgram.Redumper => new Modules.Redumper.Parameters(System, Type, Drive.Name, OutputPath, driveSpeed, Options),
|
|
|
|
// If no dumping program found, set to null
|
|
InternalProgram.NONE => null,
|
|
|
|
// This should never happen, but it needs a fallback
|
|
_ => new Modules.Redumper.Parameters(System, Type, Drive.Name, OutputPath, driveSpeed, Options),
|
|
};
|
|
|
|
// Generate and return the param string
|
|
return Parameters?.GenerateParameters();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Dumping
|
|
|
|
/// <summary>
|
|
/// Cancel an in-progress dumping process
|
|
/// </summary>
|
|
public void CancelDumping() => Parameters?.KillInternalProgram();
|
|
|
|
/// <summary>
|
|
/// Eject the disc using DiscImageCreator
|
|
/// </summary>
|
|
public async Task<string?> EjectDisc() =>
|
|
await RunStandaloneDiscImageCreatorCommand(Modules.DiscImageCreator.CommandStrings.Eject);
|
|
|
|
/// <summary>
|
|
/// Reset the current drive using DiscImageCreator
|
|
/// </summary>
|
|
public async Task<string?> ResetDrive() =>
|
|
await RunStandaloneDiscImageCreatorCommand(Modules.DiscImageCreator.CommandStrings.Reset);
|
|
|
|
/// <summary>
|
|
/// Execute the initial invocation of the dumping programs
|
|
/// </summary>
|
|
/// <param name="progress">Optional result progress callback</param>
|
|
#if NET40
|
|
public Result Run(IProgress<Result>? progress = null)
|
|
#else
|
|
public async Task<Result> Run(IProgress<Result>? progress = null)
|
|
#endif
|
|
{
|
|
// If we don't have parameters
|
|
if (Parameters == null)
|
|
return Result.Failure("Error! Current configuration is not supported!");
|
|
|
|
// Check that we have the basics for dumping
|
|
Result result = IsValidForDump();
|
|
if (!result)
|
|
return result;
|
|
|
|
// Invoke output processing, if needed
|
|
if (!Options.ToolsInSeparateWindow)
|
|
{
|
|
outputQueue = new ProcessingQueue<string>(ProcessOutputs);
|
|
if (Parameters.ReportStatus != null)
|
|
Parameters.ReportStatus += OutputToLog;
|
|
}
|
|
|
|
// Execute internal tool
|
|
progress?.Report(Result.Success($"Executing {InternalProgram}... {(Options.ToolsInSeparateWindow ? "please wait!" : "see log for output!")}"));
|
|
|
|
var directoryName = Path.GetDirectoryName(OutputPath);
|
|
if (!string.IsNullOrEmpty(directoryName))
|
|
Directory.CreateDirectory(directoryName);
|
|
|
|
#if NET40
|
|
var executeTask = Task.Factory.StartNew(() => Parameters.ExecuteInternalProgram(Options.ToolsInSeparateWindow));
|
|
executeTask.Wait();
|
|
#else
|
|
await Task.Run(() => Parameters.ExecuteInternalProgram(Options.ToolsInSeparateWindow));
|
|
#endif
|
|
progress?.Report(Result.Success($"{InternalProgram} has finished!"));
|
|
|
|
// Remove event handler if needed
|
|
if (!Options.ToolsInSeparateWindow)
|
|
{
|
|
outputQueue?.Dispose();
|
|
Parameters.ReportStatus -= OutputToLog;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verify that the current environment has a complete dump and create submission info is possible
|
|
/// </summary>
|
|
/// <param name="resultProgress">Optional result progress callback</param>
|
|
/// <param name="protectionProgress">Optional protection progress callback</param>
|
|
/// <param name="processUserInfo">Optional user prompt to deal with submission information</param>
|
|
/// <param name="seedInfo">A seed SubmissionInfo object that contains user data</param>
|
|
/// <returns>Result instance with the outcome</returns>
|
|
public async Task<Result> VerifyAndSaveDumpOutput(
|
|
IProgress<Result>? resultProgress = null,
|
|
IProgress<BinaryObjectScanner.ProtectionProgress>? protectionProgress = null,
|
|
Func<SubmissionInfo?, (bool?, SubmissionInfo?)>? processUserInfo = null,
|
|
SubmissionInfo? seedInfo = null)
|
|
{
|
|
if (Parameters == null)
|
|
return Result.Failure("Error! Current configuration is not supported!");
|
|
|
|
resultProgress?.Report(Result.Success("Gathering submission information... please wait!"));
|
|
|
|
// Get the output directory and filename separately
|
|
var outputDirectory = Path.GetDirectoryName(OutputPath);
|
|
var outputFilename = Path.GetFileName(OutputPath);
|
|
|
|
// Check to make sure that the output had all the correct files
|
|
(bool foundFiles, List<string> missingFiles) = Parameters.FoundAllFiles(outputDirectory, outputFilename, false);
|
|
if (!foundFiles)
|
|
{
|
|
resultProgress?.Report(Result.Failure($"There were files missing from the output:\n{string.Join("\n", [.. missingFiles])}"));
|
|
return Result.Failure("Error! Please check output directory as dump may be incomplete!");
|
|
}
|
|
|
|
// Extract the information from the output files
|
|
resultProgress?.Report(Result.Success("Extracting output information from output files..."));
|
|
var submissionInfo = await SubmissionInfoTool.ExtractOutputInformation(
|
|
OutputPath,
|
|
Drive,
|
|
System,
|
|
Type,
|
|
Options,
|
|
Parameters,
|
|
resultProgress,
|
|
protectionProgress);
|
|
resultProgress?.Report(Result.Success("Extracting information complete!"));
|
|
|
|
// Inject seed submission info data, if necessary
|
|
if (seedInfo != null)
|
|
{
|
|
resultProgress?.Report(Result.Success("Injecting user-supplied information..."));
|
|
Builder.InjectSubmissionInformation(submissionInfo, seedInfo);
|
|
resultProgress?.Report(Result.Success("Information injection complete!"));
|
|
}
|
|
|
|
// Eject the disc automatically if configured to
|
|
if (Options.EjectAfterDump == true)
|
|
{
|
|
resultProgress?.Report(Result.Success($"Ejecting disc in drive {Drive?.Name}"));
|
|
await EjectDisc();
|
|
}
|
|
|
|
// Reset the drive automatically if configured to
|
|
if (InternalProgram == InternalProgram.DiscImageCreator && Options.DICResetDriveAfterDump)
|
|
{
|
|
resultProgress?.Report(Result.Success($"Resetting drive {Drive?.Name}"));
|
|
await ResetDrive();
|
|
}
|
|
|
|
// Get user-modifiable information if confugured to
|
|
if (Options.PromptForDiscInformation && processUserInfo != null)
|
|
{
|
|
resultProgress?.Report(Result.Success("Waiting for additional disc information..."));
|
|
|
|
bool? filledInfo;
|
|
(filledInfo, submissionInfo) = processUserInfo(submissionInfo);
|
|
|
|
if (filledInfo == true)
|
|
resultProgress?.Report(Result.Success("Additional disc information added!"));
|
|
else
|
|
resultProgress?.Report(Result.Success("Disc information skipped!"));
|
|
}
|
|
|
|
// Process special fields for site codes
|
|
resultProgress?.Report(Result.Success("Processing site codes..."));
|
|
Formatter.ProcessSpecialFields(submissionInfo);
|
|
resultProgress?.Report(Result.Success("Processing complete!"));
|
|
|
|
// Format the information for the text output
|
|
resultProgress?.Report(Result.Success("Formatting information..."));
|
|
(var formattedValues, var formatResult) = Formatter.FormatOutputData(submissionInfo, Options.EnableRedumpCompatibility);
|
|
if (formattedValues == null)
|
|
resultProgress?.Report(Result.Failure(formatResult));
|
|
else
|
|
resultProgress?.Report(Result.Success(formatResult));
|
|
|
|
// Get the filename suffix for auto-generated files
|
|
var filenameSuffix = Options.AddFilenameSuffix ? Path.GetFileNameWithoutExtension(outputFilename) : null;
|
|
|
|
// Write the text output
|
|
resultProgress?.Report(Result.Success("Writing information to !submissionInfo.txt..."));
|
|
(bool txtSuccess, string txtResult) = InfoTool.WriteOutputData(outputDirectory, filenameSuffix, formattedValues);
|
|
if (txtSuccess)
|
|
resultProgress?.Report(Result.Success(txtResult));
|
|
else
|
|
resultProgress?.Report(Result.Failure(txtResult));
|
|
|
|
// Write the copy protection output
|
|
if (submissionInfo?.CopyProtection?.FullProtections != null && submissionInfo.CopyProtection.FullProtections.Any())
|
|
{
|
|
if (Options.ScanForProtection && Options.OutputSeparateProtectionFile)
|
|
{
|
|
resultProgress?.Report(Result.Success("Writing protection to !protectionInfo.txt..."));
|
|
bool scanSuccess = InfoTool.WriteProtectionData(outputDirectory, filenameSuffix, submissionInfo, Options.HideDriveLetters);
|
|
if (scanSuccess)
|
|
resultProgress?.Report(Result.Success("Writing complete!"));
|
|
else
|
|
resultProgress?.Report(Result.Failure("Writing could not complete!"));
|
|
}
|
|
}
|
|
|
|
// Write the JSON output, if required
|
|
if (Options.OutputSubmissionJSON)
|
|
{
|
|
resultProgress?.Report(Result.Success($"Writing information to !submissionInfo.json{(Options.IncludeArtifacts ? ".gz" : string.Empty)}..."));
|
|
bool jsonSuccess = InfoTool.WriteOutputData(outputDirectory, filenameSuffix, submissionInfo, Options.IncludeArtifacts);
|
|
if (jsonSuccess)
|
|
resultProgress?.Report(Result.Success("Writing complete!"));
|
|
else
|
|
resultProgress?.Report(Result.Failure("Writing could not complete!"));
|
|
}
|
|
|
|
// Compress the logs, if required
|
|
if (Options.CompressLogFiles)
|
|
{
|
|
resultProgress?.Report(Result.Success("Compressing log files..."));
|
|
(bool compressSuccess, string compressResult) = InfoTool.CompressLogFiles(outputDirectory, filenameSuffix, outputFilename, Parameters);
|
|
if (compressSuccess)
|
|
resultProgress?.Report(Result.Success(compressResult));
|
|
else
|
|
resultProgress?.Report(Result.Failure(compressResult));
|
|
}
|
|
|
|
// Delete unnecessary files, if required
|
|
if (Options.DeleteUnnecessaryFiles)
|
|
{
|
|
resultProgress?.Report(Result.Success("Deleting unnecessary files..."));
|
|
(bool deleteSuccess, string deleteResult) = InfoTool.DeleteUnnecessaryFiles(outputDirectory, outputFilename, Parameters);
|
|
if (deleteSuccess)
|
|
resultProgress?.Report(Result.Success(deleteResult));
|
|
else
|
|
resultProgress?.Report(Result.Failure(deleteResult));
|
|
}
|
|
|
|
// Create PS3 IRD, if required
|
|
if (Options.CreateIRDAfterDumping && System == RedumpSystem.SonyPlayStation3 && Type == MediaType.BluRay)
|
|
{
|
|
resultProgress?.Report(Result.Success("Creating IRD... please wait!"));
|
|
(bool deleteSuccess, string deleteResult) = await InfoTool.WriteIRD(OutputPath, submissionInfo?.Extras?.DiscKey, submissionInfo?.Extras?.DiscID, submissionInfo?.Extras?.PIC, submissionInfo?.SizeAndChecksums?.Layerbreak, submissionInfo?.SizeAndChecksums?.CRC32);
|
|
if (deleteSuccess)
|
|
resultProgress?.Report(Result.Success(deleteResult));
|
|
else
|
|
resultProgress?.Report(Result.Failure(deleteResult));
|
|
}
|
|
|
|
resultProgress?.Report(Result.Success("Submission information process complete!"));
|
|
return Result.Success();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the parameters are valid
|
|
/// </summary>
|
|
/// <returns>True if the configuration is valid, false otherwise</returns>
|
|
internal bool ParametersValid()
|
|
{
|
|
// Missing drive means it can never be valid
|
|
if (Drive == null)
|
|
return false;
|
|
|
|
bool parametersValid = Parameters?.IsValid() ?? false;
|
|
bool floppyValid = !(Drive.InternalDriveType == InternalDriveType.Floppy ^ Type == MediaType.FloppyDisk);
|
|
|
|
// TODO: HardDisk being in the Removable category is a hack, fix this later
|
|
bool removableDiskValid = !((Drive.InternalDriveType == InternalDriveType.Removable || Drive.InternalDriveType == InternalDriveType.HardDisk)
|
|
^ (Type == MediaType.CompactFlash || Type == MediaType.SDCard || Type == MediaType.FlashDrive || Type == MediaType.HardDisk));
|
|
|
|
return parametersValid && floppyValid && removableDiskValid;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run internal program async with an input set of parameters
|
|
/// </summary>
|
|
/// <param name="parameters"></param>
|
|
/// <returns>Standard output from commandline window</returns>
|
|
private static async Task<string> ExecuteInternalProgram(BaseParameters parameters)
|
|
{
|
|
Process childProcess;
|
|
#if NET40
|
|
string output = await Task.Factory.StartNew(() =>
|
|
#else
|
|
string output = await Task.Run(() =>
|
|
#endif
|
|
{
|
|
childProcess = new Process()
|
|
{
|
|
StartInfo = new ProcessStartInfo()
|
|
{
|
|
FileName = parameters.ExecutablePath!,
|
|
Arguments = parameters.GenerateParameters()!,
|
|
CreateNoWindow = true,
|
|
UseShellExecute = false,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
},
|
|
};
|
|
childProcess.Start();
|
|
childProcess.WaitForExit(1000);
|
|
|
|
// Just in case, we want to push a button 5 times to clear any errors
|
|
for (int i = 0; i < 5; i++)
|
|
childProcess.StandardInput.WriteLine("Y");
|
|
|
|
string stdout = childProcess.StandardOutput.ReadToEnd();
|
|
childProcess.Dispose();
|
|
return stdout;
|
|
});
|
|
|
|
return output;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate the current environment is ready for a dump
|
|
/// </summary>
|
|
/// <returns>Result instance with the outcome</returns>
|
|
private Result IsValidForDump()
|
|
{
|
|
// Validate that everything is good
|
|
if (Parameters == null || !ParametersValid())
|
|
return Result.Failure("Error! Current configuration is not supported!");
|
|
|
|
// Fix the output paths, just in case
|
|
OutputPath = InfoTool.NormalizeOutputPaths(OutputPath, false);
|
|
|
|
// Validate that the output path isn't on the dumping drive
|
|
if (Drive?.Name != null && OutputPath.StartsWith(Drive.Name))
|
|
return Result.Failure("Error! Cannot output to same drive that is being dumped!");
|
|
|
|
// Validate that the required program exists
|
|
if (!File.Exists(Parameters.ExecutablePath))
|
|
return Result.Failure($"Error! {Parameters.ExecutablePath} does not exist!");
|
|
|
|
// Validate that the dumping drive doesn't contain the executable
|
|
string fullExecutablePath = Path.GetFullPath(Parameters.ExecutablePath!);
|
|
if (Drive?.Name != null && fullExecutablePath.StartsWith(Drive.Name))
|
|
return Result.Failure("Error! Cannot dump same drive that executable resides on!");
|
|
|
|
// Validate that the current configuration is supported
|
|
return Tools.GetSupportStatus(System, Type);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate that DIscImageCreator is able to be found
|
|
/// </summary>
|
|
/// <returns>True if DiscImageCreator is found properly, false otherwise</returns>
|
|
private bool RequiredProgramsExist()
|
|
{
|
|
// Validate that the path is configured
|
|
if (string.IsNullOrEmpty(Options.DiscImageCreatorPath))
|
|
return false;
|
|
|
|
// Validate that the required program exists
|
|
if (!File.Exists(Options.DiscImageCreatorPath))
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run a standalone DiscImageCreator command
|
|
/// </summary>
|
|
/// <param name="command">Command string to run</param>
|
|
/// <returns>The output of the command on success, null on error</returns>
|
|
private async Task<string?> RunStandaloneDiscImageCreatorCommand(string command)
|
|
{
|
|
// Validate that DiscImageCreator is all set
|
|
if (!RequiredProgramsExist())
|
|
return null;
|
|
|
|
// Validate we're not trying to eject a non-optical
|
|
if (Drive == null || Drive.InternalDriveType != InternalDriveType.Optical)
|
|
return null;
|
|
|
|
CancelDumping();
|
|
|
|
var parameters = new Modules.DiscImageCreator.Parameters(string.Empty)
|
|
{
|
|
BaseCommand = command,
|
|
DrivePath = Drive.Name,
|
|
ExecutablePath = Options.DiscImageCreatorPath,
|
|
};
|
|
|
|
return await ExecuteInternalProgram(parameters);
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|