mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-17 19:01:23 +02:00
Co-authored-by: Lydacious <81742991+lydacious@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
13 KiB
C++
334 lines
13 KiB
C++
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See install_cli.h.
|
|
#include "install_cli.h"
|
|
#include "installer.h"
|
|
#include "locate.h"
|
|
#include "placement.h"
|
|
#include "portable_switch.h"
|
|
|
|
#include <user/paths.h>
|
|
|
|
#ifdef __APPLE__
|
|
#include <TargetConditionals.h>
|
|
#endif
|
|
|
|
#include <chrono>
|
|
#include <csignal>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include <unistd.h>
|
|
|
|
namespace install
|
|
{
|
|
namespace
|
|
{
|
|
namespace fs = std::filesystem;
|
|
using Clock = std::chrono::steady_clock;
|
|
|
|
std::atomic<bool> s_cancel{ false };
|
|
|
|
void OnInterrupt(int)
|
|
{
|
|
s_cancel.store(true, std::memory_order_relaxed);
|
|
}
|
|
|
|
// Ctrl-C (or a SIGTERM) asks the install to stop between chunks, so it
|
|
// deletes its partial copy instead of leaving gigabytes behind. The
|
|
// handler resets itself: a second Ctrl-C kills at once.
|
|
class InterruptGuard
|
|
{
|
|
public:
|
|
InterruptGuard()
|
|
{
|
|
s_cancel.store(false);
|
|
struct sigaction sa{};
|
|
sa.sa_handler = OnInterrupt;
|
|
sa.sa_flags = SA_RESETHAND;
|
|
sigemptyset(&sa.sa_mask);
|
|
sigaction(SIGINT, &sa, &previousInt);
|
|
sigaction(SIGTERM, &sa, &previousTerm);
|
|
}
|
|
|
|
~InterruptGuard()
|
|
{
|
|
sigaction(SIGINT, &previousInt, nullptr);
|
|
sigaction(SIGTERM, &previousTerm, nullptr);
|
|
}
|
|
|
|
private:
|
|
struct sigaction previousInt{}, previousTerm{};
|
|
};
|
|
|
|
// One rewritten status line on a terminal; a line per file otherwise
|
|
// (a log file or the Steam launcher's output).
|
|
class ProgressPrinter
|
|
{
|
|
public:
|
|
void operator()(const Progress& p)
|
|
{
|
|
Clock::time_point now = Clock::now();
|
|
bool newFile = p.currentFile != lastFile;
|
|
if (newFile)
|
|
lastFile = std::string(p.currentFile);
|
|
if (p.phase == Progress::Phase::Checking || p.phase == Progress::Phase::Finalizing)
|
|
return;
|
|
int percent = p.bytesTotal ? int(p.bytesDone * 100 / p.bytesTotal) : 0;
|
|
if (!tty)
|
|
{
|
|
if (newFile && !lastFile.empty())
|
|
printf(" %3d%% %s\n", percent, lastFile.c_str());
|
|
return;
|
|
}
|
|
if (!newFile && now - lastPrint < std::chrono::milliseconds(200))
|
|
return;
|
|
lastPrint = now;
|
|
double seconds = std::chrono::duration<double>(now - start).count();
|
|
double rate = seconds > 0.5 ? double(p.bytesDone) / seconds : 0.0;
|
|
printf("\r %3d%% %s / %s %s/s %s\x1b[K", percent, FormatSize(p.bytesDone).c_str(),
|
|
FormatSize(p.bytesTotal).c_str(), FormatSize(uint64_t(rate)).c_str(), lastFile.c_str());
|
|
fflush(stdout);
|
|
printed = true;
|
|
}
|
|
|
|
void Finish()
|
|
{
|
|
if (printed)
|
|
printf("\n");
|
|
fflush(stdout);
|
|
}
|
|
|
|
double Seconds() const
|
|
{
|
|
return std::chrono::duration<double>(Clock::now() - start).count();
|
|
}
|
|
|
|
private:
|
|
bool tty = isatty(STDOUT_FILENO);
|
|
bool printed = false;
|
|
std::string lastFile;
|
|
Clock::time_point start = Clock::now(), lastPrint{};
|
|
};
|
|
|
|
int Failed(const Result& r)
|
|
{
|
|
fprintf(stderr, "error (%s): %s\n", ErrorName(r.error), r.message.c_str());
|
|
return r.error == Error::Cancelled ? 130 : 1;
|
|
}
|
|
|
|
int Usage(const char* message)
|
|
{
|
|
fprintf(stderr, "%s\n"
|
|
"usage: SpeedBreaker --install <image|folder> [--dest <dir>]\n"
|
|
" SpeedBreaker --verify <image|folder>\n"
|
|
" SpeedBreaker --find-images [folder...]\n"
|
|
" SpeedBreaker --where\n"
|
|
" SpeedBreaker --portable on|off\n", message);
|
|
return 2;
|
|
}
|
|
|
|
Result OpenAndCheck(const char* path, std::unique_ptr<DiscSource>& source)
|
|
{
|
|
Result r = OpenDiscSource(path, source);
|
|
if (!r.Ok())
|
|
return r;
|
|
printf("Source: %s (%s, %zu files)\n", source->Path().c_str(),
|
|
source->Kind() == SourceKind::Image ? "disc image" : "folder", source->Files().size());
|
|
return {};
|
|
}
|
|
|
|
// `named`: the player gave the folder (--dest). Without --dest it's
|
|
// the default folder, which gets what the installer screen's
|
|
// "Internal storage" entry gets: only Install's own checks.
|
|
int RunInstall(const char* sourcePath, const fs::path& destination, bool named)
|
|
{
|
|
// --dest: into exactly that folder (no subfolder, unlike the
|
|
// installer screen's "Choose a folder..."), with the screen's
|
|
// refusals for a folder the player picks: not SpeedBreaker's own
|
|
// folder, inside an install, the home folder, iCloud Drive, a
|
|
// folder kept in memory... (install/placement.h). Then the folder
|
|
// as the checks saw it (absolute, links resolved, no trailing
|
|
// separator) is installed into and recorded as such.
|
|
fs::path folder = destination;
|
|
std::vector<std::string> notes;
|
|
if (named)
|
|
{
|
|
FolderChoice where = CheckExactFolder(destination, true);
|
|
if (!where.result.Ok())
|
|
return Failed(where.result);
|
|
folder = where.gameDir;
|
|
notes = std::move(where.notes);
|
|
}
|
|
std::unique_ptr<DiscSource> source;
|
|
if (Result r = OpenAndCheck(sourcePath, source); !r.Ok())
|
|
return Failed(r);
|
|
for (const std::string& note : notes)
|
|
printf("Note: %s\n", note.c_str());
|
|
printf("Installing %s to %s\n", FormatSize(GameManifest().totalBytes).c_str(), folder.string().c_str());
|
|
|
|
InterruptGuard guard;
|
|
ProgressPrinter printer;
|
|
Result r = Install(*source, folder, std::ref(printer), &s_cancel);
|
|
printer.Finish();
|
|
if (!r.Ok())
|
|
return Failed(r);
|
|
printf("Installed and verified in %.0f s: %s\n", printer.Seconds(), folder.string().c_str());
|
|
// So the game finds it there (the default location clears the record).
|
|
if (!RecordInstallPath(folder))
|
|
fprintf(stderr, "Couldn't record the install location; start the game with NFSMW_GAME_DIR=%s\n",
|
|
folder.string().c_str());
|
|
return 0;
|
|
}
|
|
|
|
int RunVerify(const char* sourcePath)
|
|
{
|
|
std::unique_ptr<DiscSource> source;
|
|
if (Result r = OpenAndCheck(sourcePath, source); !r.Ok())
|
|
return Failed(r);
|
|
|
|
InterruptGuard guard;
|
|
ProgressPrinter printer;
|
|
Result r = Verify(*source, std::ref(printer), &s_cancel);
|
|
printer.Finish();
|
|
if (!r.Ok())
|
|
return Failed(r);
|
|
printf("All %zu files match the original disc (%.0f s).\n", GameManifest().files.size(), printer.Seconds());
|
|
return 0;
|
|
}
|
|
|
|
int RunFindImages(const std::vector<fs::path>& folders)
|
|
{
|
|
std::vector<fs::path> where = folders.empty() ? DiscImageSearchFolders() : folders;
|
|
printf("Looking in %zu folders (and one level below them)...\n", where.size());
|
|
InterruptGuard guard;
|
|
std::vector<DiscImageCandidate> images = FindDiscImages(where, 1ull << 30, &s_cancel);
|
|
for (const DiscImageCandidate& image : images)
|
|
{
|
|
printf(" %-6s %s (%s)\n", image.status.Ok() ? "ready" : "no", image.path.c_str(),
|
|
FormatSize(image.size).c_str());
|
|
if (!image.status.Ok())
|
|
printf(" %s: %s\n", ErrorName(image.status.error), image.status.message.c_str());
|
|
}
|
|
if (images.empty())
|
|
printf("No Xbox 360 disc images found.\n");
|
|
return images.empty() ? 1 : 0;
|
|
}
|
|
|
|
// Where settings, saves and caches are, and whether that's portable mode.
|
|
void PrintDataFolders()
|
|
{
|
|
const portable::Layout& layout = portable::Current();
|
|
if (layout.Active())
|
|
printf("Portable mode (%s in %s): data in %s, caches in %s\n", portable::kMarker, layout.root.c_str(),
|
|
GetUserPath().c_str(), GetCachePath().c_str());
|
|
else
|
|
printf("Data in %s, caches in %s\n", GetUserPath().c_str(), GetCachePath().c_str());
|
|
}
|
|
|
|
int RunWhere()
|
|
{
|
|
std::optional<GameInstall> game = FindGameInstall();
|
|
if (!game)
|
|
{
|
|
// The installer screen's notice: a recorded folder that isn't there now, say.
|
|
RecordedInstall recorded = CheckRecordedInstall();
|
|
#if defined(__APPLE__) && !TARGET_OS_IOS
|
|
std::string notice = DescribeRecordedInstall(recorded, recorded.path.string(), true);
|
|
#else
|
|
std::string notice = DescribeRecordedInstall(recorded, recorded.path.string(), false);
|
|
#endif
|
|
if (!notice.empty())
|
|
printf("%s\n", notice.c_str());
|
|
printf("No game install found. Install one with --install <image|folder> [--dest <dir>].\n");
|
|
PrintDataFolders();
|
|
return 1;
|
|
}
|
|
const char* origin = game->origin == InstallOrigin::Environment ? "NFSMW_GAME_DIR"
|
|
: game->origin == InstallOrigin::Development ? "development checkout" : "installed";
|
|
printf("%s (%s%s)\n", game->path.c_str(), origin, game->marker ? ", verified by the installer" : "");
|
|
PrintDataFolders();
|
|
return 0;
|
|
}
|
|
|
|
// --portable on|off (install/portable_switch.h): the copy, with the
|
|
// game's copy shown as --install shows it.
|
|
int RunPortable(bool on)
|
|
{
|
|
if (!portable::kSupported)
|
|
{
|
|
fprintf(stderr, "Portable mode isn't available on macOS or iOS: SpeedBreaker keeps its data in the system's "
|
|
"folder for apps there.\n");
|
|
return 1;
|
|
}
|
|
InterruptGuard guard;
|
|
GameCopier copier = [](const fs::path& from, const fs::path& to) -> Result
|
|
{
|
|
std::unique_ptr<DiscSource> source;
|
|
if (Result r = OpenDiscSource(from, source); !r.Ok())
|
|
return r;
|
|
printf("Copying the game from %s (%s), checking every file:\n", from.c_str(),
|
|
FormatSize(GameManifest().totalBytes).c_str());
|
|
ProgressPrinter printer;
|
|
Result r = Install(*source, to, std::ref(printer), &s_cancel);
|
|
printer.Finish();
|
|
return r;
|
|
};
|
|
std::vector<std::string> said;
|
|
Result r = on ? MakePortable(BundleFolder(), HomeUserPath(), copier, said)
|
|
: LeavePortable(portable::Current(), HomeUserPath(), copier, said);
|
|
for (const std::string& line : said)
|
|
printf("%s\n", line.c_str());
|
|
if (!r.Ok())
|
|
return Failed(r);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
std::optional<int> RunInstallCommand(int argc, char** argv)
|
|
{
|
|
for (int i = 1; i < argc; i++)
|
|
{
|
|
std::string_view arg = argv[i];
|
|
if (arg == "--install")
|
|
{
|
|
if (i + 1 >= argc)
|
|
return Usage("--install needs the disc image or folder to install from.");
|
|
const char* source = argv[i + 1];
|
|
fs::path destination = DefaultInstallPath();
|
|
bool named = false;
|
|
for (int j = i + 2; j < argc; j++)
|
|
{
|
|
if (std::string_view(argv[j]) == "--dest" && j + 1 < argc)
|
|
{
|
|
destination = argv[++j];
|
|
named = true;
|
|
}
|
|
else
|
|
return Usage((std::string("unexpected argument: ") + argv[j]).c_str());
|
|
}
|
|
return RunInstall(source, destination, named);
|
|
}
|
|
if (arg == "--verify")
|
|
{
|
|
if (i + 1 >= argc)
|
|
return Usage("--verify needs the disc image or folder to check.");
|
|
return RunVerify(argv[i + 1]);
|
|
}
|
|
if (arg == "--find-images")
|
|
return RunFindImages(std::vector<fs::path>(argv + i + 1, argv + argc));
|
|
if (arg == "--where")
|
|
return RunWhere();
|
|
if (arg == "--portable")
|
|
{
|
|
std::string_view what = i + 1 < argc ? std::string_view(argv[i + 1]) : std::string_view();
|
|
if ((what != "on" && what != "off") || i + 2 < argc)
|
|
return Usage("--portable takes on (copy this computer's SpeedBreaker data and game into SpeedBreaker's "
|
|
"folder, and keep them there from now on) or off (copy them back).");
|
|
return RunPortable(what == "on");
|
|
}
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
}
|