diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 37679e3f56..82b387db4d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,9 +8,9 @@ on: pull_request: env: - # SCCACHE_GHA_ENABLED: "true" + SCCACHE_GHA_ENABLED: "true" RUSTC_WRAPPER: "sccache" - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + # SENTRY_DSN: ${{ secrets.SENTRY_DSN }} jobs: build-linux: @@ -22,7 +22,7 @@ jobs: matrix: include: - name: GCC x86_64 - runner: [self-hosted, Linux] + runner: ubuntu-latest preset: gcc artifact_arch: x86_64 # - name: GCC aarch64 @@ -41,7 +41,6 @@ jobs: submodules: recursive - name: Install dependencies - if: 'false' # disabled for self-hosted run: | sudo apt-get update sudo apt-get -y install ninja-build clang lld openssl libcurl4-openssl-dev \ @@ -51,7 +50,6 @@ jobs: libxss-dev libfuse2 libusb-1.0-0-dev libdecor-0-dev libpipewire-0.3-dev libunwind-dev - name: Setup sccache - if: 'false' # disabled for self-hosted uses: mozilla-actions/sccache-action@v0.0.9 - name: Print sccache stats @@ -67,7 +65,6 @@ jobs: run: ci/build-appimage.sh - name: Upload artifacts - if: startsWith(github.event.ref, 'refs/tags/v') uses: actions/upload-artifact@v7 with: name: dusk-${{env.DUSK_VERSION}}-linux-${{matrix.preset}}-${{matrix.artifact_arch}} @@ -77,7 +74,7 @@ jobs: build-apple: name: Build Apple (${{matrix.name}}) - runs-on: [self-hosted, macOS] + runs-on: macos-latest strategy: fail-fast: false matrix: @@ -86,14 +83,14 @@ jobs: platform: macos preset: x-macos-ci-arm64 artifact_name: macos-appleclang-arm64 - # - name: AppleClang macOS x86_64 - # platform: macos - # preset: x-macos-ci-x86_64 - # artifact_name: macos-appleclang-x86_64 - # - name: AppleClang iOS arm64 - # platform: ios - # preset: x-ios-ci - # artifact_name: ios-appleclang-arm64 + - name: AppleClang macOS x86_64 + platform: macos + preset: x-macos-ci-x86_64 + artifact_name: macos-appleclang-x86_64 + - name: AppleClang iOS arm64 + platform: ios + preset: x-ios-ci + artifact_name: ios-appleclang-arm64 # - name: AppleClang tvOS arm64 # platform: tvos # preset: x-tvos-ci @@ -106,7 +103,6 @@ jobs: submodules: recursive - name: Install dependencies - if: 'false' run: brew install cmake ninja - name: Install Rust iOS target @@ -137,7 +133,6 @@ jobs: run: cmake --build --preset ${{matrix.preset}} - name: Upload artifacts - if: startsWith(github.event.ref, 'refs/tags/v') uses: actions/upload-artifact@v7 with: name: dusk-${{env.DUSK_VERSION}}-${{matrix.artifact_name}} @@ -145,6 +140,73 @@ jobs: build/install/Dusk.app build/install/debug.tar.* + build-android: + name: Build Android (${{matrix.name}}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + - name: Clang arm64-v8a + preset: x-android-ci-arm64 + abi: arm64-v8a + artifact_arch: arm64 + rust_target: aarch64-linux-android + + env: + ANDROID_NDK_VERSION: "29.0.14206865" + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get -y install ninja-build + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 17 + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android SDK packages + run: sdkmanager "platforms;android-36" "build-tools;36.1.0" "ndk;${ANDROID_NDK_VERSION}" + + - name: Install Rust Android target + run: | + rustup toolchain install stable + rustup target add ${{matrix.rust_target}} + + - name: Setup sccache + uses: mozilla-actions/sccache-action@v0.0.9 + + - name: Configure CMake + run: cmake --preset ${{matrix.preset}} + + - name: Build native library + run: cmake --build --preset ${{matrix.preset}} --target dusk + + - name: Stage stripped JNI library + run: ANDROID_STAGE_ABIS="${{matrix.abi}}" platforms/android/scripts/stage-jni-libs.sh + + - name: Build APK + working-directory: platforms/android + run: ./gradlew :app:assembleRelease --rerun-tasks + + - name: Upload artifacts + uses: actions/upload-artifact@v7 + with: + name: dusk-${{env.DUSK_VERSION}}-android-${{matrix.artifact_arch}} + path: platforms/android/app/build/outputs/apk/release/app-${{matrix.abi}}-release-unsigned.apk + build-windows: name: Build Windows (${{matrix.name}}) runs-on: ${{matrix.runner}} @@ -154,7 +216,7 @@ jobs: matrix: include: - name: MSVC x86_64 - runner: [self-hosted, Windows] + runner: windows-latest preset: msvc msvc_arch: amd64 vcpkg_arch: x64 @@ -191,7 +253,6 @@ jobs: uses: mozilla-actions/sccache-action@v0.0.9 - name: Install dependencies - if: 'false' # disabled for self-hosted run: | choco install ninja vcpkg install freetype:${{matrix.vcpkg_arch}}-windows-static zstd:${{matrix.vcpkg_arch}}-windows-static @@ -203,7 +264,6 @@ jobs: run: cmake --build --preset x-windows-ci-${{matrix.preset}} - name: Upload artifacts - if: startsWith(github.event.ref, 'refs/tags/v') uses: actions/upload-artifact@v7 with: name: dusk-${{env.DUSK_VERSION}}-win32-msvc-${{matrix.artifact_arch}} diff --git a/CMakeLists.txt b/CMakeLists.txt index c2281292bc..a90608a0a6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -115,16 +115,22 @@ option(DUSK_ENABLE_UPDATE_CHECKER "Enable update checking support" ON) if(ANDROID) set(DUSK_MOVIE_SUPPORT OFF) - set(NOD_COMPRESS_BZIP2 OFF CACHE BOOL "" FORCE) - set(NOD_COMPRESS_LZMA OFF CACHE BOOL "" FORCE) - set(NOD_COMPRESS_ZLIB OFF CACHE BOOL "" FORCE) - set(NOD_COMPRESS_ZSTD OFF CACHE BOOL "" FORCE) endif () option(DUSK_ENABLE_SENTRY_NATIVE "Enable sentry-native crash reporting support" OFF) set(DUSK_SENTRY_DSN "" CACHE STRING "Sentry DSN") set(DUSK_SENTRY_ENVIRONMENT "development" CACHE STRING "Sentry environment") +# Edit & Continue +if (MSVC) + if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "EditAndContinue") + endif () + if (CMAKE_MSVC_DEBUG_INFORMATION_FORMAT STREQUAL "EditAndContinue") + add_link_options("/INCREMENTAL") + endif () +endif () + if (DUSK_MOVIE_SUPPORT) find_package(libjpeg-turbo 3.0 CONFIG QUIET) if (libjpeg-turbo_FOUND) @@ -154,6 +160,7 @@ if (DUSK_MOVIE_SUPPORT) CMAKE_C_COMPILER_LAUNCHER CMAKE_MAKE_PROGRAM CMAKE_MSVC_RUNTIME_LIBRARY + CMAKE_MSVC_DEBUG_INFORMATION_FORMAT CMAKE_OSX_ARCHITECTURES DEPLOYMENT_TARGET ENABLE_ARC @@ -372,16 +379,6 @@ if (DUSK_ENABLE_DISCORD AND NOT ANDROID AND NOT IOS AND NOT TVOS) list(APPEND GAME_COMPILE_DEFS DUSK_DISCORD=1) endif () -# Edit & Continue -if (MSVC) - if ("${CMAKE_MSVC_DEBUG_INFORMATION_FORMAT}" STREQUAL "" AND CMAKE_BUILD_TYPE STREQUAL "Debug") - set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT "EditAndContinue") - endif () - if (CMAKE_MSVC_DEBUG_INFORMATION_FORMAT STREQUAL "EditAndContinue") - add_link_options("/INCREMENTAL") - endif () -endif () - if(ANDROID) list(APPEND GAME_COMPILE_DEFS TARGET_ANDROID=1) endif () diff --git a/CMakePresets.json b/CMakePresets.json index 3b2f0cae67..ea58a72243 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -352,6 +352,25 @@ "ANDROID_ABI": "x86_64" } }, + { + "name": "x-android-ci", + "hidden": true, + "inherits": [ + "android-base", + "ci" + ] + }, + { + "name": "x-android-ci-arm64", + "binaryDir": "${sourceDir}/build/android-arm64", + "inherits": [ + "x-android-ci" + ], + "cacheVariables": { + "ANDROID_ABI": "arm64-v8a", + "Rust_CARGO_TARGET": "aarch64-linux-android" + } + }, { "name": "x-linux-ci", "hidden": true, @@ -412,6 +431,7 @@ "x-macos-ci" ], "cacheVariables": { + "AURORA_DAWN_PROVIDER": "vendor", "CMAKE_OSX_ARCHITECTURES": "x86_64", "Rust_CARGO_TARGET": "x86_64-apple-darwin" } @@ -555,6 +575,15 @@ "dusk" ] }, + { + "name": "x-android-ci-arm64", + "configurePreset": "x-android-ci-arm64", + "description": "(Internal) Android CI arm64-v8a", + "displayName": "(Internal) Android CI arm64-v8a", + "targets": [ + "dusk" + ] + }, { "name": "windows-msvc-debug", "configurePreset": "windows-msvc-debug", diff --git a/extern/aurora b/extern/aurora index 9413f24f65..22c2e5e55a 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 9413f24f65e20969b8320e651af306a2fd0798c8 +Subproject commit 22c2e5e55a7e00404149dd499e2639cd74fb1189 diff --git a/include/dusk/io.hpp b/include/dusk/io.hpp index fb71a77d68..6de60e3449 100644 --- a/include/dusk/io.hpp +++ b/include/dusk/io.hpp @@ -2,11 +2,13 @@ #define DUSK_IO_HPP #include +#include // I can't believe it's 2026 and neither SDL (no error codes) nor // C++ (no error codes) have a file system API functional enough for me to use. // Here you go, this one's inspired by C#. I only wrote the functions I need. + namespace dusk::io { /** @@ -15,7 +17,7 @@ namespace dusk::io { * Methods on this class throw appropriate C++ exceptions when an error occurs. */ class FileStream { - void* file; + FILE* file; public: FileStream() noexcept; @@ -23,7 +25,7 @@ public: /** * \brief Take ownership of a FILE* handle. */ - explicit FileStream(void* file); + explicit FileStream(FILE* file); FileStream(const FileStream& other) = delete; FileStream(FileStream&& other) noexcept; @@ -34,6 +36,11 @@ public: */ static FileStream OpenRead(const char* utf8Path); + /** + * \brief Open a file for reading at the given path. + */ + static FileStream OpenRead(const std::filesystem::path& path); + /** * \brief Create a file for writing. * @@ -41,16 +48,33 @@ public: */ static FileStream Create(const char* utf8Path); + /** + * \brief Create a file for writing. + * + * If there is an existing file, its contents are demolished. + */ + static FileStream Create(const std::filesystem::path& path); + /** * \brief Read the byte contents of a file directly into a vector. */ static std::vector ReadAllBytes(const char* utf8Path); + /** + * \brief Read the byte contents of a file directly into a vector. + */ + static std::vector ReadAllBytes(const std::filesystem::path& path); + /** * \brief Read the byte contents of a file directly into a vector. */ static void WriteAllText(const char* utf8Path, std::string_view text); + /** + * \brief Read the byte contents of a file directly into a vector. + */ + static void WriteAllText(const std::filesystem::path& path, std::string_view text); + /** * \brief Read the remaining contents of the file directly into a vector. */ @@ -67,6 +91,8 @@ public: * Write data to the file. */ void Write(const char* data, size_t dataLen); + + FILE* ToInner(); }; } diff --git a/include/dusk/main.h b/include/dusk/main.h index 787c2541e2..c5c4fb27d1 100644 --- a/include/dusk/main.h +++ b/include/dusk/main.h @@ -7,6 +7,14 @@ #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; @@ -22,6 +30,7 @@ namespace dusk { #endif void RequestRestart() noexcept; + bool OpenDataFolder(); } #endif // DUSK_MAIN_H diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml index f3e11030ca..f5a0365856 100644 --- a/platforms/android/app/src/main/AndroidManifest.xml +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -24,6 +24,17 @@ + + + + + + = Build.VERSION_CODES.R) { - WindowInsetsController ctrl = getWindow().getDecorView().getWindowInsetsController(); + window.setDecorFitsSystemWindows(false); + WindowInsetsController ctrl = window.getDecorView().getWindowInsetsController(); if (ctrl != null) { + ctrl.setSystemBarsBehavior( + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); ctrl.hide(WindowInsets.Type.systemBars()); } - }else { - View decorView = getWindow().getDecorView(); - // Hide the status bar. - int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN; + } else { + View decorView = window.getDecorView(); + int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE; decorView.setSystemUiVisibility(uiOptions); - // Remember that you should never show the action bar if the - // status bar is hidden, so hide that too if necessary. ActionBar actionBar = getActionBar(); if (actionBar != null) { actionBar.hide(); 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 new file mode 100644 index 0000000000..79fa8894d0 --- /dev/null +++ b/platforms/android/app/src/main/java/com/twilitrealm/dusk/DuskDocumentsProvider.java @@ -0,0 +1,413 @@ +package dev.twilitrealm.dusk; + +import android.content.ContentResolver; +import android.content.res.AssetFileDescriptor; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.net.Uri; +import android.os.Bundle; +import android.os.CancellationSignal; +import android.os.ParcelFileDescriptor; +import android.provider.DocumentsContract; +import android.provider.DocumentsContract.Document; +import android.provider.DocumentsContract.Root; +import android.provider.DocumentsProvider; +import android.webkit.MimeTypeMap; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; + +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 DIRECTORY_MIME_TYPE = Document.MIME_TYPE_DIR; + + private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { + Root.COLUMN_ROOT_ID, + Root.COLUMN_FLAGS, + Root.COLUMN_TITLE, + Root.COLUMN_DOCUMENT_ID, + Root.COLUMN_ICON, + Root.COLUMN_AVAILABLE_BYTES, + Root.COLUMN_SUMMARY + }; + + private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { + Document.COLUMN_DOCUMENT_ID, + Document.COLUMN_DISPLAY_NAME, + Document.COLUMN_FLAGS, + Document.COLUMN_MIME_TYPE, + Document.COLUMN_LAST_MODIFIED, + Document.COLUMN_SIZE + }; + + @Override + public boolean onCreate() { + ensureUserDirectories(); + return true; + } + + @Override + public Cursor queryRoots(String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); + final File root = getRootDirectory(); + final MatrixCursor.RowBuilder row = result.newRow(); + + row.add(Root.COLUMN_ROOT_ID, ROOT_ID); + row.add(Root.COLUMN_FLAGS, + Root.FLAG_LOCAL_ONLY | + Root.FLAG_SUPPORTS_CREATE | + Root.FLAG_SUPPORTS_IS_CHILD); + row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_name)); + row.add(Root.COLUMN_DOCUMENT_ID, ROOT_DOCUMENT_ID); + row.add(Root.COLUMN_ICON, R.mipmap.icon); + row.add(Root.COLUMN_AVAILABLE_BYTES, root.getFreeSpace()); + row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.documents_provider_summary)); + + return result; + } + + @Override + public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { + final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); + includeDocument(result, documentId, getFileForDocumentId(documentId)); + return result; + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) + throws FileNotFoundException + { + return queryChildDocumentsInternal(parentDocumentId, projection); + } + + @Override + public Cursor queryChildDocuments(String parentDocumentId, String[] projection, Bundle queryArgs) + throws FileNotFoundException + { + return queryChildDocumentsInternal(parentDocumentId, projection); + } + + private Cursor queryChildDocumentsInternal(String parentDocumentId, String[] projection) + throws FileNotFoundException + { + final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); + final File parent = getFileForDocumentId(parentDocumentId); + final File[] files = parent.listFiles(); + result.setNotificationUri(getContext().getContentResolver(), getChildDocumentsUri(parentDocumentId)); + + if (files == null) { + return result; + } + + for (File file : files) { + includeDocument(result, getDocumentIdForFile(file), file); + } + + return result; + } + + @Override + public boolean isChildDocument(String parentDocumentId, String documentId) { + try { + final File parent = getFileForDocumentId(parentDocumentId); + final File child = getFileForDocumentId(documentId); + return isInside(parent, child); + } catch (FileNotFoundException e) { + return false; + } + } + + @Override + public String createDocument(String parentDocumentId, String mimeType, String displayName) + throws FileNotFoundException + { + final File parent = getFileForDocumentId(parentDocumentId); + if (!parent.isDirectory()) { + throw new FileNotFoundException("Parent is not a directory: " + parentDocumentId); + } + + final String safeDisplayName = sanitizeDisplayName(displayName); + final File file = buildUniqueFile(parent, safeDisplayName); + final boolean created; + if (DIRECTORY_MIME_TYPE.equals(mimeType)) { + created = file.mkdir(); + } else { + try { + created = file.createNewFile(); + } catch (IOException e) { + throw asFileNotFound("Unable to create document", e); + } + } + + if (!created) { + throw new FileNotFoundException("Unable to create document: " + displayName); + } + + notifyChildrenChanged(parentDocumentId); + return getDocumentIdForFile(file); + } + + @Override + public String renameDocument(String documentId, String displayName) throws FileNotFoundException { + final File file = getFileForDocumentId(documentId); + if (ROOT_DOCUMENT_ID.equals(documentId)) { + throw new FileNotFoundException("Cannot rename root document"); + } + + final File target = buildUniqueFile(file.getParentFile(), sanitizeDisplayName(displayName)); + final String parentDocumentId = getDocumentIdForFile(file.getParentFile()); + if (!file.renameTo(target)) { + throw new FileNotFoundException("Unable to rename document: " + documentId); + } + notifyDocumentChanged(documentId); + notifyDocumentChanged(getDocumentIdForFile(target)); + notifyChildrenChanged(parentDocumentId); + return getDocumentIdForFile(target); + } + + @Override + public void deleteDocument(String documentId) throws FileNotFoundException { + if (ROOT_DOCUMENT_ID.equals(documentId)) { + throw new FileNotFoundException("Cannot delete root document"); + } + + final File file = getFileForDocumentId(documentId); + final String parentDocumentId = getDocumentIdForFile(file.getParentFile()); + deleteRecursively(file); + notifyDocumentChanged(documentId); + notifyChildrenChanged(parentDocumentId); + } + + @Override + public ParcelFileDescriptor openDocument(String documentId, String mode, CancellationSignal signal) + throws FileNotFoundException + { + return ParcelFileDescriptor.open(getFileForDocumentId(documentId), modeToParcelMode(mode)); + } + + @Override + public AssetFileDescriptor openDocumentThumbnail(String documentId, android.graphics.Point sizeHint, + CancellationSignal signal) throws FileNotFoundException + { + throw new FileNotFoundException("Thumbnails are not supported"); + } + + private void includeDocument(MatrixCursor result, String documentId, File file) throws FileNotFoundException { + final MatrixCursor.RowBuilder row = result.newRow(); + final boolean isDirectory = file.isDirectory(); + final String displayName = ROOT_DOCUMENT_ID.equals(documentId) + ? getContext().getString(R.string.documents_provider_root_name) + : file.getName(); + + int flags = Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME; + if (isDirectory) { + flags |= Document.FLAG_DIR_SUPPORTS_CREATE; + } else if (file.canWrite()) { + flags |= Document.FLAG_SUPPORTS_WRITE; + } + if (ROOT_DOCUMENT_ID.equals(documentId)) { + flags &= ~(Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_RENAME); + } + + row.add(Document.COLUMN_DOCUMENT_ID, documentId); + row.add(Document.COLUMN_DISPLAY_NAME, displayName); + row.add(Document.COLUMN_FLAGS, flags); + row.add(Document.COLUMN_MIME_TYPE, isDirectory ? DIRECTORY_MIME_TYPE : getMimeType(file)); + row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified()); + row.add(Document.COLUMN_SIZE, isDirectory ? null : file.length()); + } + + private File getRootDirectory() throws FileNotFoundException { + final File root = getContext().getFilesDir(); + if (root == null) { + throw new FileNotFoundException("Dusk files directory is unavailable"); + } + return root; + } + + private File getFileForDocumentId(String documentId) throws FileNotFoundException { + final File root = getRootDirectory(); + if (ROOT_DOCUMENT_ID.equals(documentId)) { + return root; + } + if (!documentId.startsWith(ROOT_DOCUMENT_ID + "/")) { + throw new FileNotFoundException("Invalid document id: " + documentId); + } + + final String relativePath = documentId.substring(ROOT_DOCUMENT_ID.length() + 1); + final File file = new File(root, relativePath); + if (!isInside(root, file)) { + throw new FileNotFoundException("Document escapes Dusk files directory: " + documentId); + } + if (!file.exists()) { + throw new FileNotFoundException("Document does not exist: " + documentId); + } + return file; + } + + private String getDocumentIdForFile(File file) throws FileNotFoundException { + final File root = getRootDirectory(); + if (sameFile(root, file)) { + return ROOT_DOCUMENT_ID; + } + if (!isInside(root, file)) { + throw new FileNotFoundException("File escapes Dusk files directory: " + file); + } + + final String rootPath = canonicalPath(root); + final String filePath = canonicalPath(file); + return ROOT_DOCUMENT_ID + "/" + filePath.substring(rootPath.length() + 1); + } + + private void ensureUserDirectories() { + final File root = getContext().getFilesDir(); + if (root == null) { + return; + } + new File(root, "texture_replacements").mkdirs(); + new File(root, "USA/Card A").mkdirs(); + new File(root, "EUR/Card A").mkdirs(); + } + + private static String[] resolveRootProjection(String[] projection) { + return projection != null ? projection : DEFAULT_ROOT_PROJECTION; + } + + private static String[] resolveDocumentProjection(String[] projection) { + return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; + } + + private static String sanitizeDisplayName(String displayName) throws FileNotFoundException { + if (displayName == null) { + throw new FileNotFoundException("Document name is empty"); + } + + final String sanitized = displayName.trim(); + if (sanitized.isEmpty() || ".".equals(sanitized) || "..".equals(sanitized) || + sanitized.contains("/") || sanitized.contains("\\")) + { + throw new FileNotFoundException("Invalid document name: " + displayName); + } + return sanitized; + } + + private static File buildUniqueFile(File parent, String displayName) { + File file = new File(parent, displayName); + if (!file.exists()) { + return file; + } + + final int dot = displayName.lastIndexOf('.'); + final String baseName = dot > 0 ? displayName.substring(0, dot) : displayName; + final String extension = dot > 0 ? displayName.substring(dot) : ""; + for (int i = 1; i < 100; ++i) { + file = new File(parent, baseName + " (" + i + ")" + extension); + if (!file.exists()) { + return file; + } + } + return new File(parent, baseName + " (" + System.currentTimeMillis() + ")" + extension); + } + + private static int modeToParcelMode(String mode) { + if ("r".equals(mode)) { + return ParcelFileDescriptor.MODE_READ_ONLY; + } + if ("w".equals(mode) || "wt".equals(mode)) { + return ParcelFileDescriptor.MODE_WRITE_ONLY | + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_TRUNCATE; + } + if ("wa".equals(mode)) { + return ParcelFileDescriptor.MODE_WRITE_ONLY | + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_APPEND; + } + if ("rw".equals(mode)) { + return ParcelFileDescriptor.MODE_READ_WRITE | + ParcelFileDescriptor.MODE_CREATE; + } + if ("rwt".equals(mode)) { + return ParcelFileDescriptor.MODE_READ_WRITE | + ParcelFileDescriptor.MODE_CREATE | + ParcelFileDescriptor.MODE_TRUNCATE; + } + return ParcelFileDescriptor.MODE_READ_ONLY; + } + + private static String getMimeType(File file) { + final int dot = file.getName().lastIndexOf('.'); + if (dot >= 0) { + final String extension = file.getName().substring(dot + 1).toLowerCase(); + final String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); + if (mimeType != null) { + return mimeType; + } + } + return "application/octet-stream"; + } + + private Uri getChildDocumentsUri(String parentDocumentId) { + return DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId); + } + + private void notifyChildrenChanged(String parentDocumentId) { + final ContentResolver resolver = getContext().getContentResolver(); + resolver.notifyChange(getChildDocumentsUri(parentDocumentId), null, false); + } + + private void notifyDocumentChanged(String documentId) { + final ContentResolver resolver = getContext().getContentResolver(); + resolver.notifyChange(DocumentsContract.buildDocumentUri(AUTHORITY, documentId), null, false); + } + + private static void deleteRecursively(File file) throws FileNotFoundException { + if (file.isDirectory()) { + final File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + deleteRecursively(child); + } + } + } + if (!file.delete()) { + throw new FileNotFoundException("Unable to delete document: " + file); + } + } + + private static boolean isInside(File parent, File child) { + try { + final String parentPath = canonicalPath(parent); + final String childPath = canonicalPath(child); + return childPath.equals(parentPath) || childPath.startsWith(parentPath + File.separator); + } catch (FileNotFoundException e) { + return false; + } + } + + private static boolean sameFile(File a, File b) { + try { + return canonicalPath(a).equals(canonicalPath(b)); + } catch (FileNotFoundException e) { + return false; + } + } + + private static String canonicalPath(File file) throws FileNotFoundException { + try { + return file.getCanonicalPath(); + } catch (IOException e) { + throw asFileNotFound("Unable to resolve path", e); + } + } + + private static FileNotFoundException asFileNotFound(String message, IOException cause) { + final FileNotFoundException exception = new FileNotFoundException(message + ": " + cause.getMessage()); + exception.initCause(cause); + return exception; + } +} diff --git a/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java b/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java index 1579b73345..5ed335ac39 100644 --- a/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java +++ b/platforms/android/app/src/main/java/org/libsdl/app/SDLSurface.java @@ -35,6 +35,8 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, View.OnApplyWindowInsetsListener, View.OnKeyListener, View.OnTouchListener, SensorEventListener, ScaleGestureDetector.OnScaleGestureListener { + private static native void auroraNativeSetSurfaceReady(boolean ready); + // Sensors protected SensorManager mSensorManager; protected Display mDisplay; @@ -96,6 +98,7 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, @Override public void surfaceCreated(SurfaceHolder holder) { Log.v("SDL", "surfaceCreated()"); + auroraNativeSetSurfaceReady(false); SDLActivity.onNativeSurfaceCreated(); } @@ -103,6 +106,7 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, @Override public void surfaceDestroyed(SurfaceHolder holder) { Log.v("SDL", "surfaceDestroyed()"); + auroraNativeSetSurfaceReady(false); // Transition to pause, if needed SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED; @@ -192,6 +196,7 @@ public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback, /* Surface is ready */ mIsSurfaceReady = true; + auroraNativeSetSurfaceReady(true); SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED; SDLActivity.handleNativeState(); diff --git a/platforms/android/app/src/main/res/values/strings.xml b/platforms/android/app/src/main/res/values/strings.xml index 79d8812b82..752dae76bf 100644 --- a/platforms/android/app/src/main/res/values/strings.xml +++ b/platforms/android/app/src/main/res/values/strings.xml @@ -1,4 +1,6 @@ Dusk + Dusk Data + Saves, texture packs, settings, and logs diff --git a/platforms/android/gradlew b/platforms/android/gradlew old mode 100644 new mode 100755 diff --git a/platforms/ios/Info.plist.in b/platforms/ios/Info.plist.in index 881d5aff35..72174d487d 100644 --- a/platforms/ios/Info.plist.in +++ b/platforms/ios/Info.plist.in @@ -79,5 +79,9 @@ CADisableMinimumFrameDurationOnPhone + UIFileSharingEnabled + + LSSupportsOpeningDocumentsInPlace + diff --git a/res/rml/window.rcss b/res/rml/window.rcss index 8dd29b55e4..45473f312f 100644 --- a/res/rml/window.rcss +++ b/res/rml/window.rcss @@ -498,7 +498,6 @@ progress.verification-progress-bar { display: flex; flex-direction: row; flex-wrap: nowrap; - justify-content: stretch; align-items: stretch; gap: 12dp; width: 100%; diff --git a/src/dusk/achievements.cpp b/src/dusk/achievements.cpp index cac6f776b6..28e66c891e 100644 --- a/src/dusk/achievements.cpp +++ b/src/dusk/achievements.cpp @@ -1030,7 +1030,7 @@ void AchievementSystem::load() { return; } try { - auto data = io::FileStream::ReadAllBytes(filePath.string().c_str()); + auto data = io::FileStream::ReadAllBytes(filePath); auto j = json::parse(data); if (!j.is_object()) { return; @@ -1067,7 +1067,7 @@ void AchievementSystem::save() { } try { io::FileStream::WriteAllText( - (dusk::ConfigPath / ACHIEVEMENTS_FILENAME).string().c_str(), + dusk::ConfigPath / ACHIEVEMENTS_FILENAME, j.dump(2) ); } catch (const std::exception&) {} diff --git a/src/dusk/config.cpp b/src/dusk/config.cpp index 940797c9ce..8225536d34 100644 --- a/src/dusk/config.cpp +++ b/src/dusk/config.cpp @@ -23,8 +23,8 @@ aurora::Module DuskConfigLog("dusk::config"); static absl::flat_hash_map RegisteredConfigVars; static bool RegistrationDone = false; -static std::string GetConfigJsonPath() { - return (dusk::ConfigPath / ConfigFileName).string(); +static std::u8string GetConfigJsonPath() { + return (dusk::ConfigPath / ConfigFileName).u8string(); } ConfigVarBase::ConfigVarBase(const char* name, const ConfigImplBase* impl) : name(name), registered(false), layer(ConfigVarLayer::Default), impl(impl) { @@ -189,7 +189,7 @@ void dusk::config::LoadFromUserPreferences() { if (configJsonPath.empty()) { return; } - LoadFromFileName(configJsonPath.c_str()); + LoadFromFileName(reinterpret_cast(configJsonPath.c_str())); } static void LoadFromPath(const char* path) { @@ -241,7 +241,9 @@ void dusk::config::Save() { return; } - DuskConfigLog.info("Saving config to '{}'", configJsonPath); + DuskConfigLog.info( + "Saving config to '{}'", + reinterpret_cast(configJsonPath.c_str())); json j; @@ -251,7 +253,7 @@ void dusk::config::Save() { } } - io::FileStream::WriteAllText(configJsonPath.c_str(), j.dump(4)); + io::FileStream::WriteAllText(reinterpret_cast(configJsonPath.c_str()), j.dump(4)); } ConfigVarBase* dusk::config::GetConfigVar(std::string_view name) { diff --git a/src/dusk/gyro.cpp b/src/dusk/gyro.cpp index 4011cbb721..680d500631 100644 --- a/src/dusk/gyro.cpp +++ b/src/dusk/gyro.cpp @@ -140,8 +140,8 @@ void read(float dt) { float my_rel = 0.0f; SDL_GetRelativeMouseState(&mx_rel, &my_rel); // Convert pixels to radians - s_pitch_rad = my_rel * kMousePixelToRad * getSettings().game.gyroSensitivityX; - s_yaw_rad = -mx_rel * kMousePixelToRad * getSettings().game.gyroSensitivityY; + s_pitch_rad = my_rel * kMousePixelToRad * getSettings().game.gyroSensitivityY; + s_yaw_rad = -mx_rel * kMousePixelToRad * getSettings().game.gyroSensitivityX; s_roll_rad = 0.0f; s_pitch_rad = getSettings().game.gyroInvertPitch ? -s_pitch_rad : s_pitch_rad; @@ -184,7 +184,7 @@ void read(float dt) { const float yaw_rate = apply_deadband(s_smooth_gy, deadband); const float roll_rate = apply_deadband(s_smooth_gz, deadband); - s_pitch_rad = -pitch_rate * dt * getSettings().game.gyroSensitivityX; + s_pitch_rad = -pitch_rate * dt * getSettings().game.gyroSensitivityY; s_roll_rad = roll_rate * dt * getSettings().game.gyroSensitivityX; // GYRO NOTE: Exposing Z sensitivity seems unusual, so I'm just using X float horizontal_rate = yaw_rate; @@ -223,7 +223,7 @@ void read(float dt) { } } - s_yaw_rad = horizontal_rate * dt * getSettings().game.gyroSensitivityY; + s_yaw_rad = horizontal_rate * dt * getSettings().game.gyroSensitivityX; s_pitch_rad = getSettings().game.gyroInvertPitch ? -s_pitch_rad : s_pitch_rad; s_yaw_rad = getSettings().game.gyroInvertYaw ? -s_yaw_rad : s_yaw_rad; diff --git a/src/dusk/imgui/ImGuiConsole.cpp b/src/dusk/imgui/ImGuiConsole.cpp index 41a60b4c7f..7d4db50229 100644 --- a/src/dusk/imgui/ImGuiConsole.cpp +++ b/src/dusk/imgui/ImGuiConsole.cpp @@ -379,18 +379,18 @@ namespace dusk { m_menuRandomizer.windowRandoGeneration(); // Hide mouse cursor if the F1 menu is not open and the cursor is idle for 3 seconds. - ImGuiIO& io = ImGui::GetIO(); - if (showMenu) { - mouseHideTimer = 0.0f; - ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; // Imgui will re-show cursor. - } else if (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f) { - mouseHideTimer = 0.0f; - ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; // Imgui will re-show cursor. - } else if (mouseHideTimer <= 3.0f) { - mouseHideTimer += ImGui::GetIO().DeltaTime; - } else { - ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; - SDL_HideCursor(); + if (dusk::getSettings().game.gyroMode.getValue() != GyroMode::Mouse) + { + ImGuiIO& io = ImGui::GetIO(); + if (io.MouseDelta.x != 0.0f || io.MouseDelta.y != 0.0f) { + mouseHideTimer = 0.0f; + ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; // Imgui will re-show cursor. + } else if (mouseHideTimer <= 3.0f) { + mouseHideTimer += ImGui::GetIO().DeltaTime; + } else { + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; + SDL_HideCursor(); + } } ShowToasts(); diff --git a/src/dusk/imgui/ImGuiConsole.hpp b/src/dusk/imgui/ImGuiConsole.hpp index 86c95b2849..6761276ab0 100644 --- a/src/dusk/imgui/ImGuiConsole.hpp +++ b/src/dusk/imgui/ImGuiConsole.hpp @@ -2,11 +2,9 @@ #define DUSK_IMGUI_HPP #include -#include #include #include -#include #include #include "ImGuiMenuGame.hpp" @@ -75,24 +73,4 @@ bool ImGuiButtonCenter(std::string_view text); float ImGuiScale(); } // namespace dusk -#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 - -namespace fs = std::filesystem; - -static void OpenDataFolder() { - const std::string path = fs::absolute(dusk::ConfigPath).generic_string(); -#if defined(_WIN32) - const std::string url = std::string("file:///") + path; -#else - const std::string url = std::string("file://") + path; -#endif - (void)SDL_OpenURL(url.c_str()); -} -#else -#define DUSK_CAN_OPEN_DATA_FOLDER 0 -#endif - #endif // DUSK_IMGUI_HPP diff --git a/src/dusk/imgui/ImGuiMenuGame.cpp b/src/dusk/imgui/ImGuiMenuGame.cpp index 79c032ee35..783bbbdeec 100644 --- a/src/dusk/imgui/ImGuiMenuGame.cpp +++ b/src/dusk/imgui/ImGuiMenuGame.cpp @@ -56,12 +56,12 @@ namespace dusk { } // L+R+A+Start to reset timer - if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigStart(PAD_1)) { + if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigZ(PAD_1)) { m_speedrunInfo.reset(); } // L+R+A+Z to manually stop timer - if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigZ(PAD_1)) { + if (mDoCPd_c::getHoldL(PAD_1) && mDoCPd_c::getHoldR(PAD_1) && mDoCPd_c::getHoldA(PAD_1) && mDoCPd_c::getTrigY(PAD_1)) { if (m_speedrunInfo.m_isRunStarted) { m_speedrunInfo.m_endTimestamp = OSGetTime() - m_speedrunInfo.m_startTimestamp; m_speedrunInfo.m_isRunStarted = false; diff --git a/src/dusk/io.cpp b/src/dusk/io.cpp index 50b5cab5bd..3c87a66acf 100644 --- a/src/dusk/io.cpp +++ b/src/dusk/io.cpp @@ -1,7 +1,8 @@ -#include "dusk/io.hpp" #include #include +#include "dusk/io.hpp" + using namespace dusk::io; #if _WIN32 @@ -58,7 +59,7 @@ static FILE* OpenCore(const char* path, const MODE_TYPE* mode) { FileStream::FileStream() noexcept : file(nullptr) { } -FileStream::FileStream(void* file) : file(file) { +FileStream::FileStream(FILE* file) : file(file) { if (!file) { CRASH("Invalid file handle"); } @@ -78,10 +79,18 @@ FileStream FileStream::OpenRead(const char* utf8Path) { return FileStream(OpenCore(utf8Path, MODE("rb"))); } +FileStream FileStream::OpenRead(const std::filesystem::path& path) { + return FileStream(OpenCore(path, MODE("rb"))); +} + FileStream FileStream::Create(const char* utf8Path) { return FileStream(OpenCore(utf8Path, MODE("wb"))); } +FileStream FileStream::Create(const std::filesystem::path& path) { + return FileStream(OpenCore(path, MODE("wb"))); +} + std::vector FileStream::ReadFull() { const auto fileHandle = ThrowIfNotOpen(*this); @@ -128,7 +137,11 @@ std::vector FileStream::ReadFull() { } std::vector FileStream::ReadAllBytes(const char* utf8Path) { - auto handle = OpenRead(utf8Path); + return ReadAllBytes(reinterpret_cast(utf8Path)); +} + +std::vector FileStream::ReadAllBytes(const std::filesystem::path& path) { + auto handle = OpenRead(path); return std::move(handle.ReadFull()); } @@ -142,6 +155,16 @@ void FileStream::Write(const char* data, size_t dataLen) { } void FileStream::WriteAllText(const char* utf8Path, const std::string_view text) { - auto handle = Create(utf8Path); + WriteAllText(reinterpret_cast(utf8Path), text); +} + +void FileStream::WriteAllText(const std::filesystem::path& path, const std::string_view text) { + auto handle = Create(path); handle.Write(text.data(), text.size()); } + +FILE* FileStream::ToInner() { + auto handle = file; + file = nullptr; + return handle; +} \ No newline at end of file diff --git a/src/dusk/iso_validate.cpp b/src/dusk/iso_validate.cpp index 4d9c48d826..00cd365ca9 100644 --- a/src/dusk/iso_validate.cpp +++ b/src/dusk/iso_validate.cpp @@ -9,6 +9,8 @@ #include #include +#include "dusk/logging.h" + namespace { constexpr uint8_t hex_nibble_to_u8(char c) { @@ -42,6 +44,18 @@ constexpr XXH128_hash_t parse_xxh128(std::string_view hex) { }; } +const char* verification_state_name(dusk::DiscVerificationState state) noexcept { + switch (state) { + case dusk::DiscVerificationState::Success: + return "verified"; + case dusk::DiscVerificationState::HashMismatch: + return "hash mismatch"; + case dusk::DiscVerificationState::Unknown: + default: + return "unknown"; + } +} + } // namespace namespace dusk::iso { @@ -248,4 +262,10 @@ bool isPal(const char* path) { DiscInfo info{}; return inspect(path, info) == ValidationError::Success && info.isPal; } + +void log_verification_state(std::string_view path, DiscVerificationState state) { + const std::string pathText = path.empty() ? "" : std::string(path); + DuskLog.info( + "Disc verification status: {} (path: {})", verification_state_name(state), pathText); +} } // namespace dusk::iso diff --git a/src/dusk/iso_validate.hpp b/src/dusk/iso_validate.hpp index c737a017d9..176284fd02 100644 --- a/src/dusk/iso_validate.hpp +++ b/src/dusk/iso_validate.hpp @@ -31,6 +31,7 @@ struct DiscInfo { ValidationError inspect(const char* path, DiscInfo& info); ValidationError validate(const char* path, VerificationStatus& status, DiscInfo& info); bool isPal(const char* path); +void log_verification_state(std::string_view path, DiscVerificationState state); } // namespace dusk::iso diff --git a/src/dusk/logging.cpp b/src/dusk/logging.cpp index 8b96fcffd5..67723af4df 100644 --- a/src/dusk/logging.cpp +++ b/src/dusk/logging.cpp @@ -8,6 +8,7 @@ #include #include +#include "dusk/io.hpp" #include "tracy/Tracy.hpp" #if TARGET_ANDROID @@ -40,7 +41,7 @@ std::atomic g_logStateAlive(true); struct LogState { std::mutex mutex; FILE* file = nullptr; - std::string filePath; + std::u8string filePath; ~LogState() { CloseFile(); @@ -212,14 +213,14 @@ void dusk::InitializeFileLogging(const std::filesystem::path& configDir, AuroraL } const std::filesystem::path logPath = logsDir / MakeTimestampedLogName(); - g_logState.file = std::fopen(logPath.string().c_str(), "wb"); + g_logState.file = io::FileStream::Create(logPath).ToInner(); if (g_logState.file == nullptr) { std::fprintf(stderr, "[WARNING | dusk] Failed to open log file '%s'\n", logPath.string().c_str()); return; } - g_logState.filePath = logPath.string(); + g_logState.filePath = logPath.u8string(); aurora::g_config.logCallback = &aurora_log_callback; aurora::g_config.logLevel = logLevel; WriteLogLine(g_logState.file, "INFO", "dusk", "File logging initialized", 24); @@ -237,5 +238,6 @@ const char* dusk::GetLogFilePath() { return nullptr; } std::lock_guard lock(g_logState.mutex); - return g_logState.filePath.empty() ? nullptr : g_logState.filePath.c_str(); + return reinterpret_cast( + g_logState.filePath.empty() ? nullptr : g_logState.filePath.c_str()); } diff --git a/src/dusk/ui/document.cpp b/src/dusk/ui/document.cpp index a7bcc3f9ed..4296ac27d9 100644 --- a/src/dusk/ui/document.cpp +++ b/src/dusk/ui/document.cpp @@ -5,6 +5,7 @@ #include "Z2AudioLib/Z2SeMgr.h" #include "m_Do/m_Do_audio.h" +#include namespace dusk::ui { namespace { @@ -106,6 +107,7 @@ bool Document::visible() const { bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { if (cmd == NavCommand::Menu) { + toggle_cursor_if_gyro(!visible()); mDoAud_seStartMenu(visible() ? kSoundMenuClose : kSoundMenuOpen); toggle(); return true; @@ -113,4 +115,17 @@ bool Document::handle_nav_command(Rml::Event& event, NavCommand cmd) { return false; } +void Document::toggle_cursor_if_gyro(bool cursor_enabled) { + if (dusk::getSettings().game.gyroMode.getValue() == GyroMode::Mouse) + { + if (cursor_enabled) { + ImGui::GetIO().ConfigFlags &= ~ImGuiConfigFlags_NoMouseCursorChange; + SDL_ShowCursor(); + } else { + ImGui::GetIO().ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange; + SDL_HideCursor(); + } + } +} + } // namespace dusk::ui diff --git a/src/dusk/ui/document.hpp b/src/dusk/ui/document.hpp index d0f4cae841..eed489beb3 100644 --- a/src/dusk/ui/document.hpp +++ b/src/dusk/ui/document.hpp @@ -43,6 +43,8 @@ public: bool pending_close() const { return mPendingClose; } bool closed() const { return mClosed; } + void toggle_cursor_if_gyro(bool); + protected: virtual bool handle_nav_command(Rml::Event& event, NavCommand cmd); diff --git a/src/dusk/ui/graphics_tuner.cpp b/src/dusk/ui/graphics_tuner.cpp index 8a11528914..4f68d4a078 100644 --- a/src/dusk/ui/graphics_tuner.cpp +++ b/src/dusk/ui/graphics_tuner.cpp @@ -193,9 +193,9 @@ Rml::String format_graphics_setting_value(GraphicsOption option, int value) { return ""; } -GraphicsTuner::GraphicsTuner(GraphicsTunerProps props) +GraphicsTuner::GraphicsTuner(GraphicsTunerProps props, bool prelaunch) : Document(kDocumentSource), mOption(props.option), mValueMin(props.valueMin), - mValueMax(props.valueMax), mDefaultValue(props.defaultValue) { + mValueMax(props.valueMax), mDefaultValue(props.defaultValue), mPrelaunch(prelaunch) { if (mDocument == nullptr) { return; } @@ -207,7 +207,7 @@ GraphicsTuner::GraphicsTuner(GraphicsTunerProps props) description->SetInnerRML(escape(props.helpText)); } if (auto* carouselParent = mDocument->GetElementById("carousel-container")) { - add_component(carouselParent, + mCarousel = &add_component(carouselParent, SteppedCarousel::Props{ .min = mValueMin, .max = mValueMax, @@ -281,7 +281,12 @@ bool GraphicsTuner::handle_nav_command(Rml::Event& event, NavCommand cmd) { pop(); return true; } - return Document::handle_nav_command(event, cmd); + + if (mCarousel && mCarousel->handle_nav_command(cmd)) { + return true; + } + + return mPrelaunch ? false : Document::handle_nav_command(event, cmd); } void GraphicsTuner::reset_default() { diff --git a/src/dusk/ui/graphics_tuner.hpp b/src/dusk/ui/graphics_tuner.hpp index 3f2418ef30..254aada22b 100644 --- a/src/dusk/ui/graphics_tuner.hpp +++ b/src/dusk/ui/graphics_tuner.hpp @@ -28,9 +28,9 @@ public: bool focus() override; void update() override; + bool handle_nav_command(NavCommand cmd); private: - bool handle_nav_command(NavCommand cmd); void apply(int value); Props mProps; @@ -59,7 +59,7 @@ struct GraphicsTunerProps { class GraphicsTuner : public Document { public: - explicit GraphicsTuner(GraphicsTunerProps props); + explicit GraphicsTuner(GraphicsTunerProps props, bool prelaunch); void show() override; void hide(bool close) override; @@ -86,7 +86,9 @@ private: int mValueMax = 0; int mDefaultValue = 0; std::vector > mComponents; + SteppedCarousel* mCarousel; Rml::Element* mRoot; + bool mPrelaunch; }; } // namespace dusk::ui diff --git a/src/dusk/ui/menu_bar.cpp b/src/dusk/ui/menu_bar.cpp index 81c2eea441..ea791d745b 100644 --- a/src/dusk/ui/menu_bar.cpp +++ b/src/dusk/ui/menu_bar.cpp @@ -42,6 +42,7 @@ MenuBar::MenuBar() : Document(kDocumentSource), mRoot(mDocument->GetElementById( mTabBar = std::make_unique(mRoot, TabBar::Props{ .onClose = [this] { + toggle_cursor_if_gyro(false); mDoAud_seStartMenu(kSoundMenuClose); hide(false); }, @@ -203,6 +204,7 @@ bool MenuBar::handle_nav_command(Rml::Event& event, NavCommand cmd) { return true; } if (cmd == NavCommand::Cancel && visible()) { + toggle_cursor_if_gyro(false); mDoAud_seStartMenu(kSoundMenuClose); hide(false); return true; diff --git a/src/dusk/ui/prelaunch.cpp b/src/dusk/ui/prelaunch.cpp index 5c41958409..03ad57a3bb 100644 --- a/src/dusk/ui/prelaunch.cpp +++ b/src/dusk/ui/prelaunch.cpp @@ -302,9 +302,17 @@ std::string get_error_msg(iso::ValidationError error) { } void persist_disc_choice(const std::string& path, iso::ValidationError validation) { + const auto previousPath = getSettings().backend.isoPath.getValue(); + const auto previousVerification = getSettings().backend.isoVerification.getValue(); + const auto verification = verification_to_config(validation); + getSettings().backend.isoPath.setValue(path); - getSettings().backend.isoVerification.setValue(verification_to_config(validation)); + getSettings().backend.isoVerification.setValue(verification); config::Save(); + + if (previousPath != path || previousVerification != verification) { + iso::log_verification_state(path, verification); + } } void apply_valid_disc_result( @@ -687,6 +695,8 @@ Prelaunch::Prelaunch() : Document(kDocumentSource), mRoot(mDocument->GetElementB return; } + toggle_cursor_if_gyro(false); + mDoAud_seStartMenu(kSoundPlay); show_menu_notification(); diff --git a/src/dusk/ui/settings.cpp b/src/dusk/ui/settings.cpp index fd7365a39d..d904c823d7 100644 --- a/src/dusk/ui/settings.cpp +++ b/src/dusk/ui/settings.cpp @@ -9,6 +9,7 @@ #include "dusk/file_select.hpp" #include "dusk/imgui/ImGuiEngine.hpp" #include "dusk/livesplit.h" +#include "dusk/main.h" #include "graphics_tuner.hpp" #include "m_Do/m_Do_main.h" #include "menu_bar.hpp" @@ -274,7 +275,7 @@ SelectButton& config_percent_select(Pane& leftPane, Pane& rightPane, ConfigVar void graphics_tuner_control(Window& window, Pane& leftPane, Pane& rightPane, ConfigVar& var, - const GraphicsTunerProps& props) { + const GraphicsTunerProps& props, bool prelaunch) { leftPane.register_control( leftPane .add_select_button({ @@ -292,10 +293,10 @@ void graphics_tuner_control(Window& window, Pane& leftPane, Pane& rightPane, Con .isModified = [&var] { return var.getValue() != var.getDefaultValue(); }, .submit = false, }) - .on_nav_command([&window, props](Rml::Event&, NavCommand cmd) { + .on_nav_command([&window, props, prelaunch](Rml::Event&, NavCommand cmd) { if (cmd == NavCommand::Confirm || cmd == NavCommand::Left || cmd == NavCommand::Right) { - window.push(std::make_unique(props)); + window.push(std::make_unique(props, prelaunch)); return true; } return false; @@ -551,7 +552,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = 0, .valueMax = 12, .defaultValue = 0, - }); + }, mPrelaunch); graphics_tuner_control(*this, leftPane, rightPane, getSettings().game.shadowResolutionMultiplier, GraphicsTunerProps{ @@ -561,7 +562,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = 1, .valueMax = 8, .defaultValue = 1, - }); + }, mPrelaunch); leftPane.add_section("Post-Processing"); graphics_tuner_control(*this, leftPane, rightPane, getSettings().game.bloomMode, @@ -572,7 +573,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = static_cast(BloomMode::Off), .valueMax = static_cast(BloomMode::Dusk), .defaultValue = static_cast(BloomMode::Classic), - }); + }, mPrelaunch); graphics_tuner_control(*this, leftPane, rightPane, getSettings().game.bloomMultiplier, GraphicsTunerProps{ .option = GraphicsOption::BloomMultiplier, @@ -581,7 +582,7 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { .valueMin = 0, .valueMax = 100, .defaultValue = 100, - }); + }, mPrelaunch); leftPane.add_section("Rendering"); config_bool_select(leftPane, rightPane, getSettings().game.enableFrameInterpolation, @@ -946,6 +947,18 @@ SettingsWindow::SettingsWindow(bool prelaunch) : mPrelaunch(prelaunch) { auto& rightPane = add_child(content, Pane::Type::Uncontrolled); leftPane.add_section("Dusk"); +#if DUSK_CAN_OPEN_DATA_FOLDER + leftPane.register_control( + leftPane.add_button("Open Data Folder").on_pressed([] { + mDoAud_seStartMenu(kSoundClick); + dusk::OpenDataFolder(); + }), + rightPane, [](Pane& pane) { + pane.add_text( + "Open the folder where Dusk stores settings, saves, logs, texture " + "replacements, and other app data."); + }); +#endif leftPane.register_control( leftPane.add_select_button({ .key = "Notifications", diff --git a/src/m_Do/m_Do_main.cpp b/src/m_Do/m_Do_main.cpp index a2e368f8ca..806ceea3f3 100644 --- a/src/m_Do/m_Do_main.cpp +++ b/src/m_Do/m_Do_main.cpp @@ -71,6 +71,7 @@ #include #include "SDL3/SDL_filesystem.h" +#include "SDL3/SDL_misc.h" #include "cxxopts.hpp" #include "d/actor/d_a_movie_player.h" #include "dusk/audio/DuskAudioSystem.h" @@ -83,6 +84,9 @@ #include "f_pc/f_pc_draw.h" #include "tracy/Tracy.hpp" #include +#ifdef __APPLE__ +#include +#endif #if RANDOMIZER_ONLY #include "dusk/randomizer/generator/randomizer.hpp" @@ -119,6 +123,31 @@ 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 '{}': {}", + ConfigPath.string(), 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 '{}': {}", path.string(), SDL_GetError()); + return false; + } + return true; +#else + return false; +#endif +} + s32 LOAD_COPYDATE(void*) { char buffer[32]; memset(buffer, 0, sizeof(buffer)); @@ -397,13 +426,77 @@ static void ApplyCVarOverrides(const cxxopts::OptionValue& option) { } } -static std::filesystem::path CalculateConfigPath() { +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 result; + return reinterpret_cast(result); } static void EnsureInitialPipelineCache(const std::filesystem::path& configDir) { @@ -552,7 +645,7 @@ int game_main(int argc, char* argv[]) { exit(1); } - dusk::ConfigPath = CalculateConfigPath(); + dusk::ConfigPath = calculate_config_path(); const auto startupLogLevel = static_cast(parsed_arg_options["log-level"].as()); dusk::InitializeFileLogging(dusk::ConfigPath, startupLogLevel); @@ -560,13 +653,14 @@ int game_main(int argc, char* argv[]) { ApplyCVarOverrides(parsed_arg_options["cvar"]); dusk::InitializeCrashReporting(); EnsureInitialPipelineCache(dusk::ConfigPath); - PADSetDefaultMapping(&defaultPadMapping); + // TODO: How to handle this? + //PADSetDefaultMapping(&defaultPadMapping, PAD_TYPE_STANDARD); { - const auto configPathString = dusk::ConfigPath.string(); + const auto configPathString = dusk::ConfigPath.u8string(); AuroraConfig config{}; config.appName = dusk::AppName; - config.configPath = configPathString.c_str(); + config.configPath = reinterpret_cast(configPathString.c_str()); config.vsync = dusk::getSettings().video.enableVsync; config.startFullscreen = dusk::getSettings().video.enableFullscreen; config.windowPosX = -1; @@ -664,6 +758,10 @@ int game_main(int argc, char* argv[]) { } } + dusk::iso::log_verification_state( + dusk::getSettings().backend.isoPath.getValue(), + dusk::getSettings().backend.isoVerification.getValue()); + if (!dvd_opened) { if (dusk::getSettings().backend.isoPath.getValue().empty()) { forcePreLaunchUI = true;