LifePlay-Revival/Source/LifePlay/LPCityMapsDownload.cpp
2026-10-22 21:10:39 +02:00

1417 lines
42 KiB
C++

// Lot MAPDL1 (MAPDL1-SPEC-05 v3, etape E5) - voir LPCityMapsDownload.h
#include "LPCityMapsDownload.h"
#include "Async/Async.h"
#include "HAL/FileManager.h"
#include "HAL/PlatformMisc.h"
#include "HAL/PlatformProcess.h"
#include "HAL/PlatformTime.h"
#include "HttpModule.h"
#include "Interfaces/IHttpResponse.h"
#include "LifePlay.h"
#include "Misc/DateTime.h"
#include "Misc/Paths.h"
// OpenSSL (EVP, SHA-256) et libzip : avant les en-tetes Windows (OpenSSL declare X509_NAME, que wincrypt.h redefinit en macro).
// UI : le typedef d'OpenSSL heurte l'espace de noms UI du moteur (C2365, E2 bis) ; meme parade que BuildPatchServices/Crypto.cpp.
#define UI UI_ST
THIRD_PARTY_INCLUDES_START
#include "openssl/evp.h"
#include "libzip/zip.h"
THIRD_PARTY_INCLUDES_END
#undef UI
#if PLATFORM_WINDOWS
#include "Windows/WindowsHWrapper.h" // windows.h puis PostWindowsApi.h avant ShlObj.h (comme LPSaveRelaunch.cpp)
#include "Windows/AllowWindowsPlatformTypes.h"
#include <shlobj.h>
// commdlg.h definit des macros sans suffixe (FindText, ReplaceText, ...) qui fuiraient dans les autres fichiers du meme bloc unity.
#pragma push_macro("FindText")
#pragma push_macro("ReplaceText")
#pragma push_macro("ChooseColor")
#pragma push_macro("ChooseFont")
#pragma push_macro("PrintDlg")
#pragma push_macro("PageSetupDlg")
#pragma push_macro("GetOpenFileName")
#pragma push_macro("GetSaveFileName")
#pragma push_macro("GetFileTitle")
#include <commdlg.h>
#pragma pop_macro("GetFileTitle")
#pragma pop_macro("GetSaveFileName")
#pragma pop_macro("GetOpenFileName")
#pragma pop_macro("PageSetupDlg")
#pragma pop_macro("PrintDlg")
#pragma pop_macro("ChooseFont")
#pragma pop_macro("ChooseColor")
#pragma pop_macro("ReplaceText")
#pragma pop_macro("FindText")
#include "Windows/HideWindowsPlatformTypes.h"
#endif
namespace LPCityMapsDownloadImpl
{
const TCHAR* const UserAgent = TEXT("LifePlay-CityMaps/1.0");
constexpr int32 MaxAttempts = 3;
// Attente apres le 1er, le 2e, le 3e echec d'une URL (la 3e precede l'URL suivante).
constexpr float RetryDelays[MaxAttempts] = { 2.f, 4.f, 8.f };
constexpr float ActivityTimeoutSeconds = 30.f;
constexpr float WatchIntervalSeconds = 2.f;
constexpr int32 BlockBytes = 1024 * 1024;
constexpr double SpeedWindowSeconds = 0.5;
constexpr uint64 MaxExtractedBytes = 16ULL * 1024ULL * 1024ULL * 1024ULL;
constexpr uint64 FreeSpaceSlackBytes = 64ULL * 1024ULL * 1024ULL;
FString Stamp()
{
return FDateTime::Now().ToString(TEXT("%Y%m%d-%H%M%S"));
}
bool IsDigits(const FString& Text)
{
if (Text.IsEmpty())
{
return false;
}
for (const TCHAR C : Text)
{
if (!FChar::IsDigit(C))
{
return false;
}
}
return true;
}
/** Un nom de pack ou de fichier de l'index : lettres, chiffres, "_", "-", "." ; ni vide, ni commencant par un point. */
bool IsSafeSegment(const FString& Text)
{
if (Text.IsEmpty() || Text.StartsWith(TEXT(".")))
{
return false;
}
for (const TCHAR C : Text)
{
if (!(FChar::IsAlnum(C) || C == TEXT('_') || C == TEXT('-') || C == TEXT('.')))
{
return false;
}
}
return true;
}
FString NativePath(const FString& Path)
{
return FPaths::ConvertRelativePathToFull(Path).Replace(TEXT("/"), TEXT("\\"));
}
/** Deplacement sans ecrasement : Replace = false (avec Replace, le moteur SUPPRIME la destination : FileManagerGeneric.cpp:303)
* et bDoNotRetryOrError = true (pas de reprises de 5 s : FileManagerGeneric.cpp:325). Le dossier de destination est cree (l. 301). */
bool MoveFileSafe(const FString& Source, const FString& Destination)
{
return IFileManager::Get().Move(*Destination, *Source, false, false, false, true);
}
/** "bytes 1024-2047/4096" -> 1024. */
bool ParseContentRangeStart(const FString& Header, int64& OutStart)
{
FString Text = Header.TrimStartAndEnd();
if (!Text.StartsWith(TEXT("bytes "), ESearchCase::IgnoreCase))
{
return false;
}
Text.RightChopInline(6);
Text.TrimStartInline();
int32 Dash = INDEX_NONE;
if (!Text.FindChar(TEXT('-'), Dash) || Dash <= 0)
{
return false;
}
const FString StartText = Text.Left(Dash);
if (!IsDigits(StartText))
{
return false;
}
OutStart = FCString::Atoi64(*StartText);
return true;
}
/** SHA-256 du fichier, par blocs de 1 Mio (OpenSSL EVP). Appelee hors du fil de jeu. */
bool ComputeSha256(const FString& Path, FString& OutHex, const FThreadSafeBool& Cancel, FString& OutError)
{
OutHex.Reset();
TUniquePtr<FArchive> Reader(IFileManager::Get().CreateFileReader(*Path, FILEREAD_Silent));
if (!Reader)
{
OutError = TEXT("the file cannot be opened");
return false;
}
EVP_MD_CTX* Context = EVP_MD_CTX_new();
if (!Context)
{
OutError = TEXT("SHA-256 context could not be created");
return false;
}
bool bOk = EVP_DigestInit_ex(Context, EVP_sha256(), nullptr) == 1;
TArray<uint8> Buffer;
Buffer.SetNumUninitialized(BlockBytes);
int64 Remaining = Reader->TotalSize();
while (bOk && Remaining > 0)
{
if (Cancel)
{
OutError = TEXT("cancelled");
bOk = false;
break;
}
const int64 Chunk = FMath::Min<int64>(Remaining, BlockBytes);
Reader->Serialize(Buffer.GetData(), Chunk);
if (Reader->IsError())
{
OutError = TEXT("the file cannot be read");
bOk = false;
break;
}
bOk = EVP_DigestUpdate(Context, Buffer.GetData(), static_cast<size_t>(Chunk)) == 1;
Remaining -= Chunk;
}
uint8 Digest[EVP_MAX_MD_SIZE];
unsigned int DigestLength = 0;
if (bOk)
{
bOk = EVP_DigestFinal_ex(Context, Digest, &DigestLength) == 1 && DigestLength == 32;
}
EVP_MD_CTX_free(Context);
if (!bOk)
{
if (OutError.IsEmpty())
{
OutError = TEXT("SHA-256 calculation failed");
}
return false;
}
OutHex = BytesToHexLower(Digest, static_cast<int32>(DigestLength));
return true;
}
enum class EMemberKind : uint8
{
Reject,
/** README.txt de la racine : ni extrait, ni pose. */
Ignore,
Directory,
File
};
/** Regles de la fiche (section 2.4) : chaque nom commence par LifePlay/Mods/<pack>/ ou LifePlay/Content/Modules/<pack>/, ou vaut
* README.txt ; aucun "..", aucun "\", aucun chemin absolu, aucun autre prefixe. Les entrees de dossier qui sont les parents de
* ces deux racines (LifePlay/, LifePlay/Mods/, LifePlay/Content/, LifePlay/Content/Modules/) sont admises : elles ne creent rien. */
EMemberKind ClassifyMember(const FString& Name, const FString& Pack, FString& OutReason)
{
if (Name.IsEmpty())
{
OutReason = TEXT("empty member name");
return EMemberKind::Reject;
}
for (const TCHAR C : Name)
{
if (C < 32 || C == TEXT('\\') || C == TEXT(':'))
{
OutReason = FString::Printf(TEXT("forbidden character in '%s'"), *Name);
return EMemberKind::Reject;
}
}
if (Name.StartsWith(TEXT("/")))
{
OutReason = FString::Printf(TEXT("absolute path '%s'"), *Name);
return EMemberKind::Reject;
}
const bool bDirectory = Name.EndsWith(TEXT("/"));
TArray<FString> Segments;
Name.ParseIntoArray(Segments, TEXT("/"), false);
for (int32 Index = 0; Index < Segments.Num(); ++Index)
{
const FString& Segment = Segments[Index];
const bool bLast = Index == Segments.Num() - 1;
// Point ou espace final refuse (couvre ".", "..", "..." : Windows retire points et espaces de fin d'un segment ; reserve R-1, E3 bis).
if (Segment.EndsWith(TEXT(".")) || Segment.EndsWith(TEXT(" ")) || (Segment.IsEmpty() && !(bLast && bDirectory)))
{
OutReason = FString::Printf(TEXT("unsafe path '%s'"), *Name);
return EMemberKind::Reject;
}
}
if (!bDirectory && Name.Equals(TEXT("README.txt"), ESearchCase::CaseSensitive))
{
return EMemberKind::Ignore;
}
const FString ModsRoot = FString::Printf(TEXT("LifePlay/Mods/%s/"), *Pack);
const FString ModulesRoot = FString::Printf(TEXT("LifePlay/Content/Modules/%s/"), *Pack);
if (Name.StartsWith(ModsRoot, ESearchCase::CaseSensitive) || Name.StartsWith(ModulesRoot, ESearchCase::CaseSensitive))
{
return bDirectory ? EMemberKind::Directory : EMemberKind::File;
}
if (bDirectory)
{
static const TCHAR* const Ancestors[] = { TEXT("LifePlay/"), TEXT("LifePlay/Mods/"), TEXT("LifePlay/Content/"), TEXT("LifePlay/Content/Modules/") };
for (const TCHAR* Ancestor : Ancestors)
{
if (Name.Equals(Ancestor, ESearchCase::CaseSensitive))
{
return EMemberKind::Directory;
}
}
}
OutReason = FString::Printf(TEXT("unexpected member '%s'"), *Name);
return EMemberKind::Reject;
}
/** Ouvre le zip en lecture seule. Le chemin passe en UTF-16 (zip_source_win32w_create) : un nom de compte avec des accents
* ne casse pas l'ouverture. Le zip_t renvoye est ferme par zip_close. */
zip_t* OpenZip(const FString& Path, FString& OutError)
{
#if PLATFORM_WINDOWS
zip_error_t ZipError;
zip_error_init(&ZipError);
zip_source_t* Source = zip_source_win32w_create(TCHAR_TO_WCHAR(*Path), 0, -1, &ZipError);
if (!Source)
{
OutError = FString::Printf(TEXT("the archive cannot be opened (%hs)"), zip_error_strerror(&ZipError));
zip_error_fini(&ZipError);
return nullptr;
}
zip_t* Archive = zip_open_from_source(Source, ZIP_RDONLY, &ZipError);
if (!Archive)
{
// Echec de zip_open_from_source : la source n'est pas reprise par libzip, il faut la liberer.
OutError = FString::Printf(TEXT("the archive is not a valid zip file (%hs)"), zip_error_strerror(&ZipError));
zip_source_free(Source);
zip_error_fini(&ZipError);
return nullptr;
}
zip_error_fini(&ZipError);
return Archive;
#else
OutError = TEXT("zip archives are only supported on Windows");
return nullptr;
#endif
}
struct FMoveRecord
{
FString From;
FString To;
};
struct FInstallResult
{
bool bOk = false;
int32 FilesPlaced = 0;
FString Error;
bool bRollbackOk = true;
};
/** Defait les deplacements deja faits, dans l'ordre inverse. */
bool RollBack(const TArray<FMoveRecord>& Done)
{
bool bAll = true;
for (int32 Index = Done.Num() - 1; Index >= 0; --Index)
{
if (!MoveFileSafe(Done[Index].To, Done[Index].From))
{
bAll = false;
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] INSTALL retour arriere : %s non remis a sa place (%s)"), *Done[Index].To, *Done[Index].From);
}
}
return bAll;
}
/** Fichiers sous Dir, en chemins relatifs. Faux si un chemin trouve ne commence pas par Dir. Dossier absent : liste vide, vrai. */
bool ListFilesRelative(const FString& Dir, TArray<FString>& OutRelative)
{
OutRelative.Reset();
IFileManager& FileManager = IFileManager::Get();
if (!FileManager.DirectoryExists(*Dir))
{
return true;
}
TArray<FString> Found;
FileManager.FindFilesRecursive(Found, *Dir, TEXT("*"), true, false);
FString Prefix = Dir;
FPaths::NormalizeFilename(Prefix);
while (Prefix.EndsWith(TEXT("/")))
{
Prefix.LeftChopInline(1);
}
Prefix += TEXT("/");
for (FString File : Found)
{
FPaths::NormalizeFilename(File);
if (!File.StartsWith(Prefix, ESearchCase::IgnoreCase))
{
return false;
}
OutRelative.Add(File.RightChop(Prefix.Len()));
}
return true;
}
/** Controle du zip, extraction dans <Downloads>/<pack>/_pose/, puis pose. Appelee hors du fil de jeu. Rien n'est supprime. */
FInstallResult InstallPack(const FString& ZipPath, const FString& Pack, const FString& DownloadsDir)
{
FInstallResult Result;
IFileManager& FileManager = IFileManager::Get();
FString OpenError;
zip_t* Archive = OpenZip(ZipPath, OpenError);
if (!Archive)
{
Result.Error = OpenError;
return Result;
}
// 1. Controle de tous les membres : rien n'est extrait si un seul est refuse.
struct FMember
{
zip_uint64_t Index = 0;
FString Name;
uint64 Size = 0;
};
TArray<FMember> Files;
uint64 TotalBytes = 0;
bool bHasUplugin = false;
const FString UpluginName = FString::Printf(TEXT("LifePlay/Mods/%s/%s.uplugin"), *Pack, *Pack);
const zip_int64_t Count = zip_get_num_entries(Archive, 0);
if (Count <= 0)
{
Result.Error = TEXT("the archive is empty");
zip_close(Archive);
return Result;
}
for (zip_int64_t Index = 0; Index < Count; ++Index)
{
zip_stat_t Stat;
zip_stat_init(&Stat);
if (zip_stat_index(Archive, static_cast<zip_uint64_t>(Index), 0, &Stat) != 0 || !(Stat.valid & ZIP_STAT_NAME) || !Stat.name)
{
Result.Error = TEXT("the archive directory cannot be read");
zip_close(Archive);
return Result;
}
const FString Name(UTF8_TO_TCHAR(Stat.name));
FString Why;
switch (ClassifyMember(Name, Pack, Why))
{
case EMemberKind::Reject:
Result.Error = FString::Printf(TEXT("archive refused: %s"), *Why);
zip_close(Archive);
return Result;
case EMemberKind::File:
{
if (!(Stat.valid & ZIP_STAT_SIZE))
{
Result.Error = FString::Printf(TEXT("archive refused: no size for '%s'"), *Name);
zip_close(Archive);
return Result;
}
FMember Member;
Member.Index = static_cast<zip_uint64_t>(Index);
Member.Name = Name;
Member.Size = Stat.size;
TotalBytes += Stat.size;
Files.Add(MoveTemp(Member));
if (Name.Equals(UpluginName, ESearchCase::CaseSensitive))
{
bHasUplugin = true;
}
break;
}
default:
break;
}
}
if (!bHasUplugin)
{
Result.Error = FString::Printf(TEXT("archive refused: %s is missing"), *UpluginName);
zip_close(Archive);
return Result;
}
if (TotalBytes > MaxExtractedBytes)
{
Result.Error = TEXT("archive refused: unreasonable extracted size");
zip_close(Archive);
return Result;
}
// 2. Place disque : Saved (extraction) et dossier du jeu (pose).
const FString Root = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir());
TArray<FString> Volumes;
Volumes.Add(DownloadsDir);
Volumes.Add(Root);
for (const FString& Volume : Volumes)
{
uint64 TotalSpace = 0;
uint64 FreeSpace = 0;
if (FPlatformMisc::GetDiskTotalAndFreeSpace(Volume, TotalSpace, FreeSpace) && FreeSpace < TotalBytes + FreeSpaceSlackBytes)
{
Result.Error = FString::Printf(TEXT("not enough free disk space on %s"), *NativePath(Volume));
zip_close(Archive);
return Result;
}
}
// 3. Extraction dans un dossier neuf (un _pose existant reste : trace d'un essai precedent).
FString PoseDir = DownloadsDir / Pack / TEXT("_pose");
for (int32 Suffix = 2; FileManager.DirectoryExists(*PoseDir); ++Suffix)
{
PoseDir = DownloadsDir / Pack / FString::Printf(TEXT("_pose-%s-%d"), *Stamp(), Suffix);
}
FileManager.MakeDirectory(*PoseDir, true);
TArray<uint8> Buffer;
Buffer.SetNumUninitialized(BlockBytes);
for (const FMember& Member : Files)
{
const FString Destination = PoseDir / Member.Name;
zip_file_t* In = zip_fopen_index(Archive, Member.Index, 0);
if (!In)
{
Result.Error = FString::Printf(TEXT("cannot read '%s' in the archive"), *Member.Name);
zip_close(Archive);
return Result;
}
TUniquePtr<FArchive> Out(FileManager.CreateFileWriter(*Destination, 0));
if (!Out)
{
zip_fclose(In);
Result.Error = FString::Printf(TEXT("cannot create %s"), *NativePath(Destination));
zip_close(Archive);
return Result;
}
uint64 Written = 0;
bool bFailed = false;
for (;;)
{
const zip_int64_t Got = zip_fread(In, Buffer.GetData(), static_cast<zip_uint64_t>(Buffer.Num()));
if (Got < 0)
{
bFailed = true;
break;
}
if (Got == 0)
{
break;
}
Out->Serialize(Buffer.GetData(), Got);
if (Out->IsError())
{
bFailed = true;
break;
}
Written += static_cast<uint64>(Got);
}
zip_fclose(In);
const bool bClosed = Out->Close();
Out.Reset();
if (bFailed || !bClosed || Written != Member.Size)
{
Result.Error = FString::Printf(TEXT("extraction of '%s' failed (disk full?)"), *Member.Name);
zip_close(Archive);
return Result;
}
}
zip_close(Archive);
// 4. Pose : l'ancienne installation d'abord mise de cote, puis Content/Modules/<pack>, enfin Mods/<pack>.
TArray<FString> Trees;
Trees.Add(FString::Printf(TEXT("Content/Modules/%s"), *Pack));
Trees.Add(FString::Printf(TEXT("Mods/%s"), *Pack));
const FString OldDir = DownloadsDir / TEXT("_ancien") / FString::Printf(TEXT("%s-%s"), *Pack, *Stamp());
TArray<FMoveRecord> Done;
FString Failure;
for (const FString& Tree : Trees)
{
const FString Existing = Root / Tree;
TArray<FString> Relative;
if (!ListFilesRelative(Existing, Relative))
{
Failure = FString::Printf(TEXT("cannot list the previous installation %s"), *NativePath(Existing));
break;
}
for (const FString& Rel : Relative)
{
const FString From = Existing / Rel;
const FString To = OldDir / Tree / Rel;
if (!MoveFileSafe(From, To))
{
Failure = FString::Printf(TEXT("cannot move the previous file away: %s"), *NativePath(From));
break;
}
Done.Add(FMoveRecord{ From, To });
}
if (!Failure.IsEmpty())
{
break;
}
}
int32 Placed = 0;
if (Failure.IsEmpty())
{
for (const FString& Tree : Trees)
{
const FString Source = PoseDir / TEXT("LifePlay") / Tree;
TArray<FString> Relative;
if (!ListFilesRelative(Source, Relative))
{
Failure = FString::Printf(TEXT("cannot list %s"), *NativePath(Source));
break;
}
for (const FString& Rel : Relative)
{
const FString From = Source / Rel;
const FString To = Root / Tree / Rel;
if (!MoveFileSafe(From, To))
{
Failure = FString::Printf(TEXT("cannot place %s"), *NativePath(To));
break;
}
Done.Add(FMoveRecord{ From, To });
++Placed;
}
if (!Failure.IsEmpty())
{
break;
}
}
}
if (!Failure.IsEmpty())
{
Result.Error = Failure;
Result.bRollbackOk = RollBack(Done);
return Result;
}
Result.bOk = true;
Result.FilesPlaced = Placed;
return Result;
}
}
FLPCityMapsDownload::FLPCityMapsDownload(const FLPCityDownloadJob& InJob)
: Job(InJob)
, CancelFlag(MakeShared<FThreadSafeBool, ESPMode::ThreadSafe>(false))
{
using namespace LPCityMapsDownloadImpl;
State.BytesTotal = Job.Octets;
bJobValid = IsSafeSegment(Job.Pack) && IsSafeSegment(Job.Zip) && Job.Sha256.Len() == 64 && Job.Octets > 0 && Job.Urls.Num() > 0;
}
FLPCityMapsDownload::~FLPCityMapsDownload()
{
OnChanged = nullptr;
StopWatchingDownloads();
if (RetryHandle.IsValid())
{
FTSTicker::GetCoreTicker().RemoveTicker(RetryHandle);
RetryHandle.Reset();
}
*CancelFlag = true;
// Le .part est garde pour une reprise.
AbortRequestQuietly();
CloseWriter();
}
FString FLPCityMapsDownload::GetDownloadsDir()
{
return FPaths::ConvertRelativePathToFull(FPaths::ProjectSavedDir()) / TEXT("Downloads");
}
FString FLPCityMapsDownload::GetPartPath() const
{
// A cote du zip, sans sous-dossier par pack : un echec sans octet ne laisse pas de dossier vide (essai COM31, cas B9).
return GetDownloadsDir() / (Job.Zip + TEXT(".part"));
}
FString FLPCityMapsDownload::GetFinalPath() const
{
return GetDownloadsDir() / Job.Zip;
}
FString FLPCityMapsDownload::HostOf(const FString& Url)
{
FString Rest = Url;
const int32 SchemeEnd = Url.Find(TEXT("://"));
if (SchemeEnd != INDEX_NONE)
{
Rest = Url.Mid(SchemeEnd + 3);
}
int32 End = Rest.Len();
for (int32 Index = 0; Index < Rest.Len(); ++Index)
{
const TCHAR C = Rest[Index];
if (C == TEXT('/') || C == TEXT('?') || C == TEXT('#'))
{
End = Index;
break;
}
}
FString Authority = Rest.Left(End);
int32 At = INDEX_NONE;
if (Authority.FindLastChar(TEXT('@'), At))
{
Authority = Authority.Mid(At + 1);
}
int32 Colon = INDEX_NONE;
if (Authority.FindChar(TEXT(':'), Colon))
{
Authority = Authority.Left(Colon);
}
return Authority;
}
bool FLPCityMapsDownload::FindSystemDownloadsFolder(FString& OutFolder)
{
#if PLATFORM_WINDOWS
// Meme appel que le moteur (WindowsPlatformProcess.cpp:1482-1488).
TCHAR* FolderPath = nullptr;
bool bOk = false;
if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Downloads, 0, nullptr, &FolderPath)) && FolderPath)
{
OutFolder = FString(FolderPath);
FPaths::NormalizeFilename(OutFolder);
bOk = !OutFolder.IsEmpty();
}
if (FolderPath)
{
CoTaskMemFree(FolderPath);
}
return bOk;
#else
(void)OutFolder;
return false;
#endif
}
bool FLPCityMapsDownload::PickZipFile(FString& OutPath)
{
#if PLATFORM_WINDOWS
wchar_t FileBuffer[2048] = { 0 };
OPENFILENAMEW Dialog;
FMemory::Memzero(&Dialog, sizeof(Dialog));
Dialog.lStructSize = sizeof(Dialog);
Dialog.hwndOwner = GetActiveWindow();
Dialog.lpstrFilter = L"Zip archives (*.zip)\0*.zip\0All files (*.*)\0*.*\0";
Dialog.nFilterIndex = 1;
Dialog.lpstrFile = FileBuffer;
Dialog.nMaxFile = UE_ARRAY_COUNT(FileBuffer);
Dialog.lpstrTitle = L"Choose the downloaded map archive";
// OFN_NOCHANGEDIR : la boite ne doit pas changer le dossier courant du jeu.
Dialog.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | OFN_HIDEREADONLY;
if (!GetOpenFileNameW(&Dialog))
{
return false;
}
OutPath = FString(FileBuffer);
return !OutPath.IsEmpty();
#else
(void)OutPath;
return false;
#endif
}
bool FLPCityMapsDownload::IsBusy() const
{
switch (State.Phase)
{
case ELPCityJobPhase::Downloading:
case ELPCityJobPhase::WaitingRetry:
case ELPCityJobPhase::Verifying:
case ELPCityJobPhase::Installing:
return true;
default:
return false;
}
}
void FLPCityMapsDownload::Notify()
{
const TFunction<void()> Callback = OnChanged;
if (Callback)
{
Callback();
}
}
void FLPCityMapsDownload::SetMessage(const FString& Text, bool bIsError)
{
State.Message = Text;
State.bError = bIsError;
}
void FLPCityMapsDownload::Start(ELPCityInstallMode InMode)
{
using namespace LPCityMapsDownloadImpl;
if (IsBusy())
{
return;
}
State = FLPCityDownloadState();
State.Mode = InMode;
State.BytesTotal = Job.Octets;
bCancelRequested = false;
CancelFlag = MakeShared<FThreadSafeBool, ESPMode::ThreadSafe>(false);
LastFailure.Reset();
StopWatchingDownloads();
if (!bJobValid)
{
SetMessage(TEXT("Download failed: invalid entry in the city index."), true);
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] DOWNLOAD %s : echec entree d'index invalide"), *Job.Ville);
Notify();
return;
}
// Refus dans l'editeur : la copie de travail ne doit jamais recevoir de pack dans Mods\ (refus aussi a la pose).
if (InMode == ELPCityInstallMode::FullInstall && GIsEditor)
{
SetMessage(TEXT("Full install is only available in the packaged game."), true);
Notify();
return;
}
// Un zip deja present, de la bonne taille, sert directement apres un nouveau controle de son SHA-256.
IFileManager& FileManager = IFileManager::Get();
const FString Final = GetFinalPath();
const int64 FinalSize = FileManager.FileSize(*Final);
if (FinalSize >= 0)
{
if (FinalSize == Job.Octets)
{
BeginVerify(Final, EVerifySource::Cache);
return;
}
RejectFile(Final, TEXT("unexpected size"));
}
StartAttempt();
}
void FLPCityMapsDownload::StartAttempt()
{
using namespace LPCityMapsDownloadImpl;
if (!Job.Urls.IsValidIndex(State.UrlIndex))
{
EnterBrowserRecourse();
return;
}
IFileManager& FileManager = IFileManager::Get();
const FString Url = Job.Urls[State.UrlIndex];
const FString Part = GetPartPath();
// Surete : HTTPS seulement (l'index ecarte deja les autres liens, controle repete ici).
if (!Url.StartsWith(TEXT("https://"), ESearchCase::IgnoreCase))
{
FailAttempt(TEXT("only HTTPS links are accepted"));
return;
}
int64 Existing = FileManager.FileSize(*Part);
if (Existing < 0)
{
Existing = 0;
}
if (Existing > Job.Octets)
{
if (RejectFile(Part, TEXT("larger than expected")).IsEmpty())
{
FailAttempt(TEXT("the oversized partial file could not be set aside"));
return;
}
Existing = 0;
}
if (Existing == Job.Octets)
{
// Telechargement deja complet (session precedente interrompue avant le controle).
BeginVerify(Part, EVerifySource::Part);
return;
}
ResumeFrom = Existing;
FArchive* RawWriter = FileManager.CreateFileWriter(*Part, FILEWRITE_Append | FILEWRITE_AllowRead);
if (!RawWriter)
{
FailAttempt(TEXT("the partial file cannot be written"));
return;
}
PartWriter = MakeShareable(RawWriter);
State.Phase = ELPCityJobPhase::Downloading;
State.BytesDone = ResumeFrom;
State.BytesPerSecond = 0.0;
SpeedTime = FPlatformTime::Seconds();
SpeedBytes = ResumeFrom;
SetMessage(FString::Printf(TEXT("Downloading from %s..."), *HostOf(Url)), false);
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : %s demarrage (reprise a %lld)"), *Job.Ville, *Url, ResumeFrom);
Request = FHttpModule::Get().CreateRequest();
Request->SetURL(Url);
Request->SetVerb(TEXT("GET"));
Request->SetHeader(TEXT("User-Agent"), UserAgent);
if (ResumeFrom > 0)
{
Request->SetHeader(TEXT("Range"), FString::Printf(TEXT("bytes=%lld-"), ResumeFrom));
}
// Delai sans donnees : 30 s ; aucun delai total (HttpTotalTimeout vaut 0 par defaut : HttpModule.cpp:96).
Request->SetActivityTimeout(ActivityTimeoutSeconds);
if (!Request->SetResponseBodyReceiveStream(PartWriter.ToSharedRef()))
{
AbortRequestQuietly();
CloseWriter();
FailAttempt(TEXT("the response stream could not be set"));
return;
}
Request->OnProcessRequestComplete().BindSP(this, &FLPCityMapsDownload::HandleRequestComplete);
Request->OnRequestProgress64().BindSP(this, &FLPCityMapsDownload::HandleRequestProgress);
if (!Request->ProcessRequest())
{
AbortRequestQuietly();
CloseWriter();
FailAttempt(TEXT("the request could not be started"));
return;
}
Notify();
}
void FLPCityMapsDownload::HandleRequestProgress(FHttpRequestPtr ProgressRequest, uint64 BytesSent, uint64 BytesReceived)
{
using namespace LPCityMapsDownloadImpl;
(void)BytesSent;
if (!Request.IsValid() || ProgressRequest != Request)
{
return;
}
State.BytesDone = FMath::Min<int64>(ResumeFrom + static_cast<int64>(BytesReceived), FMath::Max<int64>(Job.Octets, 0));
const double Now = FPlatformTime::Seconds();
if (Now - SpeedTime >= SpeedWindowSeconds)
{
const double Rate = static_cast<double>(State.BytesDone - SpeedBytes) / (Now - SpeedTime);
State.BytesPerSecond = State.BytesPerSecond > 0.0 ? (0.6 * State.BytesPerSecond + 0.4 * Rate) : Rate;
SpeedTime = Now;
SpeedBytes = State.BytesDone;
}
Notify();
}
void FLPCityMapsDownload::HandleRequestComplete(FHttpRequestPtr CompletedRequest, FHttpResponsePtr Response, bool bSucceeded)
{
using namespace LPCityMapsDownloadImpl;
if (!Request.IsValid() || CompletedRequest != Request)
{
return;
}
Request.Reset();
// La demande est finie : plus aucune ecriture dans le flux.
CloseWriter();
IFileManager& FileManager = IFileManager::Get();
const FString Part = GetPartPath();
const FString Url = CompletedRequest->GetURL();
const bool bGotResponse = Response.IsValid() && Response->GetResponseCode() > 0;
const int32 Code = bGotResponse ? Response->GetResponseCode() : 0;
const int64 PartSize = FMath::Max<int64>(FileManager.FileSize(*Part), 0);
FString Redirect;
if (bGotResponse)
{
const FString& Effective = Response->GetEffectiveURL();
if (!Effective.IsEmpty() && !Effective.Equals(Url, ESearchCase::CaseSensitive))
{
Redirect = FString::Printf(TEXT(" apres redirection %s"), *Effective);
}
}
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : %s statut %d octets %lld/%lld (reprise a %lld)%s"), *Job.Ville, *Url, Code, PartSize, Job.Octets, ResumeFrom, *Redirect);
// Statut autre que 200 et 206 : le corps (page d'erreur) a pu etre ajoute au .part ; s'il a grossi, il est mis de cote.
if (bGotResponse && Code != 200 && Code != 206)
{
if (PartSize != ResumeFrom && RejectFile(Part, TEXT("error response body")).IsEmpty())
{
FailAttempt(FString::Printf(TEXT("HTTP %d (the partial file could not be set aside)"), Code));
return;
}
FailAttempt(FString::Printf(TEXT("HTTP %d"), Code));
return;
}
// Reponse 200 a une reprise : le serveur ignore Range, tout le contenu a ete ajoute a la suite de l'ancien .part.
// Le .part est deplace dans _rejet/ et le telechargement repart de zero dans un fichier neuf.
if (bGotResponse && ResumeFrom > 0 && Code == 200)
{
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : le serveur ignore Range (200), redemarrage a zero"), *Job.Ville);
if (RejectFile(Part, TEXT("server ignored Range")).IsEmpty())
{
FailAttempt(TEXT("the partial file could not be set aside"));
return;
}
StartAttempt();
return;
}
if (!bSucceeded || !bGotResponse)
{
// Coupure : le .part garde le debut valide, l'essai suivant le reprendra.
FailAttempt(FString::Printf(TEXT("connection error (%s)"), LexToString(CompletedRequest->GetFailureReason())));
return;
}
if (ResumeFrom > 0)
{
int64 RangeStart = -1;
if (!ParseContentRangeStart(Response->GetHeader(TEXT("Content-Range")), RangeStart) || RangeStart != ResumeFrom)
{
RejectFile(Part, TEXT("unexpected Content-Range"));
FailAttempt(TEXT("the server answered a resume with an unexpected Content-Range"));
return;
}
}
else if (Code == 206)
{
RejectFile(Part, TEXT("unexpected 206"));
FailAttempt(TEXT("the server answered 206 to a request without Range"));
return;
}
if (PartSize != Job.Octets)
{
if (PartSize > Job.Octets)
{
RejectFile(Part, TEXT("larger than expected"));
}
FailAttempt(FString::Printf(TEXT("size %lld instead of %lld bytes"), PartSize, Job.Octets));
return;
}
State.BytesDone = PartSize;
BeginVerify(Part, EVerifySource::Part);
}
void FLPCityMapsDownload::CloseWriter()
{
if (PartWriter.IsValid())
{
PartWriter->Close();
PartWriter.Reset();
}
}
void FLPCityMapsDownload::AbortRequestQuietly()
{
if (Request.IsValid())
{
Request->OnProcessRequestComplete().Unbind();
Request->OnRequestProgress64().Unbind();
// CancelRequest retire le flux de la demande sous verrou (HttpRequestCommon.cpp:404, 725-730) : plus d'ecriture ensuite.
Request->CancelRequest();
Request.Reset();
}
}
void FLPCityMapsDownload::FailAttempt(const FString& Reason)
{
using namespace LPCityMapsDownloadImpl;
LastFailure = Reason;
const FString Url = Job.Urls.IsValidIndex(State.UrlIndex) ? Job.Urls[State.UrlIndex] : FString();
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : %s : echec %s"), *Job.Ville, *Url, *Reason);
++State.Attempt;
if (State.Attempt < MaxAttempts)
{
ScheduleRetry(RetryDelays[State.Attempt - 1]);
return;
}
// Troisieme echec sur cette URL : attente puis URL suivante, s'il y en a une.
if (Job.Urls.IsValidIndex(State.UrlIndex + 1))
{
State.Attempt = 0;
++State.UrlIndex;
ScheduleRetry(RetryDelays[MaxAttempts - 1]);
return;
}
EnterBrowserRecourse();
}
void FLPCityMapsDownload::ScheduleRetry(float DelaySeconds)
{
State.Phase = ELPCityJobPhase::WaitingRetry;
SetMessage(FString::Printf(TEXT("Download failed: %s. Next try in %d s..."), *LastFailure, FMath::RoundToInt(DelaySeconds)), true);
if (RetryHandle.IsValid())
{
FTSTicker::GetCoreTicker().RemoveTicker(RetryHandle);
}
// FTSTicker : pas de blocage du fil de jeu.
RetryHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateSP(this, &FLPCityMapsDownload::OnRetryTick), DelaySeconds);
Notify();
}
bool FLPCityMapsDownload::OnRetryTick(float DeltaTime)
{
(void)DeltaTime;
RetryHandle.Reset();
if (State.Phase == ELPCityJobPhase::WaitingRetry)
{
StartAttempt();
}
// Faux : le ticker se retire (un seul tir).
return false;
}
void FLPCityMapsDownload::NextUrl(const FString& Reason)
{
LastFailure = Reason;
State.Attempt = 0;
++State.UrlIndex;
if (Job.Urls.IsValidIndex(State.UrlIndex))
{
StartAttempt();
return;
}
EnterBrowserRecourse();
}
void FLPCityMapsDownload::EnterBrowserRecourse()
{
State.Phase = ELPCityJobPhase::NeedsBrowser;
SetMessage(FString::Printf(TEXT("Download failed on all servers: %s"), LastFailure.IsEmpty() ? TEXT("no server answered") : *LastFailure), true);
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : echec sur tous les serveurs (%s), recours navigateur"), *Job.Ville, *LastFailure);
SetAsideEmptyPart();
StartWatchingDownloads();
Notify();
}
void FLPCityMapsDownload::Cancel()
{
switch (State.Phase)
{
case ELPCityJobPhase::Downloading:
// Le .part est garde. Plus d'ecriture apres CancelRequest, le flux peut etre ferme tout de suite.
AbortRequestQuietly();
CloseWriter();
State.Phase = ELPCityJobPhase::Idle;
SetMessage(SetAsideEmptyPart() ? TEXT("Download cancelled.") : TEXT("Download cancelled. The partial file is kept: the next try resumes it."), false);
break;
case ELPCityJobPhase::WaitingRetry:
if (RetryHandle.IsValid())
{
FTSTicker::GetCoreTicker().RemoveTicker(RetryHandle);
RetryHandle.Reset();
}
State.Phase = ELPCityJobPhase::Idle;
SetMessage(SetAsideEmptyPart() ? TEXT("Download cancelled.") : TEXT("Download cancelled. The partial file is kept: the next try resumes it."), false);
break;
case ELPCityJobPhase::Verifying:
// La fin arrive par OnVerified (le calcul regarde l'indicateur a chaque bloc de 1 Mio).
bCancelRequested = true;
*CancelFlag = true;
SetMessage(TEXT("Cancelling..."), false);
break;
default:
// Pendant la pose : pas d'annulation (la pose est transactionnelle).
return;
}
Notify();
}
void FLPCityMapsDownload::BeginVerify(const FString& Path, EVerifySource Source)
{
using namespace LPCityMapsDownloadImpl;
State.Phase = ELPCityJobPhase::Verifying;
SetMessage(TEXT("Checking the file (SHA-256)..."), false);
Notify();
TWeakPtr<FLPCityMapsDownload> Weak = AsShared();
const TSharedRef<FThreadSafeBool, ESPMode::ThreadSafe> CancelCopy = CancelFlag;
// Calcul hors du fil de jeu, retour sur le fil de jeu.
Async(EAsyncExecution::ThreadPool, [Weak, CancelCopy, Path, Source]()
{
FString Hex;
FString Error;
const bool bReadOk = LPCityMapsDownloadImpl::ComputeSha256(Path, Hex, CancelCopy.Get(), Error);
AsyncTask(ENamedThreads::GameThread, [Weak, Path, Source, bReadOk, Hex, Error]()
{
if (TSharedPtr<FLPCityMapsDownload> Self = Weak.Pin())
{
Self->OnVerified(Path, Source, bReadOk, Hex, Error);
}
});
});
}
void FLPCityMapsDownload::OnVerified(const FString& Path, EVerifySource Source, bool bReadOk, const FString& Hex, const FString& Error)
{
if (bCancelRequested)
{
bCancelRequested = false;
State.Phase = ELPCityJobPhase::Idle;
SetMessage(TEXT("Cancelled. The file is kept."), false);
Notify();
return;
}
const bool bMatch = bReadOk && Hex.Equals(Job.Sha256, ESearchCase::IgnoreCase);
UE_LOG(LogLPCity, Log, TEXT("[LPCity] SHA256 %s : %s %s"), *Job.Ville, bReadOk ? *Hex : TEXT("illisible"), bMatch ? TEXT("conforme") : TEXT("ECART"));
switch (Source)
{
case EVerifySource::Part:
if (bMatch)
{
FinalizePart();
}
else
{
// Un fichier corrompu n'est jamais reutilise : mis de cote, puis URL suivante.
RejectFile(Path, TEXT("SHA-256 mismatch"));
NextUrl(bReadOk ? FString(TEXT("SHA-256 mismatch")) : Error);
}
break;
case EVerifySource::Cache:
if (bMatch)
{
OnPackageReady();
}
else
{
RejectFile(Path, TEXT("SHA-256 mismatch"));
State.UrlIndex = 0;
State.Attempt = 0;
StartAttempt();
}
break;
case EVerifySource::Watched:
case EVerifySource::Local:
if (bMatch)
{
CopyExternalToFinal(Path);
}
else
{
// Le fichier du joueur reste ou il est ; on retourne au recours.
State.Phase = ELPCityJobPhase::NeedsBrowser;
if (Source == EVerifySource::Watched && !bReadOk)
{
// Fichier encore ouvert par le navigateur, peut-etre : il sera examine de nouveau.
ExaminedDownloads.Reset();
}
if (Source == EVerifySource::Local)
{
SetMessage(bReadOk ? FString(TEXT("SHA-256 mismatch: the selected file is not the expected archive.")) : FString::Printf(TEXT("The selected file cannot be checked: %s."), *Error), true);
}
Notify();
}
break;
}
}
void FLPCityMapsDownload::FinalizePart()
{
using namespace LPCityMapsDownloadImpl;
IFileManager& FileManager = IFileManager::Get();
const FString Part = GetPartPath();
const FString Final = GetFinalPath();
if (FileManager.FileExists(*Final))
{
RejectFile(Final, TEXT("replaced"));
}
if (!MoveFileSafe(Part, Final))
{
State.Phase = ELPCityJobPhase::Idle;
SetMessage(FString::Printf(TEXT("Download failed: the partial file could not be renamed to %s."), *NativePath(Final)), true);
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] DOWNLOAD %s : echec renommage du .part"), *Job.Ville);
Notify();
return;
}
OnPackageReady();
}
void FLPCityMapsDownload::CopyExternalToFinal(const FString& SourcePath)
{
using namespace LPCityMapsDownloadImpl;
IFileManager& FileManager = IFileManager::Get();
const FString Final = GetFinalPath();
FileManager.MakeDirectory(*FPaths::GetPath(Final), true);
if (FileManager.FileExists(*Final))
{
RejectFile(Final, TEXT("replaced"));
}
// Le fichier du joueur est COPIE (il reste ou il est).
const uint32 CopyResult = FileManager.Copy(*Final, *SourcePath, false);
if (CopyResult != COPY_OK || FileManager.FileSize(*Final) != Job.Octets)
{
State.Phase = ELPCityJobPhase::NeedsBrowser;
SetMessage(FString::Printf(TEXT("Download failed: the file could not be copied to %s."), *NativePath(Final)), true);
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] DOWNLOAD %s : echec copie de %s"), *Job.Ville, *SourcePath);
Notify();
return;
}
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : fichier du joueur copie (%s)"), *Job.Ville, *SourcePath);
OnPackageReady();
}
void FLPCityMapsDownload::OnPackageReady()
{
using namespace LPCityMapsDownloadImpl;
StopWatchingDownloads();
if (State.Mode == ELPCityInstallMode::DownloadOnly)
{
State.Phase = ELPCityJobPhase::Succeeded;
State.BytesDone = State.BytesTotal;
State.ResultPath = GetFinalPath();
SetMessage(FString::Printf(TEXT("Downloaded: %s"), *NativePath(State.ResultPath)), false);
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : termine %s"), *Job.Ville, *State.ResultPath);
Notify();
return;
}
BeginInstall();
}
void FLPCityMapsDownload::BeginInstall()
{
// Deuxieme barriere (la premiere est dans Start) : jamais de pose dans la copie de travail.
if (GIsEditor)
{
State.Phase = ELPCityJobPhase::Idle;
SetMessage(TEXT("Full install is only available in the packaged game."), true);
Notify();
return;
}
State.Phase = ELPCityJobPhase::Installing;
SetMessage(TEXT("Installing..."), false);
Notify();
TWeakPtr<FLPCityMapsDownload> Weak = AsShared();
const FString ZipPath = GetFinalPath();
const FString Pack = Job.Pack;
const FString Downloads = GetDownloadsDir();
Async(EAsyncExecution::ThreadPool, [Weak, ZipPath, Pack, Downloads]()
{
const LPCityMapsDownloadImpl::FInstallResult Outcome = LPCityMapsDownloadImpl::InstallPack(ZipPath, Pack, Downloads);
AsyncTask(ENamedThreads::GameThread, [Weak, Outcome]()
{
if (TSharedPtr<FLPCityMapsDownload> Self = Weak.Pin())
{
Self->OnInstalled(Outcome.bOk, Outcome.FilesPlaced, Outcome.Error, Outcome.bRollbackOk);
}
});
});
}
void FLPCityMapsDownload::OnInstalled(bool bOk, int32 FilesPlaced, const FString& Error, bool bRollbackOk)
{
using namespace LPCityMapsDownloadImpl;
if (!bOk)
{
State.Phase = ELPCityJobPhase::Idle;
SetMessage(FString::Printf(TEXT("Install failed: %s%s"), *Error, bRollbackOk ? TEXT(". Nothing was changed.") : TEXT(". The rollback was incomplete: see the log.")), true);
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] INSTALL %s : echec %s, retour arriere %s"), *Job.Ville, *Error, bRollbackOk ? TEXT("ok") : TEXT("ECHEC"));
Notify();
return;
}
State.Phase = ELPCityJobPhase::Succeeded;
State.bInstalled = true;
State.BytesDone = State.BytesTotal;
State.ResultPath = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir()) / TEXT("Mods") / Job.Pack;
SetMessage(FString::Printf(TEXT("Installed. Restart the game to play %s."), *Job.Ville), false);
UE_LOG(LogLPCity, Log, TEXT("[LPCity] INSTALL %s : %d fichiers poses (Modules puis Mods)"), *Job.Ville, FilesPlaced);
Notify();
}
void FLPCityMapsDownload::StartWatchingDownloads()
{
using namespace LPCityMapsDownloadImpl;
if (!WatchHandle.IsValid())
{
WatchHandle = FTSTicker::GetCoreTicker().AddTicker(FTickerDelegate::CreateSP(this, &FLPCityMapsDownload::OnWatchTick), WatchIntervalSeconds);
}
}
void FLPCityMapsDownload::StopWatchingDownloads()
{
if (WatchHandle.IsValid())
{
FTSTicker::GetCoreTicker().RemoveTicker(WatchHandle);
WatchHandle.Reset();
}
}
bool FLPCityMapsDownload::OnWatchTick(float DeltaTime)
{
(void)DeltaTime;
// Seulement pendant le recours, et un fichier controle a la fois.
if (State.Phase != ELPCityJobPhase::NeedsBrowser)
{
return true;
}
FString Folder;
if (!FindSystemDownloadsFolder(Folder))
{
return true;
}
IFileManager& FileManager = IFileManager::Get();
TArray<FString> Names;
FileManager.FindFiles(Names, *(Folder / TEXT("*.zip")), true, false);
for (const FString& Name : Names)
{
const FString Full = Folder / Name;
// Seule la taille exacte declenche un calcul de SHA-256.
if (FileManager.FileSize(*Full) != Job.Octets)
{
continue;
}
const FString Key = FString::Printf(TEXT("%s|%s"), *Full, *FileManager.GetTimeStamp(*Full).ToString());
if (ExaminedDownloads.Contains(Key))
{
continue;
}
ExaminedDownloads.Add(Key);
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : fichier de la taille attendue dans Telechargements : %s"), *Job.Ville, *Full);
BeginVerify(Full, EVerifySource::Watched);
break;
}
return true;
}
void FLPCityMapsDownload::UseLocalFile(const FString& SourcePath)
{
if (IsBusy() || SourcePath.IsEmpty())
{
return;
}
const int64 Size = IFileManager::Get().FileSize(*SourcePath);
if (Size != Job.Octets)
{
SetMessage(FString::Printf(TEXT("The selected file has the wrong size (%lld bytes, expected %lld)."), Size, Job.Octets), true);
Notify();
return;
}
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : fichier choisi par le joueur : %s"), *Job.Ville, *SourcePath);
BeginVerify(SourcePath, EVerifySource::Local);
}
bool FLPCityMapsDownload::SetAsideEmptyPart()
{
// Aucun octet recu (essai COM31, cas B9) : rien a reprendre ; le .part vide va dans _rejet/, il n'est jamais supprime.
CloseWriter();
const FString Part = GetPartPath();
return IFileManager::Get().FileSize(*Part) == 0 && !RejectFile(Part, TEXT("empty partial file")).IsEmpty();
}
FString FLPCityMapsDownload::RejectFile(const FString& Path, const TCHAR* Why)
{
using namespace LPCityMapsDownloadImpl;
IFileManager& FileManager = IFileManager::Get();
const FString Folder = GetDownloadsDir() / TEXT("_rejet");
const FString Clean = FPaths::GetCleanFilename(Path);
FString Destination = Folder / FString::Printf(TEXT("%s-%s"), *Stamp(), *Clean);
for (int32 Suffix = 2; FileManager.FileExists(*Destination); ++Suffix)
{
Destination = Folder / FString::Printf(TEXT("%s-%s-%d"), *Stamp(), *Clean, Suffix);
}
if (MoveFileSafe(Path, Destination))
{
UE_LOG(LogLPCity, Log, TEXT("[LPCity] DOWNLOAD %s : fichier deplace dans _rejet (%s) : %s"), *Job.Ville, Why, *Destination);
return Destination;
}
UE_LOG(LogLPCity, Warning, TEXT("[LPCity] DOWNLOAD %s : %s n'a pas pu etre deplace dans _rejet (%s)"), *Job.Ville, *Path, Why);
return FString();
}