diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ef323b142..aaf08be2ef 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/extern/aurora b/extern/aurora index 211bfb00a8..83cdfb40f6 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 211bfb00a85b551c436aed2359cb6233c05b4cee +Subproject commit 83cdfb40f6a734beaa7c98af20818c516434e9ee diff --git a/files.cmake b/files.cmake index d35d95b8a2..6ff8493a59 100644 --- a/files.cmake +++ b/files.cmake @@ -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 diff --git a/include/dusk/app_info.hpp b/include/dusk/app_info.hpp index 3608532957..e08fe680d9 100644 --- a/include/dusk/app_info.hpp +++ b/include/dusk/app_info.hpp @@ -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. * diff --git a/include/dusk/main.h b/include/dusk/main.h index c5c4fb27d1..240c132f40 100644 --- a/include/dusk/main.h +++ b/include/dusk/main.h @@ -1,36 +1,25 @@ #ifndef DUSK_MAIN_H #define DUSK_MAIN_H -#if defined(__APPLE__) -#include -#endif - #include -#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 diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java index d7098239cd..665cda2b5e 100644 --- a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskActivity.java @@ -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 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 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; diff --git a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java index 6c66b5eed3..d4ed6a3041 100644 --- a/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java @@ -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; } diff --git a/res/rml/window.rcss b/res/rml/window.rcss index 45473f312f..99b3753d68 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -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; diff --git a/src/dusk/data.cpp b/src/dusk/data.cpp new file mode 100644 index 0000000000..154083b365 --- /dev/null +++ b/src/dusk/data.cpp @@ -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 +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#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 sConfiguredDataPath; +std::optional sActiveDescriptorPath; + +std::filesystem::path path_from_utf8(std::string_view value) { + return std::filesystem::path{ + reinterpret_cast(value.data()), + reinterpret_cast(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(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(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(documentsPath); +#endif +#endif + + return prefPath; +} + +std::filesystem::path portable_data_path() { + return base_path_relative("data"); +} + +std::vector descriptor_paths(const std::filesystem::path& prefPath) { + std::vector paths; + if (const auto basePath = base_path_relative(kLocationDescriptorName); !basePath.empty()) { + paths.push_back(basePath); + } + paths.push_back(prefPath / kLocationDescriptorName); + return paths; +} + +std::optional 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("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()); + } + if (const auto previousPath = json.find("previousPath"); + previousPath != json.end() && previousPath->is_string()) + { + descriptor.previousPath = path_from_utf8(previousPath->get()); + } + + 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 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 descriptor_write_paths(const std::filesystem::path& prefPath) { + if (sActiveDescriptorPath && !sActiveDescriptorPath->empty()) { + return {*sActiveDescriptorPath}; + } + + std::vector 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 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 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 diff --git a/src/dusk/data.hpp b/src/dusk/data.hpp new file mode 100644 index 0000000000..85e9457bcb --- /dev/null +++ b/src/dusk/data.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#if defined(__APPLE__) +#include +#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 diff --git a/src/dusk/file_select.cpp b/src/dusk/file_select.cpp index 188d491bf3..c2277b61fc 100644 --- a/src/dusk/file_select.cpp +++ b/src/dusk/file_select.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #if defined(__ANDROID__) || defined(ANDROID) @@ -16,6 +17,12 @@ #include #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(env->CallObjectMethod(activity, getDisplayName, uri)); + auto* displayName = static_cast(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 state( + static_cast(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(SDL_GetAndroidJNIEnv()); + if (env == nullptr) { + return false; + } + + jobject activity = static_cast(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(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(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(); 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(); + 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(); + state->callback = callback; + state->userdata = userdata; + + SDL_ShowOpenFolderDialog( + &onSDLDialogFinished, state.release(), window, default_location, false); #endif } diff --git a/src/dusk/file_select.hpp b/src/dusk/file_select.hpp index 8bef51cfe6..ad4ccce1fd 100644 --- a/src/dusk/file_select.hpp +++ b/src/dusk/file_select.hpp @@ -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); diff --git a/src/dusk/file_select_macos.mm b/src/dusk/file_select_macos.mm new file mode 100644 index 0000000000..1bb34f364c --- /dev/null +++ b/src/dusk/file_select_macos.mm @@ -0,0 +1,102 @@ +#include "file_select.hpp" + +#include +#include + +#import + +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 diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index a6ebb8d31e..0ba6e08c9f 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -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 diff --git a/src/dusk/imgui/ImGuiMenuTools.cpp b/src/dusk/imgui/ImGuiMenuTools.cpp index 1cb0ec474c..2206e0e3c6 100644 --- a/src/dusk/imgui/ImGuiMenuTools.cpp +++ b/src/dusk/imgui/ImGuiMenuTools.cpp @@ -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 diff --git a/src/dusk/ios/FileSelectDialog.m b/src/dusk/ios/FileSelectDialog.m index e8efa91a69..8b32df3705 100644 --- a/src/dusk/ios/FileSelectDialog.m +++ b/src/dusk/ios/FileSelectDialog.m @@ -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}]; } diff --git a/src/dusk/ui/button.cpp b/src/dusk/ui/button.cpp index fb11571af4..0d95db3791 100644 --- a/src/dusk/ui/button.cpp +++ b/src/dusk/ui/button.cpp @@ -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 \ No newline at end of file +bool ControlledButton::disabled() const { + if (mIsDisabled) { + return mIsDisabled(); + } + return Button::disabled(); +} + +} // namespace dusk::ui diff --git a/src/dusk/ui/button.hpp b/src/dusk/ui/button.hpp index 43e349e3fa..bf97a66e6e 100644 --- a/src/dusk/ui/button.hpp +++ b/src/dusk/ui/button.hpp @@ -32,17 +32,21 @@ public: struct Props { Rml::String text; std::function isSelected; + std::function 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 mIsSelected; + std::function mIsDisabled; }; -} // namespace dusk::ui \ No newline at end of file +} // namespace dusk::ui diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index ffa8c8495f..ef6b6e5709 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -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; } diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index aab7b32b74..3b3a45f950 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -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 +#include + #if DUSK_ENABLE_SENTRY_NATIVE #include "dusk/crash_reporting.h" #endif #include +#include 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(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 = "Current data folder:
" + + escape(abbreviated_data_path_string()) + "
"; + 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.

" "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(); +#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( diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index f5d5c2c1bb..34d9422c0b 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -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(documentsPath); - - char* oldPrefPath = SDL_GetPrefPath(dusk::OrgName, dusk::AppName); - if (oldPrefPath) { - const std::filesystem::path oldConfigPath = reinterpret_cast(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(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 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(parsed_arg_options["log-level"].as()); + const auto startupLogLevel = + static_cast(parsed_arg_options["log-level"].as()); + 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();