1417 lines
42 KiB
C++
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();
|
|
}
|