// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING). See locate.h. #include "locate.h" #include "installer.h" #include "placement.h" #include #include #include #include #include #include #include #include #include #ifdef __APPLE__ #include #endif #include namespace install { namespace { namespace fs = std::filesystem; bool Hidden(const fs::path& path) { std::string name = path.filename().string(); return !name.empty() && name[0] == '.'; } #ifndef __APPLE__ std::string UserName() { if (const char* user = std::getenv("USER"); user && *user) return user; if (const passwd* pw = getpwuid(getuid())) return pw->pw_name; return {}; } #endif // default.xex in `dir` with the manifest's size (and, if asked, the // current marker beside it), as spelled on disk. std::optional UsableXex(const fs::path& dir, bool requireMarker, const Manifest& manifest) { std::error_code ec; fs::path xex = dir / "default.xex"; if (!fs::is_regular_file(xex, ec)) { // DEFAULT.XEX from a tool that upper-cases names, on a // case-sensitive file system. xex.clear(); std::error_code listEc; for (fs::directory_iterator it(dir, listEc), end; !listEc && it != end; it.increment(listEc)) { std::error_code fileEc; if (PathEqualsIgnoreCase(it->path().filename().string(), "default.xex") && it->is_regular_file(fileEc)) { xex = it->path(); break; } } if (xex.empty()) return std::nullopt; } uint64_t size = fs::file_size(xex, ec); if (ec || size != manifest.Xex().size) return std::nullopt; if (requireMarker && ReadInstallMarker(dir) != std::optional(manifest.version)) return std::nullopt; return xex; } GameInstall Found(const fs::path& dir, const fs::path& xex, InstallOrigin origin, const Manifest& manifest) { std::error_code ec; GameInstall install; fs::path absolute = fs::absolute(dir, ec); install.path = (ec ? dir : absolute).lexically_normal(); install.xex = install.path / xex.filename(); install.origin = origin; install.marker = ReadInstallMarker(dir) == std::optional(manifest.version); return install; } void Consider(const fs::path& file, uint64_t minSize, const Manifest& manifest, std::set& seen, std::vector& found) { std::string extension = file.extension().string(); if (!PathEqualsIgnoreCase(extension, ".iso")) return; std::error_code ec; uint64_t size = fs::file_size(file, ec); if (ec || size < minSize) return; fs::path key = fs::weakly_canonical(file, ec); if (!seen.insert(ec ? file : key).second) return; // Only XDVDFS images whose tree reads count; a truncated one is kept // (with its status) so the UI can say why it won't install rather // than leave the user wondering where their image went. std::unique_ptr source; Result opened = OpenDiscSource(file, source, { .allowTruncated = true }); if (!source || !source->Find("default.xex")) return; DiscImageCandidate candidate; candidate.path = file; candidate.size = size; XexIdentity identity; Result checked = CheckGameVersion(*source, manifest, &identity); candidate.titleId = identity.titleId; candidate.xexMatches = checked.Ok(); candidate.status = opened.Ok() ? checked : opened; found.push_back(std::move(candidate)); } } bool IsUsableInstall(const std::filesystem::path& dir, bool requireMarker, const Manifest& manifest) { return UsableXex(dir, requireMarker, manifest).has_value(); } std::optional FindGameInstall(const Manifest& manifest) { if (const char* env = std::getenv("NFSMW_GAME_DIR"); env && *env) { if (std::optional xex = UsableXex(env, false, manifest)) return Found(env, *xex, InstallOrigin::Environment, manifest); fprintf(stderr, "[install] NFSMW_GAME_DIR=%s has no default.xex of %llu bytes; ignoring it\n", env, (unsigned long long)manifest.Xex().size); } if (std::optional xex = UsableXex("game/files", false, manifest)) return Found("game/files", *xex, InstallOrigin::Development, manifest); // An install the player put on another drive. if (std::optional recorded = RecordedInstallPath()) { RecoverInterruptedInstall(*recorded, manifest); if (std::optional xex = UsableXex(*recorded, true, manifest)) return Found(*recorded, *xex, InstallOrigin::User, manifest); // Why, for the log (the installer screen says it to the player: // DescribeRecordedInstall). std::error_code ec; fs::file_status status = fs::status(*recorded, ec); std::string why = status.type() == fs::file_type::not_found ? "isn't there now (a drive not connected?)" : ec ? std::format("can't be read ({})", ec.message()) : "isn't a current install (files missing, or an older version)"; fprintf(stderr, "[install] the game was installed in %s, which %s\n", recorded->string().c_str(), why.c_str()); } fs::path user = DefaultInstallPath(); // A crash in the middle of a reinstall can leave the old install at // .previous, with nothing (or the new install) in its place. RecoverInterruptedInstall(user, manifest); if (std::optional xex = UsableXex(user, true, manifest)) return Found(user, *xex, InstallOrigin::User, manifest); std::error_code ec; if (fs::exists(user / "default.xex", ec)) { std::optional marker = ReadInstallMarker(user); fprintf(stderr, "[install] %s is not a current install (marker %s, manifest %.*s): reinstall\n", user.c_str(), marker ? marker->c_str() : "missing", int(manifest.version.size()), manifest.version.data()); } return std::nullopt; } namespace { fs::path LocationFile() { return GetUserPath() / "game-location"; } // A folder that is the root of a mounted file system. bool IsMountPoint(const fs::path& dir) { struct stat self{}, parent{}; if (stat(dir.c_str(), &self) != 0 || stat(dir.parent_path().c_str(), &parent) != 0) return false; return S_ISDIR(self.st_mode) && self.st_dev != parent.st_dev; } } std::optional ReadInstallRecord(const std::filesystem::path& file, const portable::Layout& layout) { FILE* f = fopen(file.c_str(), "rb"); if (!f) return std::nullopt; char buffer[4096]; size_t n = fread(buffer, 1, sizeof(buffer) - 1, f); fclose(f); // Absolute, or (portable mode) relative to SpeedBreaker's folder; no // trailing separator (records before v0.1.1 kept `--dest dir/`'s). return portable::ParseRecord(std::string_view(buffer, n), layout); } bool WriteInstallRecord(const std::filesystem::path& file, const std::string& text) { std::error_code ec; fs::create_directories(file.parent_path(), ec); fs::path temp = file; temp += ".tmp"; FILE* f = fopen(temp.c_str(), "wb"); if (!f) return false; std::string line = text + "\n"; bool ok = fwrite(line.data(), 1, line.size(), f) == line.size(); ok = fflush(f) == 0 && ok; fsync(fileno(f)); fclose(f); if (ok) fs::rename(temp, file, ec); return ok && !ec; } std::optional RecordedInstallPath() { return ReadInstallRecord(LocationFile(), portable::Current()); } bool RecordInstallPath(const std::filesystem::path& dir) { std::error_code ec; fs::path file = LocationFile(); // As the installer compares folders: absolute, links resolved, no // trailing separator (`--dest dir/` and `dir` are one record). fs::path folder = ResolvedFolder(dir); if (SameFolder(folder, DefaultInstallPath())) { fs::remove(file, ec); return !ec; } // In portable mode, a folder inside SpeedBreaker's is recorded // relative to it, so the record still holds once the folder moves. return WriteInstallRecord(file, portable::RecordText(folder, portable::Current())); } std::vector InstallDestinations() { std::vector out; // Portable mode: SpeedBreaker's own folder (its data/game), and no // drive's folder, which would leave the game behind when the folder // moves. Choose a folder... still takes any folder. if (portable::Current().Active()) { out.push_back({ "SpeedBreaker's folder", DefaultInstallPath(), false }); return out; } out.push_back({ "Internal storage", DefaultInstallPath(), false }); auto addVolume = [&](const fs::path& volume) { if (Hidden(volume) || !IsMountPoint(volume)) return; // Not read-only drives (a disc), nor the system's hidden volumes // (macOS Recovery, Preboot, VM). struct statvfs vfs{}; if (statvfs(volume.c_str(), &vfs) != 0 || (vfs.f_flag & ST_RDONLY)) return; #ifdef __APPLE__ struct statfs sfs{}; if (statfs(volume.c_str(), &sfs) != 0 || (sfs.f_flags & MNT_DONTBROWSE)) return; #endif std::string label = volume.filename().string(); out.push_back({ label.empty() ? volume.string() : label, volume / kUserFolderName / "game", true }); }; std::error_code ec; #ifdef __APPLE__ for (fs::directory_iterator it("/Volumes", ec), end; !ec && it != end; it.increment(ec)) { std::error_code sameEc; if (!fs::equivalent(it->path(), "/", sameEc)) addVolume(it->path()); } #else std::string user = UserName(); std::vector parents = { "/run/media", "/media" }; if (!user.empty()) { parents.push_back(fs::path("/run/media") / user); parents.push_back(fs::path("/media") / user); } for (const fs::path& parent : parents) for (fs::directory_iterator it(parent, fs::directory_options::skip_permission_denied, ec), end; !ec && it != end; it.increment(ec)) addVolume(it->path()); #endif return out; } std::vector DiscImageSearchFolders() { std::vector folders; auto add = [&](const fs::path& dir) { std::error_code ec; if (fs::is_directory(dir, ec) && std::find(folders.begin(), folders.end(), dir) == folders.end()) folders.push_back(dir); }; if (const char* home = std::getenv("HOME"); home && *home) { fs::path h = home; add(h); for (const char* sub : { "Downloads", "Desktop", "Documents", "Games" }) add(h / sub); } #ifdef __APPLE__ // /Volumes also lists the startup disk (a link to /): skip it, a scan of // the whole system disk is what the folders above avoid. std::error_code ec; for (fs::directory_iterator it("/Volumes", ec), end; !ec && it != end; it.increment(ec)) { std::error_code sameEc; if (!Hidden(it->path()) && !fs::equivalent(it->path(), "/", sameEc)) add(it->path()); } #else // SteamOS mounts SD cards and USB drives at /run/media//