488 lines
16 KiB
C++
488 lines
16 KiB
C++
#include "ClaudeCodeInitializer.h"
|
|
#include "UnrealMCPSetupLog.h"
|
|
#include "ClaudeCodeResultDialog.h"
|
|
#include "Dom/JsonObject.h"
|
|
#include "HAL/FileManager.h"
|
|
#include "HAL/PlatformFileManager.h"
|
|
#include "HAL/PlatformProcess.h"
|
|
#include "Misc/App.h"
|
|
#include "Misc/FileHelper.h"
|
|
#include "Misc/MessageDialog.h"
|
|
#include "Misc/Paths.h"
|
|
#include "Misc/ScopedSlowTask.h"
|
|
#include "Interfaces/IPluginManager.h"
|
|
#include "Serialization/JsonReader.h"
|
|
#include "Serialization/JsonSerializer.h"
|
|
|
|
#define LOCTEXT_NAMESPACE "FClaudeCodeInitializer"
|
|
|
|
void FClaudeCodeInitializer::Run()
|
|
{
|
|
if (!IsCppProject())
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Warning, TEXT("Init aborted: not a C++ project (no .Build.cs found)"));
|
|
FMessageDialog::Open(EAppMsgType::Ok,
|
|
LOCTEXT("NotCppError", "Claude Code ne peut être initialisé que dans un projet C++."));
|
|
return;
|
|
}
|
|
|
|
const EAppReturnType::Type Answer = FMessageDialog::Open(EAppMsgType::YesNo,
|
|
LOCTEXT("ConfirmInit", "Êtes-vous sûr de vouloir initialiser Claude Code pour ce projet ?"));
|
|
if (Answer != EAppReturnType::Yes)
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("Init cancelled by user"));
|
|
return;
|
|
}
|
|
|
|
TSharedPtr<IPlugin> Plugin = IPluginManager::Get().FindPlugin(TEXT("UnrealMCP"));
|
|
if (!Plugin.IsValid())
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("Plugin 'UnrealMCP' not found via IPluginManager"));
|
|
FMessageDialog::Open(EAppMsgType::Ok,
|
|
LOCTEXT("PluginNotFound", "Plugin UnrealMCP introuvable."));
|
|
return;
|
|
}
|
|
|
|
const FString PluginDir = Plugin->GetBaseDir();
|
|
const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir());
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("Starting Claude Code init — PluginDir: %s | ProjectDir: %s"), *PluginDir, *ProjectDir);
|
|
|
|
FInitResult Result;
|
|
{
|
|
FScopedSlowTask SlowTask(5.0f, LOCTEXT("Working", "Initialisation de Claude Code..."));
|
|
SlowTask.MakeDialog(false, false);
|
|
|
|
SlowTask.EnterProgressFrame(1.0f, LOCTEXT("StepCopy", "Vérification du serveur Python..."));
|
|
Result.Steps.Add(CopyPythonServer(PluginDir, ProjectDir));
|
|
|
|
SlowTask.EnterProgressFrame(1.0f, LOCTEXT("StepClaudeAssets", "Copie des commandes et skills Claude Code..."));
|
|
Result.Steps.Add(CopyClaudeAssets(PluginDir, ProjectDir));
|
|
|
|
SlowTask.EnterProgressFrame(1.0f, LOCTEXT("StepMcp", "Vérification de .mcp.json..."));
|
|
Result.Steps.Add(WriteMcpJson(ProjectDir));
|
|
|
|
SlowTask.EnterProgressFrame(1.0f, LOCTEXT("StepSettings", "Vérification des permissions MCP..."));
|
|
Result.Steps.Add(WriteClaudeLocalSettings(ProjectDir));
|
|
|
|
SlowTask.EnterProgressFrame(1.0f, LOCTEXT("StepUv", "Initialisation de l'environnement Python (uv sync)..."));
|
|
Result.Steps.Add(RunUvSync(ProjectDir));
|
|
}
|
|
|
|
Result.bOverallSuccess = true;
|
|
for (const FInitStep& Step : Result.Steps)
|
|
{
|
|
if (!Step.bSuccess)
|
|
{
|
|
Result.bOverallSuccess = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (Result.bOverallSuccess)
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("Claude Code init completed successfully"));
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("Claude Code init finished with errors — check Output Log for details"));
|
|
}
|
|
|
|
FClaudeCodeResultDialog::Show(Result);
|
|
}
|
|
|
|
bool FClaudeCodeInitializer::IsCppProject()
|
|
{
|
|
const FString BuildCs = FPaths::ProjectDir()
|
|
/ TEXT("Source")
|
|
/ FApp::GetProjectName()
|
|
/ (FString(FApp::GetProjectName()) + TEXT(".Build.cs"));
|
|
return FPaths::FileExists(BuildCs);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validation helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
bool FClaudeCodeInitializer::IsMcpJsonValid(const FString& ProjectDir)
|
|
{
|
|
const FString FilePath = ProjectDir / TEXT(".mcp.json");
|
|
FString RawContent;
|
|
if (!FFileHelper::LoadFileToString(RawContent, *FilePath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
TSharedPtr<FJsonObject> Root;
|
|
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(RawContent);
|
|
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const TSharedPtr<FJsonObject>* McpServers;
|
|
if (!Root->TryGetObjectField(TEXT("mcpServers"), McpServers))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const TSharedPtr<FJsonObject>* UnrealMCP;
|
|
if (!(*McpServers)->TryGetObjectField(TEXT("unrealMCP"), UnrealMCP))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
FString Command;
|
|
if (!(*UnrealMCP)->TryGetStringField(TEXT("command"), Command) || Command != TEXT("uv"))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const TArray<TSharedPtr<FJsonValue>>* Args;
|
|
if (!(*UnrealMCP)->TryGetArrayField(TEXT("args"), Args))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (const TSharedPtr<FJsonValue>& Arg : *Args)
|
|
{
|
|
if (Arg->AsString() == TEXT("unreal_mcp_server.py"))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool FClaudeCodeInitializer::IsMcpServerFolderValid(const FString& ProjectDir)
|
|
{
|
|
const FString EntryPoint = ProjectDir / TEXT(".claude/mcp-server/unreal_mcp_server.py");
|
|
return FPaths::FileExists(EntryPoint);
|
|
}
|
|
|
|
bool FClaudeCodeInitializer::IsClaudeLocalSettingsValid(const FString& ProjectDir)
|
|
{
|
|
const FString FilePath = ProjectDir / TEXT(".claude/settings.local.json");
|
|
FString RawContent;
|
|
if (!FFileHelper::LoadFileToString(RawContent, *FilePath))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
TSharedPtr<FJsonObject> Root;
|
|
TSharedRef<TJsonReader<>> Reader = TJsonReaderFactory<>::Create(RawContent);
|
|
if (!FJsonSerializer::Deserialize(Reader, Root) || !Root.IsValid())
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const TSharedPtr<FJsonObject>* Permissions;
|
|
if (!Root->TryGetObjectField(TEXT("permissions"), Permissions))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
const TArray<TSharedPtr<FJsonValue>>* Allow;
|
|
if (!(*Permissions)->TryGetArrayField(TEXT("allow"), Allow))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
for (const TSharedPtr<FJsonValue>& Entry : *Allow)
|
|
{
|
|
if (Entry->AsString().StartsWith(TEXT("mcp__unrealMCP__")))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Init steps — validate first, act only if needed
|
|
// ---------------------------------------------------------------------------
|
|
|
|
bool FClaudeCodeInitializer::CopyDirectoryFiltered(const FString& Src, const FString& Dst)
|
|
{
|
|
static const TArray<FString> SkippedDirs = { TEXT(".venv"), TEXT("__pycache__"), TEXT("unreal_mcp.egg-info") };
|
|
static const TArray<FString> SkippedExts = { TEXT(".log"), TEXT(".bck") };
|
|
|
|
IPlatformFile& PF = FPlatformFileManager::Get().GetPlatformFile();
|
|
PF.CreateDirectoryTree(*Dst);
|
|
|
|
bool bAllOk = true;
|
|
PF.IterateDirectory(*Src, [&](const TCHAR* Path, bool bIsDirectory) -> bool
|
|
{
|
|
const FString Name = FPaths::GetCleanFilename(Path);
|
|
const FString DstPath = Dst / Name;
|
|
|
|
if (bIsDirectory)
|
|
{
|
|
if (!SkippedDirs.Contains(Name))
|
|
{
|
|
bAllOk &= CopyDirectoryFiltered(Path, DstPath);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
const FString Ext = FPaths::GetExtension(Name, /*bIncludeDot*/ true).ToLower();
|
|
if (!SkippedExts.Contains(Ext))
|
|
{
|
|
if (!PF.CopyFile(*DstPath, Path))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Warning, TEXT("[Copy] Failed to copy file: %s"), Path);
|
|
bAllOk = false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
});
|
|
return bAllOk;
|
|
}
|
|
|
|
FInitStep FClaudeCodeInitializer::CopyPythonServer(const FString& PluginDir, const FString& ProjectDir)
|
|
{
|
|
FInitStep Step;
|
|
Step.Name = LOCTEXT("StepNameCopy", "Serveur Python (.claude/mcp-server)");
|
|
|
|
if (IsMcpServerFolderValid(ProjectDir))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[PythonServer] Already valid — skipping copy"));
|
|
Step.bSuccess = true;
|
|
Step.Details = TEXT("Déjà présent et valide — aucune modification.");
|
|
return Step;
|
|
}
|
|
|
|
const FString Src = PluginDir / TEXT("PythonServer");
|
|
const FString Dst = ProjectDir / TEXT(".claude/mcp-server");
|
|
|
|
if (!FPaths::DirectoryExists(Src))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("[PythonServer] Source folder not found: %s"), *Src);
|
|
Step.bSuccess = false;
|
|
Step.Details = FString::Printf(TEXT("Dossier source introuvable : %s"), *Src);
|
|
return Step;
|
|
}
|
|
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[PythonServer] Copying %s → %s (skipping .venv, __pycache__, egg-info)"), *Src, *Dst);
|
|
if (CopyDirectoryFiltered(Src, Dst))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[PythonServer] Copy successful"));
|
|
Step.bSuccess = true;
|
|
Step.Details = FString::Printf(TEXT("Copié : %s → %s"), *Src, *Dst);
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("[PythonServer] Copy failed to %s"), *Dst);
|
|
Step.bSuccess = false;
|
|
Step.Details = FString::Printf(TEXT("Échec de la copie vers %s"), *Dst);
|
|
}
|
|
return Step;
|
|
}
|
|
|
|
FInitStep FClaudeCodeInitializer::CopyClaudeAssets(const FString& PluginDir, const FString& ProjectDir)
|
|
{
|
|
FInitStep Step;
|
|
Step.Name = LOCTEXT("StepNameClaudeAssets", "Commandes & Skills (.claude/commands, .claude/skills)");
|
|
|
|
const FString ClaudeSrc = PluginDir / TEXT(".claude");
|
|
const FString ClaudeDst = ProjectDir / TEXT(".claude");
|
|
|
|
// Check if both directories are already present
|
|
const bool bCommandsOk = FPaths::DirectoryExists(ClaudeDst / TEXT("commands"));
|
|
const bool bSkillsOk = FPaths::DirectoryExists(ClaudeDst / TEXT("skills"));
|
|
if (bCommandsOk && bSkillsOk)
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[ClaudeAssets] commands/ and skills/ already present — skipping"));
|
|
Step.bSuccess = true;
|
|
Step.Details = TEXT("Déjà présents — aucune modification.");
|
|
return Step;
|
|
}
|
|
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[ClaudeAssets] Copying commands/ and skills/ from %s → %s"), *ClaudeSrc, *ClaudeDst);
|
|
|
|
bool bAllOk = true;
|
|
for (const FString& SubDir : { FString(TEXT("commands")), FString(TEXT("skills")) })
|
|
{
|
|
const FString Src = ClaudeSrc / SubDir;
|
|
const FString Dst = ClaudeDst / SubDir;
|
|
if (FPaths::DirectoryExists(Src))
|
|
{
|
|
if (!CopyDirectoryFiltered(Src, Dst))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("[ClaudeAssets] Failed to copy %s"), *Src);
|
|
bAllOk = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Warning, TEXT("[ClaudeAssets] Source not found, skipping: %s"), *Src);
|
|
}
|
|
}
|
|
|
|
Step.bSuccess = bAllOk;
|
|
Step.Details = bAllOk
|
|
? FString::Printf(TEXT("Copié : commands/ et skills/ → %s"), *ClaudeDst)
|
|
: FString::Printf(TEXT("Échec partiel — voir Output Log pour les détails"));
|
|
return Step;
|
|
}
|
|
|
|
FInitStep FClaudeCodeInitializer::WriteMcpJson(const FString& ProjectDir)
|
|
{
|
|
FInitStep Step;
|
|
Step.Name = LOCTEXT("StepNameMcp", ".mcp.json");
|
|
|
|
if (IsMcpJsonValid(ProjectDir))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[.mcp.json] Already valid — skipping write"));
|
|
Step.bSuccess = true;
|
|
Step.Details = TEXT("Déjà configuré et valide — aucune modification.");
|
|
return Step;
|
|
}
|
|
|
|
const FString FilePath = ProjectDir / TEXT(".mcp.json");
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[.mcp.json] Writing %s"), *FilePath);
|
|
const FString Content =
|
|
TEXT("{\n")
|
|
TEXT(" \"mcpServers\": {\n")
|
|
TEXT(" \"unrealMCP\": {\n")
|
|
TEXT(" \"type\": \"stdio\",\n")
|
|
TEXT(" \"command\": \"uv\",\n")
|
|
TEXT(" \"args\": [\"--directory\", \".claude/mcp-server\", \"run\", \"unreal_mcp_server.py\"]\n")
|
|
TEXT(" }\n")
|
|
TEXT(" }\n")
|
|
TEXT("}\n");
|
|
|
|
if (FFileHelper::SaveStringToFile(Content, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[.mcp.json] Written successfully"));
|
|
Step.bSuccess = true;
|
|
Step.Details = FString::Printf(TEXT("Créé : %s"), *FilePath);
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("[.mcp.json] Failed to write %s"), *FilePath);
|
|
Step.bSuccess = false;
|
|
Step.Details = FString::Printf(TEXT("Impossible d'écrire %s"), *FilePath);
|
|
}
|
|
return Step;
|
|
}
|
|
|
|
FInitStep FClaudeCodeInitializer::WriteClaudeLocalSettings(const FString& ProjectDir)
|
|
{
|
|
FInitStep Step;
|
|
Step.Name = LOCTEXT("StepNameSettings", "Permissions MCP (.claude/settings.local.json)");
|
|
|
|
if (IsClaudeLocalSettingsValid(ProjectDir))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[settings.local.json] Already valid — skipping write"));
|
|
Step.bSuccess = true;
|
|
Step.Details = TEXT("Déjà configuré et valide — aucune modification.");
|
|
return Step;
|
|
}
|
|
|
|
const FString ClaudeDir = ProjectDir / TEXT(".claude");
|
|
const FString FilePath = ClaudeDir / TEXT("settings.local.json");
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[settings.local.json] Writing %s"), *FilePath);
|
|
|
|
IPlatformFile& PF = FPlatformFileManager::Get().GetPlatformFile();
|
|
if (!PF.DirectoryExists(*ClaudeDir))
|
|
{
|
|
PF.CreateDirectoryTree(*ClaudeDir);
|
|
}
|
|
|
|
const FString Content =
|
|
TEXT("{\n")
|
|
TEXT(" \"permissions\": {\n")
|
|
TEXT(" \"allow\": [\n")
|
|
TEXT(" \"mcp__unrealMCP__get_actors_in_level\",\n")
|
|
TEXT(" \"mcp__unrealMCP__find_actors_by_name\",\n")
|
|
TEXT(" \"mcp__unrealMCP__spawn_actor\",\n")
|
|
TEXT(" \"mcp__unrealMCP__delete_actor\",\n")
|
|
TEXT(" \"mcp__unrealMCP__set_actor_transform\",\n")
|
|
TEXT(" \"mcp__unrealMCP__get_actor_properties\",\n")
|
|
TEXT(" \"mcp__unrealMCP__set_actor_property\",\n")
|
|
TEXT(" \"mcp__unrealMCP__spawn_blueprint_actor\",\n")
|
|
TEXT(" \"mcp__unrealMCP__create_blueprint\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_component_to_blueprint\",\n")
|
|
TEXT(" \"mcp__unrealMCP__set_static_mesh_properties\",\n")
|
|
TEXT(" \"mcp__unrealMCP__set_component_property\",\n")
|
|
TEXT(" \"mcp__unrealMCP__set_physics_properties\",\n")
|
|
TEXT(" \"mcp__unrealMCP__set_blueprint_property\",\n")
|
|
TEXT(" \"mcp__unrealMCP__compile_blueprint\",\n")
|
|
TEXT(" \"mcp__unrealMCP__find_blueprint_nodes\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_blueprint_event_node\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_blueprint_input_action_node\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_blueprint_function_node\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_blueprint_variable\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_blueprint_self_reference\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_blueprint_get_self_component_reference\",\n")
|
|
TEXT(" \"mcp__unrealMCP__connect_blueprint_nodes\",\n")
|
|
TEXT(" \"mcp__unrealMCP__create_input_mapping\",\n")
|
|
TEXT(" \"mcp__unrealMCP__create_umg_widget_blueprint\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_text_block_to_widget\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_button_to_widget\",\n")
|
|
TEXT(" \"mcp__unrealMCP__bind_widget_event\",\n")
|
|
TEXT(" \"mcp__unrealMCP__set_text_block_binding\",\n")
|
|
TEXT(" \"mcp__unrealMCP__add_widget_to_viewport\"\n")
|
|
TEXT(" ]\n")
|
|
TEXT(" }\n")
|
|
TEXT("}\n");
|
|
|
|
if (FFileHelper::SaveStringToFile(Content, *FilePath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM))
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[settings.local.json] Written successfully"));
|
|
Step.bSuccess = true;
|
|
Step.Details = FString::Printf(TEXT("Créé : %s"), *FilePath);
|
|
}
|
|
else
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("[settings.local.json] Failed to write %s"), *FilePath);
|
|
Step.bSuccess = false;
|
|
Step.Details = FString::Printf(TEXT("Impossible d'écrire %s"), *FilePath);
|
|
}
|
|
return Step;
|
|
}
|
|
|
|
FInitStep FClaudeCodeInitializer::RunUvSync(const FString& ProjectDir)
|
|
{
|
|
FInitStep Step;
|
|
Step.Name = LOCTEXT("StepNameUv", "Environnement Python (uv sync)");
|
|
|
|
const FString McpServerDir = ProjectDir / TEXT(".claude/mcp-server");
|
|
// --no-install-project: install dependencies only, skip building the local package.
|
|
// Building it spawns a grandchild Python process that inherits invalid pipe handles
|
|
// from FPlatformProcess::ExecProcess on Windows (os error 6 / ERROR_INVALID_HANDLE).
|
|
// unreal_mcp_server.py is a plain script — it doesn't need to be installed as a package.
|
|
const FString Args = FString::Printf(TEXT("sync --directory \"%s\" --no-install-project"), *McpServerDir);
|
|
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[uv sync] Running: uv %s"), *Args);
|
|
|
|
int32 ReturnCode = 0;
|
|
FString StdOut, StdErr;
|
|
const bool bLaunched = FPlatformProcess::ExecProcess(
|
|
TEXT("uv"), *Args, &ReturnCode, &StdOut, &StdErr);
|
|
|
|
if (!bLaunched)
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("[uv sync] 'uv' not found in PATH"));
|
|
Step.bSuccess = false;
|
|
Step.Details = TEXT("uv introuvable dans le PATH. Installez uv (https://docs.astral.sh/uv/) puis relancez.");
|
|
}
|
|
else if (ReturnCode == 0)
|
|
{
|
|
UE_LOG(LogUnrealMCPSetup, Display, TEXT("[uv sync] Completed successfully"));
|
|
Step.bSuccess = true;
|
|
Step.Details = TEXT("Environnement Python prêt.");
|
|
}
|
|
else
|
|
{
|
|
const FString ErrorMsg = StdErr.IsEmpty() ? StdOut : StdErr;
|
|
UE_LOG(LogUnrealMCPSetup, Error, TEXT("[uv sync] Failed (code %d): %s"), ReturnCode, *ErrorMsg);
|
|
Step.bSuccess = false;
|
|
Step.Details = ErrorMsg;
|
|
}
|
|
return Step;
|
|
}
|
|
|
|
#undef LOCTEXT_NAMESPACE
|