mirror of
https://github.com/TwilitRealm/dusklight
synced 2026-06-22 06:56:31 -04:00
Customizable data directory & migration (#1059)
* Customizable data directory & migration * Add file/dir rename fast-path * Write data_location.json to base path on Windows; fix UTF-8 custom paths * Build fix * Another build fix * Android data directory selection * Fix CMake target ref
This commit is contained in:
@@ -536,6 +536,13 @@ if (APPLE)
|
||||
)
|
||||
endif ()
|
||||
|
||||
if (APPLE AND NOT IOS AND NOT TVOS)
|
||||
find_library(APPKIT_FRAMEWORK AppKit REQUIRED)
|
||||
target_sources(dusklight PRIVATE src/dusk/file_select_macos.mm)
|
||||
set_source_files_properties(src/dusk/file_select_macos.mm PROPERTIES COMPILE_FLAGS -fobjc-arc)
|
||||
target_link_libraries(dusklight PRIVATE ${APPKIT_FRAMEWORK})
|
||||
endif ()
|
||||
|
||||
if (IOS)
|
||||
find_library(UIKIT_FRAMEWORK UIKit REQUIRED)
|
||||
find_library(UNIFORM_TYPE_IDENTIFIERS_FRAMEWORK UniformTypeIdentifiers REQUIRED)
|
||||
|
||||
Vendored
+1
-1
Submodule extern/aurora updated: 211bfb00a8...83cdfb40f6
@@ -1420,6 +1420,8 @@ set(DUSK_FILES
|
||||
src/dusk/asserts.cpp
|
||||
src/dusk/config.cpp
|
||||
src/dusk/crash_reporting.cpp
|
||||
src/dusk/data.cpp
|
||||
src/dusk/data.hpp
|
||||
src/dusk/endian.cpp
|
||||
src/dusk/extras.c
|
||||
src/dusk/file_select.cpp
|
||||
|
||||
@@ -9,6 +9,11 @@ namespace dusk {
|
||||
*/
|
||||
constexpr auto AppName = "Dusklight";
|
||||
|
||||
/**
|
||||
* Previous AppName to migrate data from.
|
||||
*/
|
||||
constexpr auto LegacyAppName = "Dusk";
|
||||
|
||||
/**
|
||||
* \brief The internal organization name for the game.
|
||||
*
|
||||
|
||||
+12
-23
@@ -1,36 +1,25 @@
|
||||
#ifndef DUSK_MAIN_H
|
||||
#define DUSK_MAIN_H
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#if defined(_WIN32) || \
|
||||
(defined(__APPLE__) && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_MACCATALYST) || \
|
||||
(defined(__linux__) && !defined(__ANDROID__))
|
||||
#define DUSK_CAN_OPEN_DATA_FOLDER 1
|
||||
#else
|
||||
#define DUSK_CAN_OPEN_DATA_FOLDER 0
|
||||
#endif
|
||||
|
||||
namespace dusk {
|
||||
extern bool IsRunning;
|
||||
extern bool IsShuttingDown;
|
||||
extern bool IsGameLaunched;
|
||||
extern bool RestartRequested;
|
||||
extern std::filesystem::path ConfigPath;
|
||||
|
||||
#if defined(__ANDROID__) || (defined(TARGET_OS_IOS) && TARGET_OS_IOS) || \
|
||||
extern bool IsRunning;
|
||||
extern bool IsShuttingDown;
|
||||
extern bool IsGameLaunched;
|
||||
extern bool RestartRequested;
|
||||
extern std::filesystem::path ConfigPath;
|
||||
|
||||
#if defined(__ANDROID__) || (defined(TARGET_OS_IOS) && TARGET_OS_IOS) || \
|
||||
(defined(TARGET_OS_TV) && TARGET_OS_TV)
|
||||
inline constexpr bool SupportsProcessRestart = false;
|
||||
inline constexpr bool SupportsProcessRestart = false;
|
||||
#else
|
||||
inline constexpr bool SupportsProcessRestart = true;
|
||||
inline constexpr bool SupportsProcessRestart = true;
|
||||
#endif
|
||||
|
||||
void RequestRestart() noexcept;
|
||||
bool OpenDataFolder();
|
||||
}
|
||||
void RequestRestart() noexcept;
|
||||
|
||||
} // namespace dusk
|
||||
|
||||
#endif // DUSK_MAIN_H
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package dev.twilitrealm.dusk;
|
||||
|
||||
import android.app.ActionBar;
|
||||
import android.app.Activity;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ClipData;
|
||||
import android.content.Intent;
|
||||
import android.database.Cursor;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.DocumentsContract;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.util.Log;
|
||||
import android.view.View;
|
||||
@@ -22,6 +26,13 @@ import java.util.List;
|
||||
|
||||
public class DuskActivity extends SDLActivity {
|
||||
private static final String TAG = "DuskActivity";
|
||||
private static final int FOLDER_DIALOG_REQUEST_CODE = 0x4455;
|
||||
private static final String EXTERNAL_STORAGE_AUTHORITY =
|
||||
"com.android.externalstorage.documents";
|
||||
|
||||
private long folderDialogUserdata = 0;
|
||||
|
||||
private static native void nativeFolderDialogResult(long userdata, String path, String error);
|
||||
|
||||
private static String[] splitArgs(String raw) {
|
||||
List<String> out = new ArrayList<>();
|
||||
@@ -147,9 +158,154 @@ public class DuskActivity extends SDLActivity {
|
||||
if (resultCode == RESULT_OK) {
|
||||
persistUriPermissions(data);
|
||||
}
|
||||
if (requestCode == FOLDER_DIALOG_REQUEST_CODE) {
|
||||
finishFolderDialog(resultCode, data);
|
||||
return;
|
||||
}
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
public boolean showFolderDialog(long userdata) {
|
||||
if (userdata == 0 || folderDialogUserdata != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
folderDialogUserdata = userdata;
|
||||
runOnUiThread(() -> {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION |
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
|
||||
|
||||
try {
|
||||
startActivityForResult(intent, FOLDER_DIALOG_REQUEST_CODE);
|
||||
} catch (ActivityNotFoundException e) {
|
||||
Log.w(TAG, "Unable to open folder dialog.", e);
|
||||
finishFolderDialog(Activity.RESULT_CANCELED, null);
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private void finishFolderDialog(int resultCode, Intent data) {
|
||||
long userdata = folderDialogUserdata;
|
||||
folderDialogUserdata = 0;
|
||||
if (userdata == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (resultCode == RESULT_OK && data != null && data.getData() != null) {
|
||||
String path = getRealPathForUri(data.getData());
|
||||
if (path != null && !path.isEmpty()) {
|
||||
nativeFolderDialogResult(userdata, path, null);
|
||||
} else {
|
||||
nativeFolderDialogResult(
|
||||
userdata, null, "Selected folder is not available as a filesystem path");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
nativeFolderDialogResult(userdata, null, null);
|
||||
}
|
||||
|
||||
private String getRealPathForUri(Uri uri) {
|
||||
if (uri == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String scheme = uri.getScheme();
|
||||
if ("file".equals(scheme)) {
|
||||
return uri.getPath();
|
||||
}
|
||||
|
||||
if (!"content".equals(scheme) ||
|
||||
!EXTERNAL_STORAGE_AUTHORITY.equals(uri.getAuthority()) ||
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return getExternalStoragePathForDocumentId(getExternalStorageDocumentId(uri));
|
||||
} catch (IllegalArgumentException e) {
|
||||
Log.w(TAG, "Unable to resolve URI: " + uri, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String getExternalStorageDocumentId(Uri uri) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && isTreeDocumentUri(uri)) {
|
||||
return DocumentsContract.getTreeDocumentId(uri);
|
||||
}
|
||||
|
||||
return DocumentsContract.getDocumentId(uri);
|
||||
}
|
||||
|
||||
private static boolean isTreeDocumentUri(Uri uri) {
|
||||
List<String> segments = uri.getPathSegments();
|
||||
return segments.size() >= 2 && "tree".equals(segments.get(0));
|
||||
}
|
||||
|
||||
private String getExternalStoragePathForDocumentId(String documentId) {
|
||||
if (documentId == null || documentId.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (documentId.startsWith("raw:")) {
|
||||
return documentId.substring("raw:".length());
|
||||
}
|
||||
|
||||
String[] parts = documentId.split(":", 2);
|
||||
String volumeId = parts[0];
|
||||
String relativePath = parts.length > 1 ? parts[1] : "";
|
||||
|
||||
File root = getExternalStorageRoot(volumeId);
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return relativePath.isEmpty()
|
||||
? root.getAbsolutePath()
|
||||
: new File(root, relativePath).getAbsolutePath();
|
||||
}
|
||||
|
||||
private File getExternalStorageRoot(String volumeId) {
|
||||
if ("primary".equalsIgnoreCase(volumeId)) {
|
||||
return Environment.getExternalStorageDirectory();
|
||||
}
|
||||
if ("home".equalsIgnoreCase(volumeId)) {
|
||||
return new File(
|
||||
Environment.getExternalStorageDirectory(), Environment.DIRECTORY_DOCUMENTS);
|
||||
}
|
||||
|
||||
File[] externalFilesDirs = getExternalFilesDirs(null);
|
||||
if (externalFilesDirs != null) {
|
||||
for (File externalFilesDir : externalFilesDirs) {
|
||||
File root = getStorageRootForExternalFilesDir(externalFilesDir);
|
||||
if (root != null && volumeId.equalsIgnoreCase(root.getName())) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File fallback = new File("/storage", volumeId);
|
||||
return fallback.exists() ? fallback : null;
|
||||
}
|
||||
|
||||
private File getStorageRootForExternalFilesDir(File externalFilesDir) {
|
||||
if (externalFilesDir == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String path = externalFilesDir.getAbsolutePath();
|
||||
int androidDir = path.indexOf("/Android/");
|
||||
if (androidDir <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new File(path.substring(0, androidDir));
|
||||
}
|
||||
|
||||
private void persistUriPermissions(Intent data) {
|
||||
if (data == null) {
|
||||
return;
|
||||
|
||||
+55
-1
@@ -14,15 +14,22 @@ import android.provider.DocumentsContract.Root;
|
||||
import android.provider.DocumentsProvider;
|
||||
import android.webkit.MimeTypeMap;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
public class DuskDocumentsProvider extends DocumentsProvider {
|
||||
public static final String AUTHORITY = "dev.twilitrealm.dusk.documents";
|
||||
|
||||
private static final String ROOT_ID = "dusk";
|
||||
private static final String ROOT_DOCUMENT_ID = "root";
|
||||
private static final String LOCATION_DESCRIPTOR_NAME = "data_location.json";
|
||||
private static final String DIRECTORY_MIME_TYPE = Document.MIME_TYPE_DIR;
|
||||
|
||||
private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
|
||||
@@ -46,13 +53,19 @@ public class DuskDocumentsProvider extends DocumentsProvider {
|
||||
|
||||
@Override
|
||||
public boolean onCreate() {
|
||||
ensureUserDirectories();
|
||||
if (!isCustomDataPathEnabled()) {
|
||||
ensureUserDirectories();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
|
||||
final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
|
||||
if (isCustomDataPathEnabled()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
final File root = getRootDirectory();
|
||||
final MatrixCursor.RowBuilder row = result.newRow();
|
||||
|
||||
@@ -222,6 +235,11 @@ public class DuskDocumentsProvider extends DocumentsProvider {
|
||||
}
|
||||
|
||||
private File getRootDirectory() throws FileNotFoundException {
|
||||
if (isCustomDataPathEnabled()) {
|
||||
throw new FileNotFoundException(
|
||||
"Dusk DocumentsProvider is disabled while a custom data path is configured");
|
||||
}
|
||||
|
||||
final File root = getContext().getFilesDir();
|
||||
if (root == null) {
|
||||
throw new FileNotFoundException("Dusklight files directory is unavailable");
|
||||
@@ -273,6 +291,42 @@ public class DuskDocumentsProvider extends DocumentsProvider {
|
||||
new File(root, "EUR/Card A").mkdirs();
|
||||
}
|
||||
|
||||
private boolean isCustomDataPathEnabled() {
|
||||
if (getContext() == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final File filesDir = getContext().getFilesDir();
|
||||
if (filesDir == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final File descriptor = new File(filesDir, LOCATION_DESCRIPTOR_NAME);
|
||||
if (!descriptor.isFile()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
final JSONObject json = new JSONObject(readText(descriptor));
|
||||
return "custom".equals(json.optString("mode", "default"));
|
||||
} catch (IOException | JSONException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String readText(File file) throws IOException {
|
||||
try (FileInputStream input = new FileInputStream(file);
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream())
|
||||
{
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, bytesRead);
|
||||
}
|
||||
return output.toString(StandardCharsets.UTF_8.name());
|
||||
}
|
||||
}
|
||||
|
||||
private static String[] resolveRootProjection(String[] projection) {
|
||||
return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,12 @@ window content pane:last-of-type > div {
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
.data-folder-current {
|
||||
display: block;
|
||||
font-size: 16dp;
|
||||
color: rgba(224, 219, 200, 65%);
|
||||
}
|
||||
|
||||
window content pane > spacer {
|
||||
display: block;
|
||||
/* Completes the 24dp bottom inset after the pane's 8dp gap. */
|
||||
@@ -199,6 +205,11 @@ button:not(:disabled):active {
|
||||
box-shadow: #C2A42D 0 0 0 2dp;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.modal-btn {
|
||||
flex: 1 1 0;
|
||||
text-align: center;
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
#include "data.hpp"
|
||||
|
||||
#include "dusk/app_info.hpp"
|
||||
#include "dusk/io.hpp"
|
||||
#include "dusk/logging.h"
|
||||
#include "dusk/main.h"
|
||||
|
||||
#include <array>
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <ranges>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
#include <SDL3/SDL_filesystem.h>
|
||||
#include <SDL3/SDL_iostream.h>
|
||||
#include <SDL3/SDL_misc.h>
|
||||
#include <SDL3/SDL_stdinc.h>
|
||||
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
namespace dusk::data {
|
||||
namespace {
|
||||
|
||||
aurora::Module Log{"dusk::data"};
|
||||
|
||||
constexpr auto kLocationDescriptorName = "data_location.json";
|
||||
constexpr auto kPipelineCacheName = "pipeline_cache.db";
|
||||
constexpr auto kInitialPipelineCacheName = "initial_pipeline_cache.db";
|
||||
|
||||
enum class LocationMode {
|
||||
Default,
|
||||
Portable,
|
||||
Custom,
|
||||
};
|
||||
|
||||
struct LocationDescriptor {
|
||||
LocationMode mode = LocationMode::Default;
|
||||
std::filesystem::path customPath;
|
||||
std::filesystem::path previousPath;
|
||||
};
|
||||
|
||||
struct LocatedDescriptor {
|
||||
LocationDescriptor descriptor;
|
||||
std::filesystem::path path;
|
||||
};
|
||||
|
||||
struct MigrationStats {
|
||||
std::uintmax_t directoriesCreated = 0;
|
||||
std::uintmax_t filesCopied = 0;
|
||||
std::uintmax_t symlinksCopied = 0;
|
||||
std::uintmax_t sourcesRemoved = 0;
|
||||
std::uintmax_t emptyDirectoriesRemoved = 0;
|
||||
std::uintmax_t skippedExistingTargets = 0;
|
||||
std::uintmax_t skippedDescriptorFiles = 0;
|
||||
std::uintmax_t skippedNestedTargets = 0;
|
||||
std::uintmax_t skippedUnsupportedEntries = 0;
|
||||
std::uintmax_t failures = 0;
|
||||
};
|
||||
|
||||
std::optional<std::filesystem::path> sConfiguredDataPath;
|
||||
std::optional<std::filesystem::path> sActiveDescriptorPath;
|
||||
|
||||
std::filesystem::path path_from_utf8(std::string_view value) {
|
||||
return std::filesystem::path{
|
||||
reinterpret_cast<const char8_t*>(value.data()),
|
||||
reinterpret_cast<const char8_t*>(value.data() + value.size()),
|
||||
};
|
||||
}
|
||||
|
||||
std::filesystem::path get_legacy_path() {
|
||||
if (std::string_view{LegacyAppName}.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
char* prefPath = SDL_GetPrefPath(OrgName, LegacyAppName);
|
||||
if (!prefPath) {
|
||||
Log.fatal("Unable to get PrefPath: {}", SDL_GetError());
|
||||
}
|
||||
|
||||
std::filesystem::path result{reinterpret_cast<const char8_t*>(prefPath)};
|
||||
SDL_free(prefPath);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::filesystem::path get_pref_path() {
|
||||
char* prefPath = SDL_GetPrefPath(OrgName, AppName);
|
||||
if (!prefPath) {
|
||||
Log.fatal("Unable to get PrefPath: {}", SDL_GetError());
|
||||
}
|
||||
|
||||
std::filesystem::path result{reinterpret_cast<const char8_t*>(prefPath)};
|
||||
SDL_free(prefPath);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::filesystem::path base_path_relative(const std::filesystem::path& path) {
|
||||
const auto* basePath = SDL_GetBasePath();
|
||||
if (!basePath) {
|
||||
return path;
|
||||
}
|
||||
return std::filesystem::path{basePath} / path;
|
||||
}
|
||||
|
||||
std::filesystem::path default_data_path(const std::filesystem::path& prefPath) {
|
||||
#ifdef __APPLE__
|
||||
#if TARGET_OS_IOS && !TARGET_OS_TV
|
||||
const char* documentsPath = SDL_GetUserFolder(SDL_FOLDER_DOCUMENTS);
|
||||
if (!documentsPath) {
|
||||
Log.fatal("Unable to get iOS Documents path: {}", SDL_GetError());
|
||||
}
|
||||
|
||||
return reinterpret_cast<const char8_t*>(documentsPath);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
return prefPath;
|
||||
}
|
||||
|
||||
std::filesystem::path portable_data_path() {
|
||||
return base_path_relative("data");
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> descriptor_paths(const std::filesystem::path& prefPath) {
|
||||
std::vector<std::filesystem::path> paths;
|
||||
if (const auto basePath = base_path_relative(kLocationDescriptorName); !basePath.empty()) {
|
||||
paths.push_back(basePath);
|
||||
}
|
||||
paths.push_back(prefPath / kLocationDescriptorName);
|
||||
return paths;
|
||||
}
|
||||
|
||||
std::optional<LocationDescriptor> read_location_descriptor_file(const std::filesystem::path& path) {
|
||||
if (path.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (std::error_code ec; !std::filesystem::exists(path, ec)) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
try {
|
||||
const auto bytes = io::FileStream::ReadAllBytes(path);
|
||||
const auto json = nlohmann::json::parse(bytes);
|
||||
if (!json.is_object()) {
|
||||
Log.warn("Ignoring data location descriptor '{}': root is not an object",
|
||||
io::fs_path_to_string(path));
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
LocationDescriptor descriptor;
|
||||
const auto mode = json.value<std::string>("mode", "default");
|
||||
if (mode == "portable") {
|
||||
descriptor.mode = LocationMode::Portable;
|
||||
} else if (mode == "custom") {
|
||||
descriptor.mode = LocationMode::Custom;
|
||||
} else if (mode != "default") {
|
||||
Log.warn("Ignoring unknown data location mode '{}'", mode);
|
||||
}
|
||||
|
||||
if (const auto customPath = json.find("customPath");
|
||||
customPath != json.end() && customPath->is_string())
|
||||
{
|
||||
descriptor.customPath = path_from_utf8(customPath->get<std::string>());
|
||||
}
|
||||
if (const auto previousPath = json.find("previousPath");
|
||||
previousPath != json.end() && previousPath->is_string())
|
||||
{
|
||||
descriptor.previousPath = path_from_utf8(previousPath->get<std::string>());
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
} catch (const std::exception& e) {
|
||||
Log.warn(
|
||||
"Ignoring data location descriptor '{}': {}", io::fs_path_to_string(path), e.what());
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<LocatedDescriptor> read_location_descriptor(const std::filesystem::path& prefPath) {
|
||||
for (const auto& path : descriptor_paths(prefPath)) {
|
||||
if (auto descriptor = read_location_descriptor_file(path)) {
|
||||
return LocatedDescriptor{
|
||||
.descriptor = *descriptor,
|
||||
.path = path,
|
||||
};
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::filesystem::path resolve_data_path(
|
||||
const std::filesystem::path& prefPath, const LocationDescriptor* descriptor) {
|
||||
if (!descriptor) {
|
||||
return default_data_path(prefPath);
|
||||
}
|
||||
|
||||
switch (descriptor->mode) {
|
||||
case LocationMode::Default:
|
||||
return default_data_path(prefPath);
|
||||
case LocationMode::Portable:
|
||||
return portable_data_path();
|
||||
case LocationMode::Custom:
|
||||
if (!descriptor->customPath.empty()) {
|
||||
return descriptor->customPath;
|
||||
}
|
||||
Log.warn("Data location descriptor requested custom mode without a path");
|
||||
return default_data_path(prefPath);
|
||||
}
|
||||
|
||||
return default_data_path(prefPath);
|
||||
}
|
||||
|
||||
const char* location_mode_id(LocationMode mode) {
|
||||
switch (mode) {
|
||||
case LocationMode::Default:
|
||||
return "default";
|
||||
case LocationMode::Portable:
|
||||
return "portable";
|
||||
case LocationMode::Custom:
|
||||
return "custom";
|
||||
}
|
||||
|
||||
return "default";
|
||||
}
|
||||
|
||||
std::filesystem::path normalized_path(const std::filesystem::path& path) {
|
||||
std::error_code ec;
|
||||
auto normalized = std::filesystem::weakly_canonical(path, ec);
|
||||
if (!ec) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
normalized = std::filesystem::absolute(path, ec);
|
||||
if (!ec) {
|
||||
return normalized.lexically_normal();
|
||||
}
|
||||
|
||||
return path.lexically_normal();
|
||||
}
|
||||
|
||||
std::filesystem::path absolute_path(const std::filesystem::path& path) {
|
||||
std::error_code ec;
|
||||
const auto absolute = std::filesystem::absolute(path, ec);
|
||||
if (ec) {
|
||||
return path;
|
||||
}
|
||||
return absolute.lexically_normal();
|
||||
}
|
||||
|
||||
bool is_same_or_inside(const std::filesystem::path& root, const std::filesystem::path& path) {
|
||||
const auto normalizedRoot = normalized_path(root);
|
||||
const auto normalizedPath = normalized_path(path);
|
||||
const auto relativePath = normalizedPath.lexically_relative(normalizedRoot);
|
||||
if (relativePath.empty()) {
|
||||
return normalizedPath == normalizedRoot;
|
||||
}
|
||||
if (relativePath == ".") {
|
||||
return true;
|
||||
}
|
||||
if (relativePath.is_absolute()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto it = relativePath.begin();
|
||||
return it == relativePath.end() || *it != "..";
|
||||
}
|
||||
|
||||
bool should_skip_migration_path(const std::filesystem::path& path,
|
||||
const std::filesystem::path& from, const std::filesystem::path& to, MigrationStats& stats) {
|
||||
if (is_same_or_inside(to, path)) {
|
||||
++stats.skippedNestedTargets;
|
||||
return true;
|
||||
}
|
||||
|
||||
const auto relativePath = path.lexically_relative(from);
|
||||
if (relativePath == kLocationDescriptorName) {
|
||||
++stats.skippedDescriptorFiles;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool has_location_descriptor(const std::filesystem::path& path) {
|
||||
std::error_code ec;
|
||||
return std::filesystem::exists(path / kLocationDescriptorName, ec);
|
||||
}
|
||||
|
||||
bool remove_empty_destination_for_rename(const std::filesystem::path& path) {
|
||||
std::error_code ec;
|
||||
const bool exists = std::filesystem::exists(path, ec);
|
||||
if (ec) {
|
||||
Log.debug("Could not inspect migration destination '{}': {}", io::fs_path_to_string(path),
|
||||
ec.message());
|
||||
return false;
|
||||
}
|
||||
if (!exists) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bool canRemove = std::filesystem::is_directory(path, ec) &&
|
||||
std::filesystem::is_empty(path, ec) && !has_location_descriptor(path);
|
||||
if (ec || !canRemove) {
|
||||
if (ec) {
|
||||
Log.debug("Could not inspect migration destination '{}': {}",
|
||||
io::fs_path_to_string(path), ec.message());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::filesystem::remove(path, ec);
|
||||
if (ec) {
|
||||
Log.debug("Could not remove empty migration destination '{}': {}",
|
||||
io::fs_path_to_string(path), ec.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool try_rename_directory_migration(
|
||||
const std::filesystem::path& from, const std::filesystem::path& to) {
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::is_directory(from, ec)) {
|
||||
return false;
|
||||
}
|
||||
if (ec) {
|
||||
Log.debug("Could not inspect migration source '{}': {}", io::fs_path_to_string(from),
|
||||
ec.message());
|
||||
return false;
|
||||
}
|
||||
if (has_location_descriptor(from)) {
|
||||
return false;
|
||||
}
|
||||
if (!remove_empty_destination_for_rename(to)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::filesystem::create_directories(to.parent_path(), ec);
|
||||
if (ec) {
|
||||
Log.debug("Could not create migration destination parent '{}': {}",
|
||||
io::fs_path_to_string(to.parent_path()), ec.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::filesystem::rename(from, to, ec);
|
||||
if (ec) {
|
||||
Log.debug("Could not rename data directory '{}' to '{}': {}", io::fs_path_to_string(from),
|
||||
io::fs_path_to_string(to), ec.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.info("Renamed data directory '{}' to '{}'", io::fs_path_to_string(from),
|
||||
io::fs_path_to_string(to));
|
||||
return true;
|
||||
}
|
||||
|
||||
std::filesystem::path current_data_path() {
|
||||
if (!ConfigPath.empty()) {
|
||||
return ConfigPath;
|
||||
}
|
||||
const auto prefPath = get_pref_path();
|
||||
const auto descriptor = read_location_descriptor(prefPath);
|
||||
if (descriptor) {
|
||||
sActiveDescriptorPath = descriptor->path;
|
||||
}
|
||||
return resolve_data_path(prefPath, descriptor ? &descriptor->descriptor : nullptr);
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> descriptor_write_paths(const std::filesystem::path& prefPath) {
|
||||
if (sActiveDescriptorPath && !sActiveDescriptorPath->empty()) {
|
||||
return {*sActiveDescriptorPath};
|
||||
}
|
||||
|
||||
std::vector<std::filesystem::path> paths;
|
||||
#if defined(_WIN32)
|
||||
if (const auto basePath = base_path_relative(kLocationDescriptorName); !basePath.empty()) {
|
||||
paths.push_back(basePath);
|
||||
}
|
||||
#endif
|
||||
paths.push_back(prefPath / kLocationDescriptorName);
|
||||
return paths;
|
||||
}
|
||||
|
||||
bool write_descriptor_json(const std::filesystem::path& path, const nlohmann::json& json) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path.parent_path(), ec);
|
||||
if (ec) {
|
||||
Log.warn("Failed to create data location descriptor directory '{}': {}",
|
||||
io::fs_path_to_string(path.parent_path()), ec.message());
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
io::FileStream::WriteAllText(path, json.dump(4));
|
||||
} catch (const std::exception& e) {
|
||||
Log.warn("Failed to write data location descriptor '{}': {}", io::fs_path_to_string(path),
|
||||
e.what());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write_location_descriptor(LocationMode mode, const std::filesystem::path& targetPath) {
|
||||
LocationDescriptor descriptor;
|
||||
descriptor.mode = mode;
|
||||
if (mode == LocationMode::Custom) {
|
||||
descriptor.customPath = absolute_path(targetPath);
|
||||
}
|
||||
|
||||
const auto currentPath = current_data_path();
|
||||
const auto resolvedTargetPath =
|
||||
mode == LocationMode::Custom ? descriptor.customPath : targetPath;
|
||||
if (!currentPath.empty() && normalized_path(currentPath) != normalized_path(resolvedTargetPath))
|
||||
{
|
||||
descriptor.previousPath = currentPath;
|
||||
}
|
||||
|
||||
nlohmann::json json;
|
||||
json["version"] = 1;
|
||||
json["mode"] = location_mode_id(descriptor.mode);
|
||||
if (descriptor.mode == LocationMode::Custom && !descriptor.customPath.empty()) {
|
||||
json["customPath"] = io::fs_path_to_string(descriptor.customPath);
|
||||
}
|
||||
if (!descriptor.previousPath.empty()) {
|
||||
json["previousPath"] = io::fs_path_to_string(descriptor.previousPath);
|
||||
}
|
||||
|
||||
const auto prefPath = get_pref_path();
|
||||
for (const auto& path : descriptor_write_paths(prefPath)) {
|
||||
if (write_descriptor_json(path, json)) {
|
||||
sActiveDescriptorPath = path;
|
||||
sConfiguredDataPath = resolvedTargetPath;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
std::uintmax_t remove_empty_directories(const std::filesystem::path& root, bool includeRoot) {
|
||||
std::error_code ec;
|
||||
std::vector<std::filesystem::path> directories;
|
||||
for (std::filesystem::recursive_directory_iterator it(
|
||||
root, std::filesystem::directory_options::skip_permission_denied, ec);
|
||||
it != std::filesystem::recursive_directory_iterator(); it.increment(ec))
|
||||
{
|
||||
if (ec) {
|
||||
Log.warn("Failed to scan empty directories under '{}': {}", io::fs_path_to_string(root),
|
||||
ec.message());
|
||||
return 0;
|
||||
}
|
||||
const auto status = it->symlink_status(ec);
|
||||
if (ec) {
|
||||
Log.warn("Failed to inspect '{}' while pruning empty directories: {}",
|
||||
io::fs_path_to_string(it->path()), ec.message());
|
||||
ec.clear();
|
||||
continue;
|
||||
}
|
||||
if (std::filesystem::is_directory(status)) {
|
||||
directories.push_back(it->path());
|
||||
}
|
||||
}
|
||||
|
||||
std::uintmax_t removed = 0;
|
||||
for (auto& dir : std::views::reverse(directories)) {
|
||||
if (!std::filesystem::is_empty(dir, ec)) {
|
||||
ec.clear();
|
||||
continue;
|
||||
}
|
||||
if (std::filesystem::remove(dir, ec)) {
|
||||
++removed;
|
||||
} else if (ec) {
|
||||
Log.warn("Failed to remove empty migrated source directory '{}': {}",
|
||||
io::fs_path_to_string(dir), ec.message());
|
||||
}
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
if (includeRoot) {
|
||||
if (std::filesystem::is_empty(root, ec)) {
|
||||
if (std::filesystem::remove(root, ec)) {
|
||||
++removed;
|
||||
} else if (ec) {
|
||||
Log.warn("Failed to remove empty migrated source root '{}': {}",
|
||||
io::fs_path_to_string(root), ec.message());
|
||||
}
|
||||
}
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
bool ensure_parent_directory(const std::filesystem::path& targetPath, MigrationStats& stats) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(targetPath.parent_path(), ec);
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to create migration target parent '{}': {}",
|
||||
io::fs_path_to_string(targetPath.parent_path()), ec.message());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool remove_migrated_source(const std::filesystem::path& sourcePath, MigrationStats& stats) {
|
||||
std::error_code ec;
|
||||
std::filesystem::remove(sourcePath, ec);
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Migrated '{}' but failed to remove source: {}", io::fs_path_to_string(sourcePath),
|
||||
ec.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
++stats.sourcesRemoved;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool try_rename_migration_entry(
|
||||
const std::filesystem::path& sourcePath, const std::filesystem::path& targetPath) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(targetPath, ec) || std::filesystem::is_symlink(targetPath, ec)) {
|
||||
return false;
|
||||
}
|
||||
ec.clear();
|
||||
|
||||
if (!std::filesystem::exists(sourcePath, ec)) {
|
||||
return false;
|
||||
}
|
||||
ec.clear();
|
||||
|
||||
std::filesystem::create_directories(targetPath.parent_path(), ec);
|
||||
if (ec) {
|
||||
Log.debug("Could not create migration target parent '{}' before rename: {}",
|
||||
io::fs_path_to_string(targetPath.parent_path()), ec.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::filesystem::rename(sourcePath, targetPath, ec);
|
||||
if (ec) {
|
||||
Log.debug("Could not rename migration entry '{}' to '{}': {}",
|
||||
io::fs_path_to_string(sourcePath), io::fs_path_to_string(targetPath), ec.message());
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void migrate_symlink(const std::filesystem::path& sourcePath,
|
||||
const std::filesystem::path& targetPath, MigrationStats& stats) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(targetPath, ec) || std::filesystem::is_symlink(targetPath, ec)) {
|
||||
++stats.skippedExistingTargets;
|
||||
return;
|
||||
}
|
||||
ec.clear();
|
||||
|
||||
const auto linkTarget = std::filesystem::read_symlink(sourcePath, ec);
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to read migration symlink '{}': {}", io::fs_path_to_string(sourcePath),
|
||||
ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ensure_parent_directory(targetPath, stats)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bool targetIsDirectory = std::filesystem::is_directory(sourcePath, ec);
|
||||
if (ec) {
|
||||
Log.debug("Could not resolve symlink target type for '{}': {}",
|
||||
io::fs_path_to_string(sourcePath), ec.message());
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
if (targetIsDirectory) {
|
||||
std::filesystem::create_directory_symlink(linkTarget, targetPath, ec);
|
||||
} else {
|
||||
std::filesystem::create_symlink(linkTarget, targetPath, ec);
|
||||
}
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to migrate symlink '{}' -> '{}' to '{}': {}",
|
||||
io::fs_path_to_string(sourcePath), io::fs_path_to_string(linkTarget),
|
||||
io::fs_path_to_string(targetPath), ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
++stats.symlinksCopied;
|
||||
remove_migrated_source(sourcePath, stats);
|
||||
}
|
||||
|
||||
void migrate_regular_file(const std::filesystem::path& sourcePath,
|
||||
const std::filesystem::path& targetPath, MigrationStats& stats) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(targetPath, ec)) {
|
||||
++stats.skippedExistingTargets;
|
||||
return;
|
||||
}
|
||||
ec.clear();
|
||||
|
||||
if (try_rename_migration_entry(sourcePath, targetPath)) {
|
||||
++stats.filesCopied;
|
||||
++stats.sourcesRemoved;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ensure_parent_directory(targetPath, stats)) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::filesystem::copy_file(
|
||||
sourcePath, targetPath, std::filesystem::copy_options::skip_existing, ec);
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to migrate file '{}' to '{}': {}", io::fs_path_to_string(sourcePath),
|
||||
io::fs_path_to_string(targetPath), ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
++stats.filesCopied;
|
||||
remove_migrated_source(sourcePath, stats);
|
||||
}
|
||||
|
||||
void migrate_directory(const std::filesystem::path& from, const std::filesystem::path& to,
|
||||
const std::filesystem::path& prefPath) {
|
||||
if (from.empty() || to.empty() || normalized_path(from) == normalized_path(to)) {
|
||||
Log.debug("Skipping data migration from '{}' to '{}'", io::fs_path_to_string(from),
|
||||
io::fs_path_to_string(to));
|
||||
return;
|
||||
}
|
||||
|
||||
MigrationStats stats;
|
||||
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(from, ec)) {
|
||||
if (ec) {
|
||||
Log.warn("Failed to inspect migration source '{}': {}", io::fs_path_to_string(from),
|
||||
ec.message());
|
||||
} else {
|
||||
Log.debug("Migration source '{}' does not exist", io::fs_path_to_string(from));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (try_rename_directory_migration(from, to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (try_rename_directory_migration(from, to)) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::filesystem::create_directories(to, ec);
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to create data directory '{}' for migration: {}",
|
||||
io::fs_path_to_string(to), ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
std::filesystem::recursive_directory_iterator it(
|
||||
from, std::filesystem::directory_options::skip_permission_denied, ec);
|
||||
if (ec) {
|
||||
Log.warn("Failed to begin migration scan for '{}': {}", io::fs_path_to_string(from),
|
||||
ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
const std::filesystem::recursive_directory_iterator end;
|
||||
while (it != end) {
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn(
|
||||
"Migration scan error under '{}': {}", io::fs_path_to_string(from), ec.message());
|
||||
ec.clear();
|
||||
}
|
||||
|
||||
const auto sourcePath = it->path();
|
||||
const auto status = it->symlink_status(ec);
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to inspect migration source '{}': {}",
|
||||
io::fs_path_to_string(sourcePath), ec.message());
|
||||
ec.clear();
|
||||
it.increment(ec);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (should_skip_migration_path(sourcePath, from, to, stats)) {
|
||||
if (std::filesystem::is_directory(status)) {
|
||||
it.disable_recursion_pending();
|
||||
}
|
||||
ec.clear();
|
||||
it.increment(ec);
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto relativePath = sourcePath.lexically_relative(from);
|
||||
if (relativePath.empty() || relativePath.is_absolute()) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to calculate migration relative path for '{}'",
|
||||
io::fs_path_to_string(sourcePath));
|
||||
it.increment(ec);
|
||||
continue;
|
||||
}
|
||||
|
||||
const auto targetPath = to / relativePath;
|
||||
if (std::filesystem::is_symlink(status)) {
|
||||
migrate_symlink(sourcePath, targetPath, stats);
|
||||
} else if (std::filesystem::is_directory(status)) {
|
||||
if (try_rename_migration_entry(sourcePath, targetPath)) {
|
||||
++stats.directoriesCreated;
|
||||
++stats.sourcesRemoved;
|
||||
it.disable_recursion_pending();
|
||||
} else {
|
||||
std::filesystem::create_directories(targetPath, ec);
|
||||
if (ec) {
|
||||
++stats.failures;
|
||||
Log.warn("Failed to create migration target directory '{}': {}",
|
||||
io::fs_path_to_string(targetPath), ec.message());
|
||||
ec.clear();
|
||||
it.disable_recursion_pending();
|
||||
} else {
|
||||
++stats.directoriesCreated;
|
||||
}
|
||||
}
|
||||
} else if (std::filesystem::is_regular_file(status)) {
|
||||
migrate_regular_file(sourcePath, targetPath, stats);
|
||||
} else {
|
||||
++stats.skippedUnsupportedEntries;
|
||||
}
|
||||
|
||||
it.increment(ec);
|
||||
}
|
||||
|
||||
const bool includeRoot = normalized_path(from) != normalized_path(prefPath);
|
||||
stats.emptyDirectoriesRemoved = remove_empty_directories(from, includeRoot);
|
||||
|
||||
const bool migratedAnything = stats.filesCopied > 0 || stats.symlinksCopied > 0 ||
|
||||
stats.sourcesRemoved > 0 || stats.emptyDirectoriesRemoved > 0 ||
|
||||
stats.failures > 0;
|
||||
if (migratedAnything) {
|
||||
Log.info(
|
||||
"Finished data migration from '{}' to '{}': {} files copied, {} symlinks copied, {} "
|
||||
"sources removed, {} empty directories removed, {} existing targets skipped, {} "
|
||||
"descriptor files skipped, {} nested destination paths skipped, {} unsupported entries "
|
||||
"skipped, {} failures",
|
||||
io::fs_path_to_string(from), io::fs_path_to_string(to), stats.filesCopied,
|
||||
stats.symlinksCopied, stats.sourcesRemoved, stats.emptyDirectoriesRemoved,
|
||||
stats.skippedExistingTargets, stats.skippedDescriptorFiles, stats.skippedNestedTargets,
|
||||
stats.skippedUnsupportedEntries, stats.failures);
|
||||
}
|
||||
}
|
||||
|
||||
void migrate_data(const std::filesystem::path& prefPath, const std::filesystem::path& dataPath,
|
||||
const LocationDescriptor* descriptor) {
|
||||
if (descriptor && !descriptor->previousPath.empty()) {
|
||||
migrate_directory(descriptor->previousPath, dataPath, prefPath);
|
||||
} else if (const auto legacyPath = get_legacy_path(); !legacyPath.empty()) {
|
||||
migrate_directory(legacyPath, dataPath, prefPath);
|
||||
}
|
||||
}
|
||||
|
||||
void ensure_data_directory(const std::filesystem::path& dataPath) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(dataPath, ec);
|
||||
if (ec) {
|
||||
Log.fatal("Failed to create data directory '{}': {}", io::fs_path_to_string(dataPath),
|
||||
ec.message());
|
||||
}
|
||||
}
|
||||
|
||||
SDL_IOStream* open_initial_pipeline_cache_source(std::string& sourcePathString) {
|
||||
const auto basePath = base_path_relative(kInitialPipelineCacheName);
|
||||
sourcePathString = io::fs_path_to_string(basePath);
|
||||
auto* source = SDL_IOFromFile(sourcePathString.c_str(), "rb");
|
||||
if (source != nullptr) {
|
||||
return source;
|
||||
}
|
||||
|
||||
sourcePathString = std::string{kInitialPipelineCacheName};
|
||||
return SDL_IOFromFile(sourcePathString.c_str(), "rb");
|
||||
}
|
||||
|
||||
void ensure_initial_pipeline_cache(const std::filesystem::path& configDir) {
|
||||
if (configDir.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(configDir, ec);
|
||||
if (ec) {
|
||||
Log.warn("Failed to create config directory '{}' for pipeline cache: {}",
|
||||
io::fs_path_to_string(configDir), ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
const auto pipelineCachePath = configDir / kPipelineCacheName;
|
||||
if (std::filesystem::exists(pipelineCachePath, ec)) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string sourcePathString;
|
||||
SDL_IOStream* source = open_initial_pipeline_cache_source(sourcePathString);
|
||||
if (source == nullptr) {
|
||||
Log.info("No bundled initial pipeline cache found");
|
||||
return;
|
||||
}
|
||||
|
||||
const auto pipelineCacheString = io::fs_path_to_string(pipelineCachePath);
|
||||
SDL_IOStream* destination = SDL_IOFromFile(pipelineCacheString.c_str(), "wb");
|
||||
if (destination == nullptr) {
|
||||
Log.warn("Failed to open '{}' for seeded pipeline cache: {}", pipelineCacheString,
|
||||
SDL_GetError());
|
||||
SDL_CloseIO(source);
|
||||
return;
|
||||
}
|
||||
|
||||
bool copied = true;
|
||||
std::array<char, 64 * 1024> buffer{};
|
||||
while (true) {
|
||||
const size_t bytesRead = SDL_ReadIO(source, buffer.data(), buffer.size());
|
||||
if (bytesRead > 0) {
|
||||
size_t bytesWritten = 0;
|
||||
while (bytesWritten < bytesRead) {
|
||||
const size_t written = SDL_WriteIO(
|
||||
destination, buffer.data() + bytesWritten, bytesRead - bytesWritten);
|
||||
if (written == 0) {
|
||||
Log.warn("Failed to write seeded pipeline cache '{}': {}", pipelineCacheString,
|
||||
SDL_GetError());
|
||||
copied = false;
|
||||
break;
|
||||
}
|
||||
bytesWritten += written;
|
||||
}
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (bytesRead < buffer.size()) {
|
||||
if (SDL_GetIOStatus(source) == SDL_IO_STATUS_EOF) {
|
||||
break;
|
||||
}
|
||||
|
||||
Log.warn(
|
||||
"Failed to read bundled pipeline cache '{}': {}", sourcePathString, SDL_GetError());
|
||||
copied = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!SDL_CloseIO(destination)) {
|
||||
Log.warn(
|
||||
"Failed to close seeded pipeline cache '{}': {}", pipelineCacheString, SDL_GetError());
|
||||
copied = false;
|
||||
}
|
||||
SDL_CloseIO(source);
|
||||
|
||||
if (!copied) {
|
||||
std::filesystem::remove(pipelineCachePath, ec);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.info("Seeded pipeline cache from '{}'", sourcePathString);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool open_data_path() {
|
||||
#if DUSK_CAN_OPEN_DATA_FOLDER
|
||||
std::error_code ec;
|
||||
std::filesystem::path path = std::filesystem::absolute(ConfigPath, ec);
|
||||
if (ec) {
|
||||
Log.warn("Failed to resolve absolute data folder path '{}': {}",
|
||||
io::fs_path_to_string(ConfigPath), ec.message());
|
||||
path = ConfigPath;
|
||||
}
|
||||
|
||||
#if defined(_WIN32)
|
||||
const std::string url = "file:///" + path.generic_string();
|
||||
#else
|
||||
const std::string url = "file://" + path.generic_string();
|
||||
#endif
|
||||
if (!SDL_OpenURL(url.c_str())) {
|
||||
Log.warn(
|
||||
"Failed to open data folder '{}': {}", io::fs_path_to_string(path), SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool set_custom_data_path(const std::filesystem::path& path) {
|
||||
if (path.empty()) {
|
||||
Log.warn("Ignoring empty custom data path");
|
||||
return false;
|
||||
}
|
||||
|
||||
return write_location_descriptor(LocationMode::Custom, path);
|
||||
}
|
||||
|
||||
bool set_custom_data_path(const char* path) {
|
||||
return set_custom_data_path(path_from_utf8(path));
|
||||
}
|
||||
|
||||
bool set_portable_data_path() {
|
||||
return write_location_descriptor(LocationMode::Portable, portable_data_path());
|
||||
}
|
||||
|
||||
bool reset_data_path() {
|
||||
const auto prefPath = get_pref_path();
|
||||
return write_location_descriptor(LocationMode::Default, default_data_path(prefPath));
|
||||
}
|
||||
|
||||
bool is_default_data_path() {
|
||||
const auto prefPath = get_pref_path();
|
||||
return normalized_path(configured_data_path()) == normalized_path(default_data_path(prefPath));
|
||||
}
|
||||
|
||||
std::filesystem::path configured_data_path() {
|
||||
if (sConfiguredDataPath) {
|
||||
return *sConfiguredDataPath;
|
||||
}
|
||||
|
||||
const auto prefPath = get_pref_path();
|
||||
const auto descriptor = read_location_descriptor(prefPath);
|
||||
if (descriptor) {
|
||||
sActiveDescriptorPath = descriptor->path;
|
||||
}
|
||||
sConfiguredDataPath =
|
||||
resolve_data_path(prefPath, descriptor ? &descriptor->descriptor : nullptr);
|
||||
return *sConfiguredDataPath;
|
||||
}
|
||||
|
||||
bool is_data_path_restart_pending() {
|
||||
if (ConfigPath.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return normalized_path(ConfigPath) != normalized_path(configured_data_path());
|
||||
}
|
||||
|
||||
std::filesystem::path initialize_data() {
|
||||
const auto prefPath = get_pref_path();
|
||||
const auto descriptor = read_location_descriptor(prefPath);
|
||||
if (descriptor) {
|
||||
sActiveDescriptorPath = descriptor->path;
|
||||
} else {
|
||||
sActiveDescriptorPath.reset();
|
||||
}
|
||||
const auto dataPath =
|
||||
resolve_data_path(prefPath, descriptor ? &descriptor->descriptor : nullptr);
|
||||
sConfiguredDataPath = dataPath;
|
||||
|
||||
migrate_data(prefPath, dataPath, descriptor ? &descriptor->descriptor : nullptr);
|
||||
ensure_data_directory(dataPath);
|
||||
ensure_initial_pipeline_cache(dataPath);
|
||||
|
||||
return dataPath;
|
||||
}
|
||||
|
||||
} // namespace dusk::data
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
#if defined(__APPLE__)
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
#if defined(_WIN32) || \
|
||||
(defined(__APPLE__) && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_MACCATALYST) || \
|
||||
(defined(__linux__) && !defined(__ANDROID__))
|
||||
#define DUSK_CAN_OPEN_DATA_FOLDER 1
|
||||
#else
|
||||
#define DUSK_CAN_OPEN_DATA_FOLDER 0
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST
|
||||
#define DUSK_CAN_CHANGE_DATA_FOLDER 0
|
||||
#else
|
||||
#define DUSK_CAN_CHANGE_DATA_FOLDER 1
|
||||
#endif
|
||||
|
||||
namespace dusk::data {
|
||||
|
||||
std::filesystem::path initialize_data();
|
||||
std::filesystem::path configured_data_path();
|
||||
bool open_data_path();
|
||||
bool set_custom_data_path(const char* path);
|
||||
bool set_custom_data_path(const std::filesystem::path& path);
|
||||
bool set_portable_data_path();
|
||||
bool reset_data_path();
|
||||
bool is_default_data_path();
|
||||
bool is_data_path_restart_pending();
|
||||
|
||||
} // namespace dusk::data
|
||||
+124
-6
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <SDL3/SDL_dialog.h>
|
||||
#include <SDL3/SDL_error.h>
|
||||
#include <SDL3/SDL_init.h>
|
||||
#include <SDL3/SDL_stdinc.h>
|
||||
|
||||
#if defined(__ANDROID__) || defined(ANDROID)
|
||||
@@ -16,6 +17,12 @@
|
||||
#include <TargetConditionals.h>
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) && !TARGET_OS_IOS && !TARGET_OS_TV && !TARGET_OS_MACCATALYST
|
||||
#define USE_MACOS_FOLDER_DIALOG 1
|
||||
#else
|
||||
#define USE_MACOS_FOLDER_DIALOG 0
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) && TARGET_OS_IOS && !TARGET_OS_MACCATALYST
|
||||
#define USE_IOS_DIALOG 1
|
||||
#include "ios/FileSelectDialog.h"
|
||||
@@ -23,6 +30,13 @@
|
||||
#define USE_IOS_DIALOG 0
|
||||
#endif
|
||||
|
||||
#if USE_MACOS_FOLDER_DIALOG
|
||||
namespace dusk {
|
||||
bool ShowMacOSFolderSelect(
|
||||
FileCallback callback, void* userdata, SDL_Window* window, const char* default_location);
|
||||
} // namespace dusk
|
||||
#endif
|
||||
|
||||
namespace dusk {
|
||||
namespace {
|
||||
|
||||
@@ -32,6 +46,10 @@ std::string fallback_display_name(std::string_view path) {
|
||||
}
|
||||
|
||||
std::string pathString(path);
|
||||
while (pathString.size() > 1 && (pathString.back() == '/' || pathString.back() == '\\')) {
|
||||
pathString.pop_back();
|
||||
}
|
||||
|
||||
const std::size_t slash = pathString.find_last_of("/\\");
|
||||
if (slash == std::string::npos || slash + 1 >= pathString.size()) {
|
||||
return pathString;
|
||||
@@ -98,8 +116,7 @@ std::string android_display_name(std::string_view path) {
|
||||
return {};
|
||||
}
|
||||
|
||||
auto* displayName =
|
||||
static_cast<jstring>(env->CallObjectMethod(activity, getDisplayName, uri));
|
||||
auto* displayName = static_cast<jstring>(env->CallObjectMethod(activity, getDisplayName, uri));
|
||||
env->DeleteLocalRef(uri);
|
||||
env->DeleteLocalRef(activity);
|
||||
if (displayName == nullptr || clear_pending_exception(env)) {
|
||||
@@ -110,6 +127,76 @@ std::string android_display_name(std::string_view path) {
|
||||
env->DeleteLocalRef(displayName);
|
||||
return result;
|
||||
}
|
||||
|
||||
struct AndroidFolderDialogState {
|
||||
FileCallback callback;
|
||||
void* userdata;
|
||||
std::string path;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
void onAndroidFolderDialogFinished(void* userdata) {
|
||||
std::unique_ptr<AndroidFolderDialogState> state(
|
||||
static_cast<AndroidFolderDialogState*>(userdata));
|
||||
|
||||
const char* path = state->path.empty() ? nullptr : state->path.c_str();
|
||||
const char* error = state->error.empty() ? nullptr : state->error.c_str();
|
||||
state->callback(state->userdata, path, error);
|
||||
}
|
||||
|
||||
bool show_android_folder_select(AndroidFolderDialogState* state) {
|
||||
auto* env = static_cast<JNIEnv*>(SDL_GetAndroidJNIEnv());
|
||||
if (env == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
jobject activity = static_cast<jobject>(SDL_GetAndroidActivity());
|
||||
if (activity == nullptr || clear_pending_exception(env)) {
|
||||
if (activity != nullptr) {
|
||||
env->DeleteLocalRef(activity);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
jclass activityClass = env->GetObjectClass(activity);
|
||||
if (activityClass == nullptr || clear_pending_exception(env)) {
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
jmethodID showFolderDialog =
|
||||
env->GetMethodID(activityClass, "showFolderDialog", "(J)Z");
|
||||
env->DeleteLocalRef(activityClass);
|
||||
if (showFolderDialog == nullptr || clear_pending_exception(env)) {
|
||||
env->DeleteLocalRef(activity);
|
||||
return false;
|
||||
}
|
||||
|
||||
const jboolean shown = env->CallBooleanMethod(
|
||||
activity, showFolderDialog, reinterpret_cast<jlong>(state));
|
||||
env->DeleteLocalRef(activity);
|
||||
if (clear_pending_exception(env)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return shown == JNI_TRUE;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_dev_twilitrealm_dusk_DuskActivity_nativeFolderDialogResult(
|
||||
JNIEnv* env, jclass, jlong userdata, jstring path, jstring error) {
|
||||
auto* state = reinterpret_cast<AndroidFolderDialogState*>(userdata);
|
||||
if (state == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
state->path = to_string(env, path);
|
||||
state->error = to_string(env, error);
|
||||
|
||||
if (!SDL_RunOnMainThread(&onAndroidFolderDialogFinished, state, false)) {
|
||||
onAndroidFolderDialogFinished(state);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if USE_IOS_DIALOG
|
||||
@@ -159,8 +246,8 @@ void onSDLDialogFinished(void* userdata, const char* const* filelist, [[maybe_un
|
||||
} // namespace
|
||||
|
||||
void ShowFileSelect(FileCallback callback, void* userdata, SDL_Window* window,
|
||||
const SDL_DialogFileFilter* filters, int nfilters, const char* default_location,
|
||||
bool allow_many) {
|
||||
const SDL_DialogFileFilter* filters, int nfilters, const char* default_location,
|
||||
bool allow_many) {
|
||||
if (callback == nullptr) {
|
||||
return;
|
||||
}
|
||||
@@ -171,14 +258,45 @@ void ShowFileSelect(FileCallback callback, void* userdata, SDL_Window* window,
|
||||
state->userdata = userdata;
|
||||
|
||||
Dusk_iOS_ShowFileSelect(&onIOSDialogFinished, state.release(), window, filters, nfilters,
|
||||
default_location, allow_many);
|
||||
default_location, allow_many);
|
||||
#else
|
||||
auto state = std::make_unique<SDLDialogCallbackState>();
|
||||
state->callback = callback;
|
||||
state->userdata = userdata;
|
||||
|
||||
SDL_ShowOpenFileDialog(&onSDLDialogFinished, state.release(), window, filters, nfilters,
|
||||
default_location, allow_many);
|
||||
default_location, allow_many);
|
||||
#endif
|
||||
}
|
||||
|
||||
void ShowFolderSelect(
|
||||
FileCallback callback, void* userdata, SDL_Window* window, const char* default_location) {
|
||||
if (callback == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
#if USE_IOS_DIALOG
|
||||
callback(userdata, nullptr, "Folder selection is not supported on this platform");
|
||||
#elif USE_MACOS_FOLDER_DIALOG
|
||||
ShowMacOSFolderSelect(callback, userdata, window, default_location);
|
||||
#elif defined(__ANDROID__) || defined(ANDROID)
|
||||
auto state = std::make_unique<AndroidFolderDialogState>();
|
||||
state->callback = callback;
|
||||
state->userdata = userdata;
|
||||
|
||||
if (show_android_folder_select(state.get())) {
|
||||
state.release();
|
||||
return;
|
||||
}
|
||||
|
||||
callback(userdata, nullptr, "Folder selection is not supported on this platform");
|
||||
#else
|
||||
auto state = std::make_unique<SDLDialogCallbackState>();
|
||||
state->callback = callback;
|
||||
state->userdata = userdata;
|
||||
|
||||
SDL_ShowOpenFolderDialog(
|
||||
&onSDLDialogFinished, state.release(), window, default_location, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ using FileCallback = void (*)(void* userdata, const char* path, const char* erro
|
||||
void ShowFileSelect(FileCallback callback, void* userdata, SDL_Window* window,
|
||||
const SDL_DialogFileFilter* filters, int nfilters, const char* default_location,
|
||||
bool allow_many);
|
||||
void ShowFolderSelect(
|
||||
FileCallback callback, void* userdata, SDL_Window* window, const char* default_location);
|
||||
|
||||
std::string display_name_for_path(std::string_view path);
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
#include "file_select.hpp"
|
||||
|
||||
#include <SDL3/SDL_properties.h>
|
||||
#include <SDL3/SDL_video.h>
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
namespace dusk {
|
||||
namespace {
|
||||
|
||||
struct MacOSFolderDialogState {
|
||||
FileCallback callback;
|
||||
void* userdata;
|
||||
};
|
||||
|
||||
void finish_folder_dialog(MacOSFolderDialogState* state, NSURL* url, const char* error) {
|
||||
if (state == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error != nullptr) {
|
||||
state->callback(state->userdata, nullptr, error);
|
||||
delete state;
|
||||
return;
|
||||
}
|
||||
|
||||
if (url == nil) {
|
||||
state->callback(state->userdata, nullptr, nullptr);
|
||||
delete state;
|
||||
return;
|
||||
}
|
||||
|
||||
state->callback(state->userdata, [[url path] UTF8String], nullptr);
|
||||
delete state;
|
||||
}
|
||||
|
||||
void configure_default_location(NSOpenPanel* panel, const char* defaultLocation) {
|
||||
if (panel == nil || defaultLocation == nullptr || defaultLocation[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
|
||||
NSString* path = [NSString stringWithUTF8String:defaultLocation];
|
||||
if (path == nil) {
|
||||
return;
|
||||
}
|
||||
|
||||
BOOL isDirectory = NO;
|
||||
NSFileManager* fileManager = [NSFileManager defaultManager];
|
||||
NSURL* url = [NSURL fileURLWithPath:path];
|
||||
if ([fileManager fileExistsAtPath:path isDirectory:&isDirectory] && isDirectory) {
|
||||
[panel setDirectoryURL:url];
|
||||
} else {
|
||||
[panel setDirectoryURL:[url URLByDeletingLastPathComponent]];
|
||||
}
|
||||
}
|
||||
|
||||
NSWindow* window_for_sdl_window(SDL_Window* window) {
|
||||
if (window == nullptr) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
auto props = SDL_GetWindowProperties(window);
|
||||
return (__bridge NSWindow*)SDL_GetPointerProperty(
|
||||
props, SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, nullptr);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool ShowMacOSFolderSelect(
|
||||
FileCallback callback, void* userdata, SDL_Window* window, const char* defaultLocation) {
|
||||
if (callback == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
auto* state = new MacOSFolderDialogState{
|
||||
.callback = callback,
|
||||
.userdata = userdata,
|
||||
};
|
||||
|
||||
NSOpenPanel* panel = [NSOpenPanel openPanel];
|
||||
[panel setCanChooseFiles:NO];
|
||||
[panel setCanChooseDirectories:YES];
|
||||
[panel setAllowsMultipleSelection:NO];
|
||||
[panel setCanCreateDirectories:YES];
|
||||
configure_default_location(panel, defaultLocation);
|
||||
|
||||
NSWindow* modalWindow = window_for_sdl_window(window);
|
||||
if (modalWindow != nil) {
|
||||
[panel beginSheetModalForWindow:modalWindow
|
||||
completionHandler:^(NSModalResponse result) {
|
||||
finish_folder_dialog(
|
||||
state, result == NSModalResponseOK ? [panel URL] : nil, nullptr);
|
||||
}];
|
||||
return true;
|
||||
}
|
||||
|
||||
const NSModalResponse result = [panel runModal];
|
||||
finish_folder_dialog(state, result == NSModalResponseOK ? [panel URL] : nil, nullptr);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace dusk
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "SDL3/SDL_mouse.h"
|
||||
#include "dusk/audio/DuskAudioSystem.h"
|
||||
#include "dusk/config.hpp"
|
||||
#include "dusk/data.hpp"
|
||||
#include "dusk/dusk.h"
|
||||
#include "dusk/frame_interpolation.h"
|
||||
#include "dusk/livesplit.h"
|
||||
@@ -341,7 +342,7 @@ namespace dusk {
|
||||
}
|
||||
#if DUSK_CAN_OPEN_DATA_FOLDER
|
||||
if (ImGui::Button("Open Data Folder")) {
|
||||
OpenDataFolder();
|
||||
data::open_data_path();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
#endif
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "d/actor/d_a_alink.h"
|
||||
#include "d/actor/d_a_horse.h"
|
||||
#include "d/d_com_inf_game.h"
|
||||
#include "dusk/data.hpp"
|
||||
#include "dusk/dusk.h"
|
||||
#include "dusk/main.h"
|
||||
#include "m_Do/m_Do_main.h"
|
||||
@@ -54,7 +55,7 @@ namespace dusk {
|
||||
#if DUSK_CAN_OPEN_DATA_FOLDER
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem("Open Data Folder")) {
|
||||
OpenDataFolder();
|
||||
data::open_data_path();
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ static void RunOnMainThread(void (^block)(void))
|
||||
|
||||
static NSError *MakeError(NSString *message)
|
||||
{
|
||||
return [NSError errorWithDomain:@"org.twilitrealm.dusk.file-select"
|
||||
return [NSError errorWithDomain:@"dev.twilitrealm.dusk.file-select"
|
||||
code:1
|
||||
userInfo:@{NSLocalizedDescriptionKey: message}];
|
||||
}
|
||||
|
||||
+11
-1
@@ -51,6 +51,9 @@ void Button::update_props(Props props) {
|
||||
}
|
||||
|
||||
void ControlledButton::update() {
|
||||
if (mIsDisabled) {
|
||||
set_disabled(mIsDisabled());
|
||||
}
|
||||
if (mIsSelected) {
|
||||
set_selected(mIsSelected());
|
||||
}
|
||||
@@ -64,4 +67,11 @@ bool ControlledButton::selected() const {
|
||||
return Button::selected();
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
bool ControlledButton::disabled() const {
|
||||
if (mIsDisabled) {
|
||||
return mIsDisabled();
|
||||
}
|
||||
return Button::disabled();
|
||||
}
|
||||
|
||||
} // namespace dusk::ui
|
||||
|
||||
@@ -32,17 +32,21 @@ public:
|
||||
struct Props {
|
||||
Rml::String text;
|
||||
std::function<bool()> isSelected;
|
||||
std::function<bool()> isDisabled;
|
||||
};
|
||||
|
||||
ControlledButton(Rml::Element* parent, Props props, const Rml::String& tagName = "button")
|
||||
: Button(parent, {std::move(props.text)}, tagName),
|
||||
mIsSelected(std::move(props.isSelected)) {}
|
||||
mIsSelected(std::move(props.isSelected)),
|
||||
mIsDisabled(std::move(props.isDisabled)) {}
|
||||
|
||||
void update() override;
|
||||
bool selected() const override;
|
||||
bool disabled() const override;
|
||||
|
||||
private:
|
||||
std::function<bool()> mIsSelected;
|
||||
std::function<bool()> mIsDisabled;
|
||||
};
|
||||
|
||||
} // namespace dusk::ui
|
||||
} // namespace dusk::ui
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "prelaunch.hpp"
|
||||
|
||||
#include "dusk/config.hpp"
|
||||
#include "dusk/data.hpp"
|
||||
#include "dusk/file_select.hpp"
|
||||
#include "dusk/iso_validate.hpp"
|
||||
#include "dusk/main.h"
|
||||
@@ -656,6 +657,9 @@ bool is_restart_pending() noexcept {
|
||||
if (!state.activeDiscPath.empty() && state.configuredDiscPath != state.activeDiscPath) {
|
||||
return true;
|
||||
}
|
||||
if (data::is_data_path_restart_pending()) {
|
||||
return true;
|
||||
}
|
||||
if (getSettings().backend.graphicsBackend.getValue() != state.initialGraphicsBackend) {
|
||||
return true;
|
||||
}
|
||||
|
||||
+137
-1
@@ -6,8 +6,10 @@
|
||||
#include "dusk/audio/DuskAudioSystem.h"
|
||||
#include "dusk/audio/DuskDsp.hpp"
|
||||
#include "dusk/config.hpp"
|
||||
#include "dusk/data.hpp"
|
||||
#include "dusk/file_select.hpp"
|
||||
#include "dusk/imgui/ImGuiEngine.hpp"
|
||||
#include "dusk/io.hpp"
|
||||
#include "dusk/livesplit.h"
|
||||
#include "dusk/main.h"
|
||||
#include "graphics_tuner.hpp"
|
||||
@@ -19,11 +21,15 @@
|
||||
#include "prelaunch.hpp"
|
||||
#include "ui.hpp"
|
||||
|
||||
#include <aurora/lib/window.hpp>
|
||||
#include <SDL3/SDL_filesystem.h>
|
||||
|
||||
#if DUSK_ENABLE_SENTRY_NATIVE
|
||||
#include "dusk/crash_reporting.h"
|
||||
#endif
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
|
||||
namespace dusk::ui {
|
||||
namespace {
|
||||
@@ -191,6 +197,93 @@ void reset_for_speedrun_mode() {
|
||||
getSettings().game.autoSave.setValue(false);
|
||||
}
|
||||
|
||||
std::filesystem::path normalized_display_path(const std::filesystem::path& path) {
|
||||
std::error_code ec;
|
||||
auto normalized = std::filesystem::weakly_canonical(path, ec);
|
||||
if (!ec) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
normalized = std::filesystem::absolute(path, ec);
|
||||
if (!ec) {
|
||||
return normalized.lexically_normal();
|
||||
}
|
||||
|
||||
return path.lexically_normal();
|
||||
}
|
||||
|
||||
std::filesystem::path user_home_path() {
|
||||
const char* homePath = SDL_GetUserFolder(SDL_FOLDER_HOME);
|
||||
if (homePath == nullptr || homePath[0] == '\0') {
|
||||
return {};
|
||||
}
|
||||
return std::filesystem::path{reinterpret_cast<const char8_t*>(homePath)};
|
||||
}
|
||||
|
||||
Rml::String abbreviated_data_path_string() {
|
||||
const auto path = data::configured_data_path();
|
||||
const auto homePath = user_home_path();
|
||||
if (path.empty() || homePath.empty()) {
|
||||
return io::fs_path_to_string(path);
|
||||
}
|
||||
|
||||
const auto normalizedPath = normalized_display_path(path);
|
||||
const auto normalizedHome = normalized_display_path(homePath);
|
||||
if (normalizedPath == normalizedHome) {
|
||||
return "~";
|
||||
}
|
||||
|
||||
const auto relativePath = normalizedPath.lexically_relative(normalizedHome);
|
||||
if (!relativePath.empty() && !relativePath.is_absolute()) {
|
||||
const auto it = relativePath.begin();
|
||||
if (it == relativePath.end() || *it != "..") {
|
||||
return io::fs_path_to_string(std::filesystem::path{"~"} / relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return io::fs_path_to_string(path);
|
||||
}
|
||||
|
||||
Rml::String configured_data_path_display_name() {
|
||||
const auto path = abbreviated_data_path_string();
|
||||
if (path.empty()) {
|
||||
return "(none)";
|
||||
}
|
||||
|
||||
auto display = display_name_for_path(path);
|
||||
if (display.empty()) {
|
||||
return path;
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
class DataFolderPathText : public Component {
|
||||
public:
|
||||
explicit DataFolderPathText(Rml::Element* parent) : Component(append(parent, "div")) {}
|
||||
|
||||
void update() override {
|
||||
const Rml::String rml = "<span class=\"data-folder-current\">Current data folder:<br/>" +
|
||||
escape(abbreviated_data_path_string()) + "</span>";
|
||||
if (rml != mCurrentRml) {
|
||||
mRoot->SetInnerRML(rml);
|
||||
mCurrentRml = rml;
|
||||
}
|
||||
Component::update();
|
||||
}
|
||||
|
||||
private:
|
||||
Rml::String mCurrentRml;
|
||||
};
|
||||
|
||||
void data_folder_dialog_callback(void*, const char* path, const char* error) {
|
||||
if (error != nullptr || path == nullptr) {
|
||||
return;
|
||||
}
|
||||
if (data::set_custom_data_path(path)) {
|
||||
mDoAud_seStartMenu(kSoundItemChange);
|
||||
}
|
||||
}
|
||||
|
||||
const Rml::String kInternalResolutionHelpText =
|
||||
"Configure the resolution used for rendering the game. Higher values are more demanding on "
|
||||
"your graphics hardware.";
|
||||
@@ -350,6 +443,49 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
|
||||
pane.add_rml("Set the disc image that Dusklight uses to launch the game.<br/><br/>"
|
||||
"Changes require a restart.");
|
||||
});
|
||||
#if DUSK_CAN_CHANGE_DATA_FOLDER
|
||||
leftPane.register_control(
|
||||
leftPane.add_select_button({
|
||||
.key = "Data Folder",
|
||||
.getValue = [] { return configured_data_path_display_name(); },
|
||||
.isModified = [] { return data::is_data_path_restart_pending(); },
|
||||
}),
|
||||
rightPane, [](Pane& pane) {
|
||||
pane.add_text("The data folder is where Dusklight stores settings, saves, "
|
||||
"logs, texture replacements, and other app data.");
|
||||
pane.add_child<DataFolderPathText>();
|
||||
#if DUSK_CAN_OPEN_DATA_FOLDER
|
||||
pane.add_button("Open Data Folder").on_pressed([] {
|
||||
if (data::open_data_path()) {
|
||||
mDoAud_seStartMenu(kSoundClick);
|
||||
}
|
||||
});
|
||||
#endif
|
||||
pane.add_button("Change Data Folder").on_pressed([] {
|
||||
const auto defaultLocation =
|
||||
io::fs_path_to_string(data::configured_data_path());
|
||||
ShowFolderSelect(&data_folder_dialog_callback, nullptr,
|
||||
aurora::window::get_sdl_window(),
|
||||
defaultLocation.empty() ? nullptr : defaultLocation.c_str());
|
||||
});
|
||||
#if defined(_WIN32)
|
||||
pane.add_button("Portable Mode").on_pressed([] {
|
||||
if (data::set_portable_data_path()) {
|
||||
mDoAud_seStartMenu(kSoundItemChange);
|
||||
}
|
||||
});
|
||||
#endif
|
||||
pane.add_button({
|
||||
.text = "Reset to Default",
|
||||
.isDisabled = [] { return data::is_default_data_path(); },
|
||||
}).on_pressed([] {
|
||||
if (data::reset_data_path()) {
|
||||
mDoAud_seStartMenu(kSoundItemChange);
|
||||
}
|
||||
});
|
||||
pane.add_rml("Data will be migrated automatically on restart.");
|
||||
});
|
||||
#endif
|
||||
leftPane.register_control(
|
||||
leftPane.add_select_button({
|
||||
.key = "Language",
|
||||
@@ -959,7 +1095,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) {
|
||||
leftPane.register_control(
|
||||
leftPane.add_button("Open Data Folder").on_pressed([] {
|
||||
mDoAud_seStartMenu(kSoundClick);
|
||||
dusk::OpenDataFolder();
|
||||
data::open_data_path();
|
||||
}),
|
||||
rightPane, [](Pane& pane) {
|
||||
pane.add_text(
|
||||
|
||||
+5
-199
@@ -48,6 +48,7 @@
|
||||
#include "SSystem/SComponent/c_API.h"
|
||||
#include "dusk/app_info.hpp"
|
||||
#include "dusk/crash_reporting.h"
|
||||
#include "dusk/data.hpp"
|
||||
#include "dusk/dusk.h"
|
||||
#include "dusk/frame_interpolation.h"
|
||||
#include "dusk/game_clock.h"
|
||||
@@ -124,32 +125,6 @@ void dusk::RequestRestart() noexcept {
|
||||
IsRunning = false;
|
||||
}
|
||||
|
||||
bool dusk::OpenDataFolder() {
|
||||
#if DUSK_CAN_OPEN_DATA_FOLDER
|
||||
std::error_code ec;
|
||||
std::filesystem::path path = std::filesystem::absolute(ConfigPath, ec);
|
||||
if (ec) {
|
||||
DuskLog.warn("Failed to resolve absolute data folder path '{}': {}",
|
||||
io::fs_path_to_string(ConfigPath), ec.message());
|
||||
path = ConfigPath;
|
||||
}
|
||||
|
||||
#if defined(_WIN32)
|
||||
const std::string url = "file:///" + path.generic_string();
|
||||
#else
|
||||
const std::string url = "file://" + path.generic_string();
|
||||
#endif
|
||||
if (!SDL_OpenURL(url.c_str())) {
|
||||
DuskLog.warn(
|
||||
"Failed to open data folder '{}': {}", io::fs_path_to_string(path), SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
s32 LOAD_COPYDATE(void*) {
|
||||
char buffer[32];
|
||||
memset(buffer, 0, sizeof(buffer));
|
||||
@@ -428,175 +403,6 @@ static void ApplyCVarOverrides(const cxxopts::OptionValue& option) {
|
||||
}
|
||||
}
|
||||
|
||||
static void migrate_directory(const std::filesystem::path& from, const std::filesystem::path& to) {
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(to, ec);
|
||||
if (ec) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (std::filesystem::recursive_directory_iterator it(
|
||||
from, std::filesystem::directory_options::skip_permission_denied, ec);
|
||||
it != std::filesystem::recursive_directory_iterator(); it.increment(ec))
|
||||
{
|
||||
if (ec) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto relativePath = std::filesystem::relative(it->path(), from, ec);
|
||||
if (ec) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto targetPath = to / relativePath;
|
||||
if (it->is_directory(ec)) {
|
||||
std::filesystem::create_directories(targetPath, ec);
|
||||
if (ec) {
|
||||
return;
|
||||
}
|
||||
} else if (it->is_regular_file(ec) && !std::filesystem::exists(targetPath, ec)) {
|
||||
std::filesystem::create_directories(targetPath.parent_path(), ec);
|
||||
if (ec) {
|
||||
return;
|
||||
}
|
||||
std::filesystem::copy_file(
|
||||
it->path(), targetPath, std::filesystem::copy_options::skip_existing, ec);
|
||||
if (ec) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static std::filesystem::path calculate_config_path() {
|
||||
#ifdef __APPLE__
|
||||
#if TARGET_OS_IOS && !TARGET_OS_TV
|
||||
const char* documentsPath = SDL_GetUserFolder(SDL_FOLDER_DOCUMENTS);
|
||||
if (!documentsPath) {
|
||||
DuskLog.fatal("Unable to get iOS Documents path: {}", SDL_GetError());
|
||||
}
|
||||
|
||||
std::filesystem::path configPath = reinterpret_cast<const char8_t*>(documentsPath);
|
||||
|
||||
char* oldPrefPath = SDL_GetPrefPath(dusk::OrgName, dusk::AppName);
|
||||
if (oldPrefPath) {
|
||||
const std::filesystem::path oldConfigPath = reinterpret_cast<const char8_t*>(oldPrefPath);
|
||||
SDL_free(oldPrefPath);
|
||||
|
||||
std::error_code ec;
|
||||
if (oldConfigPath != configPath && std::filesystem::exists(oldConfigPath, ec)) {
|
||||
migrate_directory(oldConfigPath, configPath);
|
||||
}
|
||||
}
|
||||
|
||||
return configPath;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
const auto result = SDL_GetPrefPath(dusk::OrgName, dusk::AppName);
|
||||
if (!result) {
|
||||
DuskLog.fatal("Unable to get PrefPath: {}", SDL_GetError());
|
||||
}
|
||||
|
||||
return reinterpret_cast<const char8_t*>(result);
|
||||
}
|
||||
|
||||
static void EnsureInitialPipelineCache(const std::filesystem::path& configDir) {
|
||||
if (configDir.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const std::filesystem::path pipelineCachePath = configDir / "pipeline_cache.db";
|
||||
if (std::filesystem::exists(pipelineCachePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::string sourcePathString;
|
||||
SDL_IOStream* source = nullptr;
|
||||
|
||||
const char* basePath = SDL_GetBasePath();
|
||||
if (basePath != nullptr) {
|
||||
sourcePathString = dusk::io::fs_path_to_string(
|
||||
std::filesystem::path(basePath) / "initial_pipeline_cache.db");
|
||||
source = SDL_IOFromFile(sourcePathString.c_str(), "rb");
|
||||
}
|
||||
if (source == nullptr) {
|
||||
sourcePathString = "initial_pipeline_cache.db";
|
||||
source = SDL_IOFromFile(sourcePathString.c_str(), "rb");
|
||||
}
|
||||
if (source == nullptr) {
|
||||
DuskLog.info("No bundled initial pipeline cache found");
|
||||
return;
|
||||
}
|
||||
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(configDir, ec);
|
||||
if (ec) {
|
||||
DuskLog.warn("Failed to create config directory '{}' for pipeline cache: {}",
|
||||
dusk::io::fs_path_to_string(configDir), ec.message());
|
||||
SDL_CloseIO(source);
|
||||
return;
|
||||
}
|
||||
|
||||
const auto pipelineCacheString = dusk::io::fs_path_to_string(pipelineCachePath);
|
||||
SDL_IOStream* destination = SDL_IOFromFile(pipelineCacheString.c_str(), "wb");
|
||||
if (destination == nullptr) {
|
||||
DuskLog.warn("Failed to open '{}' for seeded pipeline cache: {}", pipelineCacheString,
|
||||
SDL_GetError());
|
||||
SDL_CloseIO(source);
|
||||
return;
|
||||
}
|
||||
|
||||
bool copied = true;
|
||||
std::array<char, 64 * 1024> buffer{};
|
||||
while (true) {
|
||||
const size_t bytesRead = SDL_ReadIO(source, buffer.data(), buffer.size());
|
||||
if (bytesRead > 0) {
|
||||
size_t bytesWritten = 0;
|
||||
while (bytesWritten < bytesRead) {
|
||||
const size_t written = SDL_WriteIO(
|
||||
destination, buffer.data() + bytesWritten, bytesRead - bytesWritten);
|
||||
if (written == 0) {
|
||||
DuskLog.warn("Failed to write seeded pipeline cache '{}': {}",
|
||||
pipelineCacheString, SDL_GetError());
|
||||
copied = false;
|
||||
break;
|
||||
}
|
||||
bytesWritten += written;
|
||||
}
|
||||
}
|
||||
|
||||
if (!copied) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (bytesRead < buffer.size()) {
|
||||
if (SDL_GetIOStatus(source) == SDL_IO_STATUS_EOF) {
|
||||
break;
|
||||
}
|
||||
|
||||
DuskLog.warn(
|
||||
"Failed to read bundled pipeline cache '{}': {}", sourcePathString, SDL_GetError());
|
||||
copied = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!SDL_CloseIO(destination)) {
|
||||
DuskLog.warn("Failed to close seeded pipeline cache '{}': {}",
|
||||
dusk::io::fs_path_to_string(pipelineCachePath), SDL_GetError());
|
||||
copied = false;
|
||||
}
|
||||
SDL_CloseIO(source);
|
||||
|
||||
if (!copied) {
|
||||
std::filesystem::remove(pipelineCachePath, ec);
|
||||
return;
|
||||
}
|
||||
|
||||
DuskLog.info("Seeded pipeline cache from '{}'", sourcePathString);
|
||||
}
|
||||
|
||||
static constexpr PADDefaultMapping defaultPadMapping = {
|
||||
.buttons = {
|
||||
{SDL_GAMEPAD_BUTTON_SOUTH, PAD_BUTTON_A},
|
||||
@@ -691,16 +497,16 @@ int game_main(int argc, char* argv[]) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
dusk::ConfigPath = calculate_config_path();
|
||||
const auto startupLogLevel = static_cast<AuroraLogLevel>(parsed_arg_options["log-level"].as<uint8_t>());
|
||||
const auto startupLogLevel =
|
||||
static_cast<AuroraLogLevel>(parsed_arg_options["log-level"].as<uint8_t>());
|
||||
dusk::ConfigPath = dusk::data::initialize_data();
|
||||
dusk::InitializeFileLogging(dusk::ConfigPath, startupLogLevel);
|
||||
|
||||
dusk::config::LoadFromUserPreferences();
|
||||
ApplyCVarOverrides(parsed_arg_options["cvar"]);
|
||||
dusk::crash_reporting::initialize();
|
||||
EnsureInitialPipelineCache(dusk::ConfigPath);
|
||||
// TODO: How to handle this?
|
||||
//PADSetDefaultMapping(&defaultPadMapping, PAD_TYPE_STANDARD);
|
||||
// PADSetDefaultMapping(&defaultPadMapping, PAD_TYPE_STANDARD);
|
||||
|
||||
{
|
||||
const auto configPathString = dusk::ConfigPath.u8string();
|
||||
|
||||
Reference in New Issue
Block a user