Files
SpeedBreakerProject--speedb…/runtime/ui/installer_screen.cpp
T
2026-10-08 13:25:54 +00:00

1008 lines
43 KiB
C++

// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
//
// The first-run installer screen: pick the player's own disc image (found
// automatically, or browsed for), check it, copy and verify it with progress,
// then start the game. The blocking work (the scan, the check, the install)
// runs on a worker thread; this file only draws and reacts.
#include <stdafx.h>
#include "installer_screen.h"
#include "ui.h"
#include <install/installer.h>
#include <install/locate.h>
#include <imgui.h>
#include <imgui_internal.h>
#include <SDL3/SDL.h>
#include <condition_variable>
#include <future>
#include <thread>
#include <utility>
#if defined(__APPLE__) && TARGET_OS_IOS
#include <platform/ios_files.h>
#endif
namespace ui
{
namespace
{
// File dialogs (SDL's; on iOS, the Files app's picker) may answer
// after the screen is gone (the window closed first), or after the
// player went on another way, so their result lives here, tagged
// with the request it answers.
std::mutex s_dialogMutex;
uintptr_t s_dialogRequest = 0; // the latest Browse; older answers are dropped
std::optional<std::string> s_dialogPick;
uint64_t s_pickAccess = 0; // iOS: the Files app's grant to read s_dialogPick (ios_files.h)
bool s_dialogFailed = false;
std::string s_dialogNotice; // iOS: the pick isn't on the device yet; what to do instead
// Gives back a grant to read a picked file (iOS; elsewhere there are
// none, and `access` is 0).
void ReleaseAccess(uint64_t access)
{
#if defined(__APPLE__) && TARGET_OS_IOS
platform::ios::ReleaseFileAccess(access);
#else
(void)access;
#endif
}
// Drops a pick nobody will check, and its grant. s_dialogMutex held.
void ForgetPick()
{
s_dialogPick.reset();
ReleaseAccess(std::exchange(s_pickAccess, 0));
}
#if !(defined(__APPLE__) && TARGET_OS_IOS)
void SDLCALL OnDialog(void* request, const char* const* files, int)
{
std::lock_guard lock(s_dialogMutex);
if (reinterpret_cast<uintptr_t>(request) != s_dialogRequest)
return;
if (files && files[0])
s_dialogPick = files[0];
else if (!files)
{
s_dialogFailed = true;
fprintf(stderr, "[installer] file dialog failed: %s\n", SDL_GetError());
}
}
#endif
// Opens a file (or folder) dialog for a new request.
void Browse(bool folder)
{
void* request;
{
std::lock_guard lock(s_dialogMutex);
request = reinterpret_cast<void*>(++s_dialogRequest);
ForgetPick();
s_dialogFailed = false;
s_dialogNotice.clear();
}
#if defined(__APPLE__) && TARGET_OS_IOS
// SDL has no file dialog on iOS: the Files app's document picker,
// which answers on the main thread (inside SDL_PollEvent) with the
// file and a grant to read it where it is.
bool shown = platform::ios::PickDiscImage(GetWindow(), folder,
[request](std::optional<platform::ios::PickedFile> picked) {
std::lock_guard lock(s_dialogMutex);
if (reinterpret_cast<uintptr_t>(request) != s_dialogRequest)
{
if (picked)
ReleaseAccess(picked->access);
return;
}
if (picked && !picked->notHere.empty())
s_dialogNotice = std::move(picked->notHere); // nothing to read yet (no grant held)
else if (picked)
{
ForgetPick();
s_dialogPick = std::move(picked->path);
s_pickAccess = picked->access;
}
});
if (!shown)
{
std::lock_guard lock(s_dialogMutex);
s_dialogFailed = true;
}
#else
static const SDL_DialogFileFilter filters[] = { { "Xbox 360 disc image", "iso" }, { "All files", "*" } };
if (folder)
SDL_ShowOpenFolderDialog(OnDialog, request, GetWindow(), std::getenv("HOME"), false);
else
SDL_ShowOpenFileDialog(OnDialog, request, GetWindow(), filters, 2, std::getenv("HOME"), false);
#endif
}
// Forget any dialog still open: the player chose another way.
void DropDialog()
{
std::lock_guard lock(s_dialogMutex);
++s_dialogRequest;
ForgetPick();
}
std::string FileName(const std::filesystem::path& path)
{
std::string name = path.filename().string();
return name.empty() ? path.string() : name;
}
// A Files picker on screen, or on its way on or off it (iOS). The
// Choose page acts on nothing meanwhile: a controller still reaches
// it behind the picker, and UIKit refuses a second picker while one
// slides in or out, which would lose the pick.
bool PickerShown()
{
#if defined(__APPLE__) && TARGET_OS_IOS
return platform::ios::IsPickerShown();
#else
return false;
#endif
}
// The home folder as ~.
std::string HomeRelative(const std::filesystem::path& path)
{
std::string text = path.string();
if (const char* home = std::getenv("HOME"); home && *home)
{
std::string_view h = home;
while (h.size() > 1 && h.back() == '/')
h.remove_suffix(1);
if (text.starts_with(h) && (text.size() == h.size() || text[h.size()] == '/'))
text = "~" + text.substr(h.size());
}
return text;
}
// A folder for display, shortened from the left with "..." to fit `width`.
std::string DisplayPath(const std::filesystem::path& path, float width)
{
std::string text = HomeRelative(path);
if (ImGui::CalcTextSize(text.c_str()).x <= width)
return text;
size_t cut = 0;
while (cut < text.size() && ImGui::CalcTextSize(("..." + text.substr(cut)).c_str()).x > width)
cut++;
return "..." + text.substr(cut);
}
std::string Duration(double seconds)
{
if (seconds < 60)
return std::format("{} s", std::max(1, int(seconds + 0.5)));
int minutes = int(seconds / 60 + 0.5);
return minutes < 60 ? std::format("{} min", minutes) : std::format("{} h {} min", minutes / 60, minutes % 60);
}
// Folders the scan reads without asking. On macOS, Desktop,
// Documents, Downloads and removable volumes each raise a privacy
// prompt on first access, so they are left to the file dialog there;
// so is the home folder, whose one-level-down scan would open them.
std::vector<std::filesystem::path> ScanFolders()
{
#if defined(__APPLE__) && TARGET_OS_IOS
// iOS: the app's own Documents folder, the one the Files app shows
// (On My iPad > SpeedBreaker) and Finder copies into, and its
// Inbox, where iOS puts a file another app hands over. (Nothing
// does yet: Info.plist declares no document types, so SpeedBreaker
// isn't offered in Open in or AirDrop.)
std::vector<std::filesystem::path> folders;
if (const char* home = std::getenv("HOME"); home && *home)
for (const char* sub : { "Documents", "Documents/Inbox" })
{
std::error_code ec;
std::filesystem::path dir = std::filesystem::path(home) / sub;
if (std::filesystem::is_directory(dir, ec))
folders.push_back(dir);
}
return folders;
#else
std::vector<std::filesystem::path> folders = install::DiscImageSearchFolders();
#endif
#if defined(__APPLE__) && !TARGET_OS_IOS
const char* home = std::getenv("HOME");
std::erase_if(folders, [home](const std::filesystem::path& folder) {
std::string name = folder.filename().string();
return name == "Desktop" || name == "Documents" || name == "Downloads" || folder.parent_path() == "/Volumes" ||
(home && std::filesystem::path(home).lexically_normal() == folder.lexically_normal());
});
#endif
// EmuDeck's ROM folder, in the home folder and on SD cards.
std::vector<std::filesystem::path> extra;
for (const auto& folder : folders)
{
std::error_code ec;
std::filesystem::path roms = folder / "Emulation" / "roms" / "xbox360";
if (std::filesystem::is_directory(roms, ec))
extra.push_back(roms);
}
folders.insert(folders.end(), extra.begin(), extra.end());
return folders;
}
#if defined(__APPLE__) && TARGET_OS_IOS
const char* kWhereToPut = "Choose Browse for image... to pick your .iso where it is (iCloud Drive, a USB drive, "
"another app's folder), or copy it into SpeedBreaker's folder in the Files app (On My "
"iPad or On My iPhone > SpeedBreaker) and choose Rescan.";
const char* kScanning = "Looking for disc images in SpeedBreaker's folder...";
#elif defined(__APPLE__)
const char* kWhereToPut = "Put your .iso file in a Games folder in your home folder and choose Rescan, or browse for it.";
const char* kScanning = "Looking for disc images in ~/Games...";
#else
const char* kWhereToPut = "Put your .iso file in your home folder, Downloads, a Games folder, or on an SD card or USB "
"drive and choose Rescan, or browse for it.";
const char* kScanning = "Looking for disc images in your home folder, Games and removable drives...";
#endif
#if defined(__APPLE__) && TARGET_OS_IOS
const char* kNoDialog = "The Files picker couldn't be shown. Copy your .iso into SpeedBreaker's folder in the Files "
"app (On My iPad or On My iPhone > SpeedBreaker) and choose Rescan.";
#else
const char* kNoDialog = "The file browser isn't available here (in Game Mode, for example). Put the .iso in Downloads, "
"a Games folder or on an SD card and choose Rescan, or type its path below (Steam + X shows "
"the keyboard).";
#endif
const char* StatusText(const install::DiscImageCandidate& c)
{
using install::Error;
switch (c.status.error)
{
case Error::None: return "Ready to install";
case Error::UnsupportedVersion: return "Unsupported version";
case Error::WrongGame: return "Another game";
case Error::TruncatedImage: return "Incomplete image";
case Error::ReadError: return "Can't be read";
default: return "Can't be installed";
}
}
const char* FailureTitle(install::Error error)
{
using install::Error;
switch (error)
{
case Error::NotFound: return "Not found";
case Error::NotADiscImage: return "Not an Xbox 360 disc image";
case Error::UnsupportedFormat: return "Unsupported format";
case Error::TruncatedImage: return "The image is incomplete";
case Error::CorruptImage: return "The image is damaged";
case Error::WrongGame: return "This is another game";
case Error::UnsupportedVersion: return "Unsupported version of the game";
case Error::MissingFile:
case Error::CorruptFile: return "The disc image has bad files";
case Error::ReadError: return "Couldn't read the disc image";
case Error::DestinationNotWritable:
case Error::DestinationNotEmpty: return "Can't install there";
case Error::NotEnoughSpace: return "Not enough free space";
case Error::WriteError: return "Couldn't write the game files";
case Error::Cancelled: return "Install cancelled";
default: return "Install failed";
}
}
// A check of a disc image or folder, on a thread of its own. A read
// can't be interrupted, and one can wait for minutes (a file a cloud
// service is still downloading, a drive that stopped answering), so
// Back leaves the thread behind instead of waiting for it. Left
// behind, it drops its result and gives the grant to read the file
// back itself once the read returns: not before, while it still reads.
struct CheckJob
{
std::mutex mutex;
std::condition_variable finished;
bool done = false;
bool abandoned = false;
uint64_t access = 0; // once abandoned: the grant the thread gives back
install::Result result;
std::unique_ptr<install::DiscSource> source;
static std::shared_ptr<CheckJob> Start(const std::filesystem::path& path)
{
auto job = std::make_shared<CheckJob>();
std::thread([job, path] {
std::unique_ptr<install::DiscSource> opened;
install::Result r = install::OpenDiscSource(path, opened);
if (r.Ok())
r = install::CheckSource(*opened);
uint64_t release = 0;
{
std::lock_guard lock(job->mutex);
job->done = true;
if (job->abandoned)
{
release = std::exchange(job->access, 0);
fprintf(stderr, "[installer] the check of %s that was left behind has returned\n", path.c_str());
}
else
{
if (r.Ok())
job->source = std::move(opened);
job->result = std::move(r);
}
}
job->finished.notify_all();
opened.reset(); // the file closed before its grant goes
ReleaseAccess(release);
}).detach();
return job;
}
};
}
struct InstallerScreen::State
{
enum class Page { Choose, Checking, Confirm, Installing, Done, Failed };
Page page = Page::Choose;
// Scan.
std::future<std::vector<install::DiscImageCandidate>> scan;
std::vector<install::DiscImageCandidate> images;
bool scanned = false;
std::atomic<bool> scanCancel{ false };
// Check and install (one at a time): the check as `check` (CheckJob),
// the install as `task`.
std::shared_ptr<CheckJob> check;
std::future<install::Result> task;
std::unique_ptr<install::DiscSource> source;
std::filesystem::path sourcePath;
// iOS: the Files app's grant to read sourcePath, when it was picked
// outside SpeedBreaker's folder. Held while anything may still read
// it: from the check through the install, and on a failed install's
// page (Try again); given back on the way to Choose or Done.
uint64_t access = 0;
std::atomic<bool> cancel{ false };
install::Result result;
std::filesystem::path destination = install::DefaultInstallPath();
// Where to install: internal storage or a removable drive, each
// checked (space, permissions) when the Confirm page opens.
struct Destination
{
install::InstallDestination where;
install::Result check;
install::DestinationInfo info;
};
std::vector<Destination> destinations;
size_t chosen = 0;
void LoadDestinations()
{
destinations.clear();
for (auto& where : install::InstallDestinations())
{
Destination d{ where };
d.check = install::CheckDestination(where.path, &d.info);
destinations.push_back(std::move(d));
}
// The one used last time if it still works, else internal storage,
// else the first drive that works.
chosen = 0;
std::optional<std::filesystem::path> recorded = install::RecordedInstallPath();
for (size_t i = 0; i < destinations.size(); i++)
if (recorded && destinations[i].where.path == *recorded && destinations[i].check.Ok())
chosen = i;
if (!destinations[chosen].check.Ok())
for (size_t i = 0; i < destinations.size(); i++)
if (destinations[i].check.Ok())
{
chosen = i;
break;
}
destination = destinations[chosen].where.path;
}
// Progress, written by the install thread.
std::mutex progressMutex;
install::Progress progress;
std::string currentFile;
std::chrono::steady_clock::time_point copyStart{};
uint64_t copyStartBytes = 0;
char typedPath[1024] = {};
bool focusPage = true; // put the controller's cursor on the new page's main button
std::string notice; // one line under the title of the Choose page
void StartScan()
{
if (scan.valid())
return;
scanCancel = false;
scan = std::async(std::launch::async, [this] {
return install::FindDiscImages(ScanFolders(), 1ull << 30, &scanCancel);
});
}
void Go(Page next)
{
page = next;
focusPage = true;
if (next == Page::Choose || next == Page::Done || (next == Page::Failed && !source))
ReleaseAccess(std::exchange(access, 0));
}
// `grant`: the Files app's grant to read `path` (iOS), held from now on.
void StartCheck(const std::filesystem::path& path, uint64_t grant = 0)
{
DropDialog();
LeaveCheck();
sourcePath = path;
source.reset();
ReleaseAccess(std::exchange(access, grant));
notice.clear();
Go(Page::Checking);
check = CheckJob::Start(path);
}
// The check's result, into `result` and `source`, once it has one.
bool CollectCheck()
{
if (!check)
return false;
{
std::lock_guard lock(check->mutex);
if (!check->done)
return false;
result = std::move(check->result);
source = std::move(check->source);
}
check.reset();
return true;
}
// Stops waiting for the check. One still reading is left to return on
// its own, with the grant to read the file (CheckJob); call before
// anything else gives `access` back.
void LeaveCheck()
{
if (!check)
return;
{
std::lock_guard lock(check->mutex);
if (!check->done)
{
check->abandoned = true;
check->access = std::exchange(access, 0);
fprintf(stderr, "[installer] left the check of %s still reading\n", sourcePath.c_str());
}
}
check.reset();
}
void StartInstall()
{
cancel = false;
{
std::lock_guard lock(progressMutex);
progress = {};
currentFile.clear();
copyStart = {};
}
Go(Page::Installing);
task = std::async(std::launch::async, [this] {
return install::Install(*source, destination, [this](const install::Progress& p) {
std::lock_guard lock(progressMutex);
if (p.phase == install::Progress::Phase::Copying && copyStart.time_since_epoch().count() == 0)
{
copyStart = std::chrono::steady_clock::now();
copyStartBytes = p.bytesDone;
}
progress = p;
currentFile.assign(p.currentFile);
progress.currentFile = {};
}, &cancel);
});
}
bool TaskReady()
{
return task.valid() && task.wait_for(std::chrono::seconds(0)) == std::future_status::ready;
}
void Stop()
{
cancel = true;
scanCancel = true;
// A check normally returns in moments, and is waited for, so it
// doesn't still run while the app exits; one stuck in a read is
// left behind.
if (check)
{
std::unique_lock lock(check->mutex);
check->finished.wait_for(lock, std::chrono::seconds(2), [this] { return check->done; });
}
LeaveCheck();
if (task.valid())
task.wait();
if (scan.valid())
scan.wait();
}
};
InstallerScreen::InstallerScreen() : state(std::make_unique<State>())
{
state->StartScan();
// Installed on a drive that isn't there now (FindGameInstall passed it over).
if (std::optional<std::filesystem::path> recorded = install::RecordedInstallPath())
state->notice = "The game is installed in " + HomeRelative(*recorded) +
", which isn't available: insert that drive and start the game again, or install it again here.";
}
InstallerScreen::~InstallerScreen()
{
state->Stop();
// Nothing reads the picked file now; nor will anything collect a
// pick still waiting.
ReleaseAccess(std::exchange(state->access, 0));
DropDialog();
}
void InstallerScreen::Draw()
{
using Page = State::Page;
State& st = *state;
float s = Scale();
// Collect finished work.
if (st.scan.valid() && st.scan.wait_for(std::chrono::seconds(0)) == std::future_status::ready)
{
st.images = st.scan.get();
st.scanned = true;
// Onto the first image, unless the player is typing a path.
if (st.page == Page::Choose && !ImGui::IsAnyItemActive())
st.focusPage = true;
}
if (st.page == Page::Checking && st.CollectCheck())
{
if (st.result.Ok())
st.LoadDestinations();
st.Go(st.result.Ok() ? Page::Confirm : Page::Failed);
}
if (st.page == Page::Installing && st.TaskReady())
{
st.result = st.task.get();
if (st.result.Ok())
{
installedPath = st.destination;
// So the next launch finds it (internal storage clears the record).
if (!install::RecordInstallPath(st.destination))
fprintf(stderr, "[installer] couldn't record the install location %s\n", st.destination.c_str());
st.Go(Page::Done);
}
else if (st.result.error == install::Error::Cancelled)
{
st.notice = "Install cancelled. Nothing was left behind.";
st.Go(Page::Choose);
}
else
st.Go(Page::Failed);
}
{
std::optional<std::string> pick;
uint64_t access = 0;
{
std::lock_guard lock(s_dialogMutex);
if (st.page == Page::Choose)
{
pick = std::move(s_dialogPick);
s_dialogPick.reset();
access = std::exchange(s_pickAccess, 0);
}
if (s_dialogFailed)
{
s_dialogFailed = false;
st.notice = kNoDialog;
}
if (!s_dialogNotice.empty())
{
st.notice = std::move(s_dialogNotice);
s_dialogNotice.clear();
}
}
if (pick)
st.StartCheck(*pick, access);
}
ImGuiViewport* viewport = ImGui::GetMainViewport();
ImVec2 size(std::min(viewport->WorkSize.x - 2 * kSpaceL * s, 980.0f * s), std::min(viewport->WorkSize.y - 2 * kSpaceL * s, 640.0f * s));
ImGui::SetNextWindowPos(viewport->GetWorkCenter(), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(size, ImGuiCond_Always);
if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_AnyWindow))
ImGui::SetNextWindowFocus(); // a click outside the panel mustn't strand the controller
constexpr ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoSavedSettings;
if (!ImGui::Begin("Install", nullptr, flags))
{
ImGui::End();
return;
}
TitleText("SpeedBreaker");
ImGui::TextDisabled("First-time setup for Need for Speed: Most Wanted (Xbox 360)");
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
auto mainButton = [&](const char* label) {
bool pressed = ImGui::Button(label, ImVec2(0, 0));
if (st.focusPage)
{
ImGui::SetItemDefaultFocus();
ImGui::SetKeyboardFocusHere(-1);
st.focusPage = false;
}
return pressed;
};
switch (st.page)
{
case Page::Choose:
{
const bool inert = PickerShown(); // nothing acts while a Files picker is up
ImGui::TextWrapped("The game is installed from your own copy: an Xbox 360 disc image (.iso) or the files "
"extracted from one. No game data comes with this port.");
if (!st.notice.empty())
{
PushAccentText();
ImGui::TextWrapped("%s", st.notice.c_str());
PopAccentText();
}
ImGui::Spacing();
// Under the list, to the window's bottom edge: two lines for why
// the focused image won't install, a gap, the path to type, a
// gap, the buttons.
const float line = ImGui::GetTextLineHeightWithSpacing();
const float detailHeight = line * 2 + kSpaceS * s;
#if defined(__APPLE__) && TARGET_OS_IOS
const float pathRow = 0; // no path to type on iOS (no keyboard, nor paths to know)
#else
const float pathRow = line + ImGui::GetFrameHeightWithSpacing();
#endif
const float below = ImGui::GetStyle().ItemSpacing.y + detailHeight + pathRow + kSpaceS * s + ImGui::GetFrameHeight();
std::string detail; // why the focused image can't be installed
if (ImGui::BeginChild("images", ImVec2(0, -below), ImGuiChildFlags_Borders | ImGuiChildFlags_NavFlattened))
{
if (!st.scanned)
ImGui::TextDisabled("%s", kScanning);
else if (st.images.empty())
{
ImGui::TextDisabled("No disc images found.");
ImGui::TextWrapped("%s", kWhereToPut);
}
bool focused = false;
for (size_t i = 0; i < st.images.size(); i++)
{
const auto& c = st.images[i];
ImGui::PushID(int(i));
bool ok = c.status.Ok();
float rowHeight = ImGui::GetTextLineHeightWithSpacing() * 2 + 6 * s;
// Rows that can't be installed stay focusable, so a
// controller can reach the reason (shown under the list).
bool pick = ImGui::Selectable("##image", false, ImGuiSelectableFlags_None, ImVec2(0, rowHeight));
if (!ok && (ImGui::IsItemFocused() || ImGui::IsItemHovered()))
detail = c.status.message;
if (ok && st.focusPage && !focused)
{
// Straight onto the row: SetKeyboardFocusHere() from inside
// this (nav-flattened) child left the cursor where it was,
// on Browse when the scan ended after the first frame.
ImGui::SetItemDefaultFocus();
ImGui::SetFocusID(ImGui::GetItemID(), ImGui::GetCurrentWindow());
ImGui::ScrollToItem(ImGuiScrollFlags_KeepVisibleEdgeY);
focused = true;
st.focusPage = false;
}
// The name over its folder, the two lines centred in the
// row's highlight (its item rect) and kSpaceL inside it.
ImDrawList* draw = ImGui::GetWindowDrawList();
ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax();
float left = min.x + kSpaceL * s, right = max.x - kSpaceL * s;
float lineGap = kSpaceXS * s;
float top = std::round((min.y + max.y - (ImGui::GetFontSize() * 2 + lineGap)) * 0.5f);
ImU32 text = ImGui::GetColorU32(ok ? ImGuiCol_Text : ImGuiCol_TextDisabled);
ImU32 dim = ImGui::GetColorU32(ImGuiCol_TextDisabled);
draw->AddText(ImVec2(left, top), text, FileName(c.path).c_str());
draw->AddText(ImVec2(left, top + ImGui::GetFontSize() + lineGap), dim,
DisplayPath(c.path.parent_path(), right - left).c_str());
std::string status = std::format("{} {}", install::FormatSize(c.size), StatusText(c));
float w = ImGui::CalcTextSize(status.c_str()).x;
draw->AddText(ImVec2(right - w, top), ok ? ImGui::GetColorU32(ImGuiCol_PlotLines) : dim, status.c_str());
if (pick && ok && !inert)
st.StartCheck(c.path);
ImGui::PopID();
}
}
ImGui::EndChild();
{
// Two lines under the list: why the focused image won't install.
float top = ImGui::GetCursorPosY();
if (!detail.empty())
{
PushAccentText();
ImGui::TextWrapped("%s", detail.c_str());
PopAccentText();
}
ImGui::SetCursorPosY(std::max(ImGui::GetCursorPosY(), top + detailHeight));
}
#if !(defined(__APPLE__) && TARGET_OS_IOS)
ImGui::TextDisabled("Or type the path of an image or folder:");
ImGui::SetNextItemWidth(-ImGui::CalcTextSize("Use").x - ImGui::GetStyle().FramePadding.x * 2 - ImGui::GetStyle().ItemSpacing.x);
bool enter = ImGui::InputText("##path", st.typedPath, sizeof(st.typedPath), ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
ImGui::BeginDisabled(st.typedPath[0] == 0);
if (ImGui::Button("Use") || (enter && st.typedPath[0]))
{
std::string path = st.typedPath;
if (path.starts_with("~/"))
if (const char* home = std::getenv("HOME"))
path = std::string(home) + path.substr(1);
st.StartCheck(path);
}
ImGui::EndDisabled();
#endif
ButtonRowAtBottom();
// The first installable image has the cursor; otherwise Browse.
// (Never disabled: a dialog the compositor doesn't show would
// otherwise lock them for good. A new Browse replaces it.)
if (mainButton("Browse for image...") && !inert)
Browse(false);
ImGui::SameLine();
if (ImGui::Button("Browse for folder...") && !inert)
Browse(true);
ImGui::SameLine();
ImGui::BeginDisabled(!st.scanned);
if (ImGui::Button("Rescan") && !inert)
{
st.scanned = false;
st.images.clear();
st.StartScan();
}
ImGui::EndDisabled();
// No Quit on iOS: apps there don't quit themselves (the player
// leaves with the home gesture).
#if !(defined(__APPLE__) && TARGET_OS_IOS)
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetWindowContentRegionMax().x - ImGui::CalcTextSize("Quit").x - ImGui::GetStyle().FramePadding.x * 2);
if (ImGui::Button("Quit"))
quit = true;
#endif
break;
}
case Page::Checking:
ImGui::Text("Checking %s...", FileName(st.sourcePath).c_str());
ImGui::TextDisabled("Reading the game's version and file list.");
ImGui::ProgressBar(-float(ImGui::GetTime()), ImVec2(-1, 0), "");
ButtonRowAtBottom();
// A read that waits (a drive that stopped answering, a file still
// coming from a cloud service) mustn't hold the player here: Back
// leaves it to return on its own. Not focused, as on Installing:
// A pressed twice on an image mustn't come straight back.
st.focusPage = false;
if (ImGui::Button("Back"))
{
st.LeaveCheck();
st.Go(Page::Choose);
}
break;
case Page::Confirm:
{
PushAccentText();
ImGui::TextUnformatted("Ready to install");
PopAccentText();
ImGui::Spacing();
const auto& manifest = install::GameManifest();
ImGui::TextDisabled("From");
ImGui::TextWrapped("%s", HomeRelative(st.sourcePath).c_str());
(void)manifest;
ImGui::Spacing();
ImGui::TextDisabled("To");
const State::Destination& dest = st.destinations[st.chosen];
auto describe = [](const State::Destination& d) {
std::string text = d.where.label;
if (d.info.available != UINT64_MAX)
text += " " + install::FormatSize(d.info.available) + " free";
return text;
};
if (st.destinations.size() > 1)
{
// Internal storage or a removable drive: left/right (or the arrows).
int delta = 0;
std::string label = describe(dest);
bool focusRow = st.focusPage && !dest.check.Ok();
if (focusRow)
ImGui::SetKeyboardFocusHere();
ImGui::Selectable(("##dest"), false, ImGuiSelectableFlags_AllowOverlap, ImVec2(0, ImGui::GetFrameHeight()));
if (focusRow)
st.focusPage = false;
bool focused = ImGui::IsItemFocused();
if (focused)
{
if (ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft) || ImGui::IsKeyPressed(ImGuiKey_GamepadLStickLeft) || ImGui::IsKeyPressed(ImGuiKey_LeftArrow))
delta = -1;
if (ImGui::IsKeyPressed(ImGuiKey_GamepadDpadRight) || ImGui::IsKeyPressed(ImGuiKey_GamepadLStickRight) || ImGui::IsKeyPressed(ImGuiKey_RightArrow))
delta = 1;
}
// The arrows and the label centred in the row's highlight (its
// item rect, half an item spacing bigger than the row all
// round), the left arrow's point kSpaceL inside it, as in a
// settings row. (At the rect's corner they sat half a
// spacing high and 11 points in.)
ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax();
float arrow = ImGui::GetFrameHeight(), font = ImGui::GetFontSize();
float inset = std::max(0.0f, kSpaceL * s - ((arrow - font) * 0.5f + font * 0.2f));
ImGui::SetCursorScreenPos(ImVec2(min.x + inset, std::round((min.y + max.y - arrow) * 0.5f)));
ImGui::PushItemFlag(ImGuiItemFlags_NoNav, true);
if (ImGui::ArrowButton("##prev", ImGuiDir_Left))
delta = -1;
ImGui::SameLine();
if (focused)
PushAccentText();
ImGui::TextUnformatted(label.c_str());
if (focused)
PopAccentText();
ImGui::SameLine();
if (ImGui::ArrowButton("##next", ImGuiDir_Right))
delta = 1;
ImGui::PopItemFlag();
if (delta)
{
st.chosen = (st.chosen + st.destinations.size() + size_t(delta)) % st.destinations.size();
st.destination = st.destinations[st.chosen].where.path;
}
}
else
ImGui::TextUnformatted(describe(dest).c_str());
const State::Destination& d = st.destinations[st.chosen];
ImGui::TextDisabled("%s", HomeRelative(d.where.path).c_str());
ImGui::Spacing();
if (!d.check.Ok())
{
PushAccentText();
ImGui::TextWrapped("%s", d.check.message.c_str());
PopAccentText();
}
else
{
ImGui::TextDisabled("%s needed", install::FormatSize(d.info.required).c_str());
if (d.info.replacing)
ImGui::TextWrapped("Replaces the game already there. DLC and other files you added to it are kept.");
ImGui::TextWrapped("This takes a few minutes. Every file is checked against the known-good disc as it is copied.");
}
ButtonRowAtBottom();
ImGui::BeginDisabled(!d.check.Ok());
if (d.check.Ok() ? mainButton("Install") : ImGui::Button("Install"))
st.StartInstall();
ImGui::EndDisabled();
ImGui::SameLine();
// Nowhere to install: the cursor goes to Back.
if (d.check.Ok() ? ImGui::Button("Back") : mainButton("Back"))
st.Go(Page::Choose);
break;
}
case Page::Installing:
{
install::Progress p;
std::string file;
std::chrono::steady_clock::time_point copyStart;
uint64_t copyStartBytes;
{
std::lock_guard lock(st.progressMutex);
p = st.progress;
file = st.currentFile;
copyStart = st.copyStart;
copyStartBytes = st.copyStartBytes;
}
const char* phase = p.phase == install::Progress::Phase::Finalizing ? "Finishing..." : "Installing...";
PushAccentText();
ImGui::TextUnformatted(phase);
PopAccentText();
ImGui::Spacing();
float fraction = p.bytesTotal ? float(double(p.bytesDone) / double(p.bytesTotal)) : 0.0f;
ImGui::ProgressBar(fraction, ImVec2(-1, 28 * s));
std::string line = std::format("{} of {}", install::FormatSize(p.bytesDone), install::FormatSize(p.bytesTotal));
if (copyStart.time_since_epoch().count() != 0)
{
double seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - copyStart).count();
double rate = seconds > 1.0 ? double(p.bytesDone - copyStartBytes) / seconds : 0.0;
if (rate > 0)
{
line += std::format(" {}/s", install::FormatSize(uint64_t(rate)));
if (p.bytesTotal > p.bytesDone)
line += " about " + Duration(double(p.bytesTotal - p.bytesDone) / rate) + " left";
}
}
ImGui::TextUnformatted(line.c_str());
ImGui::TextDisabled("%s (%zu of %zu files)", file.c_str(), std::min(p.filesDone + 1, p.filesTotal), p.filesTotal);
#if defined(__APPLE__) && TARGET_OS_IOS
ImGui::Spacing();
ImGui::TextDisabled("Keep SpeedBreaker open until it's done: iOS pauses apps in the background.");
#endif
ButtonRowAtBottom();
// Not focused by default: a stray A press mustn't throw away minutes of copying.
st.focusPage = false;
ImGui::BeginDisabled(st.cancel.load());
if (ImGui::Button(st.cancel ? "Cancelling..." : "Cancel"))
st.cancel = true;
ImGui::EndDisabled();
break;
}
case Page::Done:
PushAccentText();
ImGui::TextUnformatted("Installed");
PopAccentText();
ImGui::Spacing();
ImGui::TextWrapped("Every file matched the known-good disc. The game is in %s.", HomeRelative(st.destination).c_str());
ImGui::TextWrapped("You can delete the disc image now if you like; the game doesn't need it any more.");
ImGui::Spacing();
#if defined(__APPLE__) && TARGET_OS_IOS
ImGui::TextDisabled("In game, press View + Menu on a controller, or tap with three fingers, to open the settings.");
#elif defined(__APPLE__)
ImGui::TextDisabled("In game, F1 or Cmd+, (or Back + Start on a controller) opens the settings.");
#else
ImGui::TextDisabled("In game, F1 (or Back + Start on a controller) opens the settings.");
#endif
ButtonRowAtBottom();
if (mainButton("Start the game"))
finished = true;
break;
case Page::Failed:
PushAccentText();
ImGui::TextUnformatted(FailureTitle(st.result.error));
PopAccentText();
ImGui::Spacing();
ImGui::TextWrapped("%s", st.result.message.c_str());
if (!st.result.file.empty())
ImGui::TextDisabled("File: %s", st.result.file.c_str());
if (!st.result.foundHash.empty())
ImGui::TextDisabled("SHA-256: %s", st.result.foundHash.c_str());
ButtonRowAtBottom();
if (mainButton("Back"))
st.Go(Page::Choose);
if (st.source)
{
// Back to the Confirm page: maybe another drive this time.
ImGui::SameLine();
if (ImGui::Button("Try again"))
{
st.LoadDestinations();
st.Go(Page::Confirm);
}
}
break;
}
ImGui::End();
}
std::optional<std::filesystem::path> RunInstaller(const std::function<bool()>& runFrame)
{
InstallerScreen screen;
SetModal([&screen] { screen.Draw(); });
bool open = true;
while (!screen.finished && !screen.quit)
if (!runFrame())
{
open = false;
break;
}
SetModal(nullptr);
if (!open || screen.quit)
return std::nullopt;
return screen.installedPath;
}
}