diff --git a/app/src/main/assets/wfm/LICENSE.WFM.txt b/app/src/main/assets/wfm/LICENSE.WFM.txt
new file mode 100644
index 0000000000..46aea4107a
--- /dev/null
+++ b/app/src/main/assets/wfm/LICENSE.WFM.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 BrunoSX
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/app/src/main/assets/wfm/NOTICE.txt b/app/src/main/assets/wfm/NOTICE.txt
new file mode 100644
index 0000000000..09de3f3067
--- /dev/null
+++ b/app/src/main/assets/wfm/NOTICE.txt
@@ -0,0 +1,23 @@
+Winlator File Manager - GameNative native-copy patch
+====================================================
+
+The wfm.exe payload is built from the original Winlator File Manager:
+https://github.com/brunodev85/wfm
+
+Source commit:
+cb1d078cef36d3d61eec822882febcde828f09e7
+
+Only the copy/paste implementation and its conflict prompts are changed. The
+patches are included alongside this notice as native-copy.patch,
+replace-all-conflicts.patch, and copy-hardening.patch, in that application
+order using standard `git apply` commands. Together they replace ACTION_COPY's
+shell32 SHFileOperation call with direct Win32 file-copy calls; offer Replace
+All, Review Each, or Cancel on the first conflict; and add safe handling for
+read-only files, reparse points, type conflicts, copy errors, path limits,
+copying a folder into its descendant through direct or aliased drive paths, and
+basic directory metadata. Unsupported reparse-point entries are skipped and
+summarized after the copy. The original WFM user interface, directory
+enumeration, move behavior, and delete behavior are retained.
+
+Winlator File Manager is licensed under the MIT License. Its copyright and
+permission notice are included in LICENSE.WFM.txt.
diff --git a/app/src/main/assets/wfm/README.md b/app/src/main/assets/wfm/README.md
new file mode 100644
index 0000000000..b0736e336c
--- /dev/null
+++ b/app/src/main/assets/wfm/README.md
@@ -0,0 +1,37 @@
+# Rebuilding `wfm.exe`
+
+The payload is built from Winlator File Manager commit
+`cb1d078cef36d3d61eec822882febcde828f09e7`.
+
+Use the 64-bit w64devkit 2.9.0 archive
+`w64devkit-x64-2.9.0.7z.exe`:
+
+- Archive SHA-256:
+ `bff1d13fc2718eebd93548cf37f8d0332d925458d5e99506cff8f46eb5a9de5a`
+- GCC: 16.1.0
+- GNU binutils/windres: 2.47.20260726
+
+Clone the upstream source, check out the pinned commit, copy the three patch
+files from this directory into the checkout, and apply them in order:
+
+```sh
+git clone https://github.com/brunodev85/wfm.git
+cd wfm
+git checkout cb1d078cef36d3d61eec822882febcde828f09e7
+git apply native-copy.patch
+git apply replace-all-conflicts.patch
+git apply copy-hardening.patch
+```
+
+From a w64devkit shell in that clean checkout, run this exact build command.
+The object order is intentional and is required for a byte-identical result:
+
+```sh
+make CC=gcc RC=windres "INCLUDE_DIR=-I./include" "OBJS=obj/file_actions.o obj/main.o obj/content_view.o obj/toolbar.o obj/navbar.o obj/treeview.o obj/sizebar.o obj/statusbar.o obj/file_node.o obj/file_utils.o obj/input_dialog.o obj/resource.o" "CFLAGS=-O3 -std=c99 -DUNICODE -D_UNICODE -DCOBJMACROS -D_CRT_NON_CONFORMING_WCSTOK -D_WIN32_IE=0x0500 -DWINVER=0x500 -Wall -Wno-error=incompatible-pointer-types -Wno-error=int-conversion" "LDFLAGS=-s -lcomctl32 -lgdi32 -lole32 -luuid -Wl,--subsystem,windows"
+```
+
+The expected output is 304,640 bytes with SHA-256:
+
+```text
+a7bb48aa14c59ece23c011c43c8439869267b13387d4a972f462a06575deebbb
+```
diff --git a/app/src/main/assets/wfm/copy-hardening.patch b/app/src/main/assets/wfm/copy-hardening.patch
new file mode 100644
index 0000000000..75f9d5b510
--- /dev/null
+++ b/app/src/main/assets/wfm/copy-hardening.patch
@@ -0,0 +1,583 @@
+From: GameNative local compatibility build
+Subject: [PATCH] Harden native WFM copies for filesystem edge cases
+
+Apply after native-copy.patch and replace-all-conflicts.patch.
+
+Handle read-only replacements, type conflicts, copy failures, path limits,
+directory metadata, unsupported reparse-point entries, and mapped-drive aliases.
+Reject destinations that resolve inside the source tree.
+
+diff --git a/src/file_actions.c b/src/file_actions.c
+index 45a2c60..225c9a1 100644
+--- a/src/file_actions.c
++++ b/src/file_actions.c
+@@ -28,6 +28,7 @@ struct ActionData {
+ wchar_t* dstPath;
+ bool cancel;
+ enum ConflictPolicy conflictPolicy;
++ int skippedCount;
+ };
+
+ static HWND hwndDlg;
+@@ -135,17 +136,288 @@ INT_PTR CALLBACK FileActionDialogProc(HWND hwndDlg, UINT msg, WPARAM wParam, LPA
+ enum CopyResult {
+ COPY_RESULT_FAILED,
+ COPY_RESULT_OK,
+- COPY_RESULT_CANCELLED
++ COPY_RESULT_CANCELLED,
++ COPY_RESULT_SKIPPED
+ };
+
+ static enum CopyResult copyPathNative(wchar_t* srcPath, wchar_t* dstPath);
+
+-static enum CopyResult confirmAndCopyFile(wchar_t* srcPath, wchar_t* dstPath) {
++static enum CopyResult failCopy(
++ const wchar_t* srcPath,
++ const wchar_t* dstPath,
++ DWORD error,
++ const wchar_t* detail
++) {
++ wchar_t systemMessage[256] = {0};
++ DWORD messageLength = FormatMessage(
++ FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
++ NULL,
++ error,
++ 0,
++ systemMessage,
++ sizeof(systemMessage) / sizeof(systemMessage[0]),
++ NULL
++ );
++ if (messageLength == 0) {
++ wcscpy_s(systemMessage, 256, L"Unknown Windows error.");
++ }
++
++ wchar_t message[MAX_PATH * 2 + 768] = {0};
++ int result = _snwprintf_s(
++ message,
++ sizeof(message) / sizeof(message[0]),
++ _TRUNCATE,
++ L"%ls\n\nFrom:\n%ls\n\nTo:\n%ls\n\n%ls (error %lu)",
++ detail,
++ srcPath,
++ dstPath,
++ systemMessage,
++ error
++ );
++ if (result < 0) {
++ _snwprintf_s(
++ message,
++ sizeof(message) / sizeof(message[0]),
++ _TRUNCATE,
++ L"%ls\n\nThe affected path is too long to display.\n\nError %lu",
++ detail,
++ error
++ );
++ }
++
++ MessageBox(hwndDlg, message, L"Copy Failed", MB_OK | MB_ICONERROR);
++ SetLastError(error);
++ return COPY_RESULT_FAILED;
++}
++
++static bool joinCopyPath(
++ const wchar_t* directory,
++ const wchar_t* name,
++ wchar_t* destination,
++ size_t destinationSize
++) {
++ int length = _snwprintf_s(
++ destination,
++ destinationSize,
++ _TRUNCATE,
++ L"%ls\\%ls",
++ directory,
++ name
++ );
++ return length >= 0 && (size_t)length < destinationSize;
++}
++
++#define COPY_RESOLVED_PATH_SIZE 1024
++
++static bool isCopyPathSeparator(wchar_t value) {
++ return value == L'\\' || value == L'/';
++}
++
++static size_t trimCopyPath(wchar_t* path) {
++ size_t length = wcslen(path);
++ while (length > 3 && isCopyPathSeparator(path[length - 1])) {
++ path[--length] = L'\0';
++ }
++ return length;
++}
++
++static bool isSameOrDescendantCopyPath(
++ wchar_t* ancestor,
++ wchar_t* candidate,
++ bool includeSame
++) {
++ size_t ancestorLength = trimCopyPath(ancestor);
++ size_t candidateLength = trimCopyPath(candidate);
++ if (candidateLength < ancestorLength ||
++ _wcsnicmp(ancestor, candidate, ancestorLength) != 0) {
++ return false;
++ }
++ if (candidateLength == ancestorLength) return includeSame;
++ if (isCopyPathSeparator(ancestor[ancestorLength - 1])) return true;
++ return isCopyPathSeparator(candidate[ancestorLength]);
++}
++
++static bool resolveFinalCopyPath(
++ const wchar_t* path,
++ wchar_t* resolvedPath,
++ DWORD resolvedPathSize
++) {
++ HANDLE handle = CreateFile(
++ path,
++ 0,
++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
++ NULL,
++ OPEN_EXISTING,
++ FILE_FLAG_BACKUP_SEMANTICS,
++ NULL
++ );
++ if (handle == INVALID_HANDLE_VALUE) return false;
++
++ DWORD length = GetFinalPathNameByHandle(
++ handle,
++ resolvedPath,
++ resolvedPathSize,
++ FILE_NAME_NORMALIZED | VOLUME_NAME_DOS
++ );
++ CloseHandle(handle);
++ return length > 0 && length < resolvedPathSize;
++}
++
++static bool removeLastCopyPathComponent(wchar_t* path) {
++ size_t length = trimCopyPath(path);
++ if (length <= 3) return false;
++
++ wchar_t* separator = wcsrchr(path, L'\\');
++ wchar_t* alternateSeparator = wcsrchr(path, L'/');
++ if (alternateSeparator != NULL &&
++ (separator == NULL || alternateSeparator > separator)) {
++ separator = alternateSeparator;
++ }
++ if (separator == NULL || separator == path) return false;
++ if (separator == path + 2 && path[1] == L':') {
++ separator[1] = L'\0';
++ }
++ else {
++ *separator = L'\0';
++ }
++ return true;
++}
++
++static bool isCopyDestinationDescendant(
++ const wchar_t* srcPath,
++ const wchar_t* dstPath
++) {
++ wchar_t fullSrc[MAX_PATH] = {0};
++ wchar_t fullDst[MAX_PATH] = {0};
++ DWORD srcLength = GetFullPathName(srcPath, MAX_PATH, fullSrc, NULL);
++ DWORD dstLength = GetFullPathName(dstPath, MAX_PATH, fullDst, NULL);
++ if (srcLength == 0 || srcLength >= MAX_PATH ||
++ dstLength == 0 || dstLength >= MAX_PATH) {
++ return false;
++ }
++
++ if (isSameOrDescendantCopyPath(fullSrc, fullDst, false)) return true;
++
++ wchar_t resolvedSrc[COPY_RESOLVED_PATH_SIZE] = {0};
++ if (!resolveFinalCopyPath(
++ fullSrc,
++ resolvedSrc,
++ COPY_RESOLVED_PATH_SIZE)) {
++ return false;
++ }
++
++ wchar_t candidate[MAX_PATH] = {0};
++ wcscpy_s(candidate, MAX_PATH, fullDst);
++ do {
++ wchar_t resolvedCandidate[COPY_RESOLVED_PATH_SIZE] = {0};
++ if (resolveFinalCopyPath(
++ candidate,
++ resolvedCandidate,
++ COPY_RESOLVED_PATH_SIZE)) {
++ return isSameOrDescendantCopyPath(
++ resolvedSrc,
++ resolvedCandidate,
++ true
++ );
++ }
++ }
++ while (removeLastCopyPathComponent(candidate));
++ return false;
++}
++
++static enum CopyResult overwriteFileNative(
++ wchar_t* srcPath,
++ wchar_t* dstPath
++) {
++ DWORD dstAttributes = GetFileAttributes(dstPath);
++ if (dstAttributes == INVALID_FILE_ATTRIBUTES) {
++ DWORD error = GetLastError();
++ if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) {
++ return CopyFile(srcPath, dstPath, TRUE)
++ ? COPY_RESULT_OK
++ : failCopy(
++ srcPath,
++ dstPath,
++ GetLastError(),
++ L"The destination disappeared before it could be replaced."
++ );
++ }
++ return failCopy(
++ srcPath,
++ dstPath,
++ error,
++ L"The existing destination could not be inspected."
++ );
++ }
++
++ if (dstAttributes & FILE_ATTRIBUTE_DIRECTORY) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_ALREADY_EXISTS,
++ L"A folder exists where the copied file needs to be created."
++ );
++ }
++ if (dstAttributes & FILE_ATTRIBUTE_REPARSE_POINT) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_NOT_SUPPORTED,
++ L"Replacing symbolic links and other reparse points is not supported."
++ );
++ }
++
++ bool clearedReadOnly = (dstAttributes & FILE_ATTRIBUTE_READONLY) != 0;
++ DWORD writableAttributes = dstAttributes & ~FILE_ATTRIBUTE_READONLY;
++ if (writableAttributes == 0) writableAttributes = FILE_ATTRIBUTE_NORMAL;
++ if (clearedReadOnly &&
++ !SetFileAttributes(dstPath, writableAttributes)) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ GetLastError(),
++ L"The existing file is read-only and could not be made writable."
++ );
++ }
++
++ if (CopyFile(srcPath, dstPath, FALSE)) return COPY_RESULT_OK;
++
++ DWORD error = GetLastError();
++ if (clearedReadOnly) SetFileAttributes(dstPath, dstAttributes);
++ return failCopy(srcPath, dstPath, error, L"The existing file could not be replaced.");
++}
++
++static enum CopyResult confirmAndCopyFile(
++ wchar_t* srcPath,
++ wchar_t* dstPath
++) {
+ if (CopyFile(srcPath, dstPath, TRUE)) return COPY_RESULT_OK;
+
+ DWORD error = GetLastError();
+- if (error != ERROR_FILE_EXISTS && error != ERROR_ALREADY_EXISTS) {
+- return COPY_RESULT_FAILED;
++ DWORD dstAttributes = GetFileAttributes(dstPath);
++ if (dstAttributes == INVALID_FILE_ATTRIBUTES) {
++ return failCopy(srcPath, dstPath, error, L"The file could not be copied.");
++ }
++ if (dstAttributes & FILE_ATTRIBUTE_DIRECTORY) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_ALREADY_EXISTS,
++ L"A folder exists where the copied file needs to be created."
++ );
++ }
++ if (dstAttributes & FILE_ATTRIBUTE_REPARSE_POINT) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_NOT_SUPPORTED,
++ L"Replacing symbolic links and other reparse points is not supported."
++ );
++ }
++ if (error != ERROR_FILE_EXISTS &&
++ error != ERROR_ALREADY_EXISTS &&
++ error != ERROR_ACCESS_DENIED) {
++ return failCopy(srcPath, dstPath, error, L"The file could not be copied.");
+ }
+
+
+@@ -171,9 +443,7 @@ static enum CopyResult confirmAndCopyFile(wchar_t* srcPath, wchar_t* dstPath) {
+ }
+
+ if (actionData->conflictPolicy == CONFLICT_POLICY_REPLACE_ALL) {
+- return CopyFile(srcPath, dstPath, FALSE)
+- ? COPY_RESULT_OK
+- : COPY_RESULT_FAILED;
++ return overwriteFileNative(srcPath, dstPath);
+ }
+ wchar_t basename[MAX_PATH] = {0};
+ getBasenameFromPath(dstPath, basename, false);
+@@ -198,32 +468,123 @@ static enum CopyResult confirmAndCopyFile(wchar_t* srcPath, wchar_t* dstPath) {
+ }
+ if (response == IDNO) return COPY_RESULT_OK;
+
+- return CopyFile(srcPath, dstPath, FALSE)
+- ? COPY_RESULT_OK
+- : COPY_RESULT_FAILED;
++ return overwriteFileNative(srcPath, dstPath);
++}
++
++static void preserveDirectoryMetadata(const wchar_t* srcPath, const wchar_t* dstPath) {
++ HANDLE srcHandle = CreateFile(
++ srcPath,
++ FILE_READ_ATTRIBUTES,
++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
++ NULL,
++ OPEN_EXISTING,
++ FILE_FLAG_BACKUP_SEMANTICS,
++ NULL
++ );
++ HANDLE dstHandle = CreateFile(
++ dstPath,
++ FILE_WRITE_ATTRIBUTES,
++ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
++ NULL,
++ OPEN_EXISTING,
++ FILE_FLAG_BACKUP_SEMANTICS,
++ NULL
++ );
++
++ if (srcHandle != INVALID_HANDLE_VALUE && dstHandle != INVALID_HANDLE_VALUE) {
++ FILETIME creationTime;
++ FILETIME accessTime;
++ FILETIME writeTime;
++ if (GetFileTime(srcHandle, &creationTime, &accessTime, &writeTime)) {
++ SetFileTime(dstHandle, &creationTime, &accessTime, &writeTime);
++ }
++ }
++ if (srcHandle != INVALID_HANDLE_VALUE) CloseHandle(srcHandle);
++ if (dstHandle != INVALID_HANDLE_VALUE) CloseHandle(dstHandle);
++
++ DWORD srcAttributes = GetFileAttributes(srcPath);
++ if (srcAttributes != INVALID_FILE_ATTRIBUTES) {
++ DWORD basicAttributes = srcAttributes & (
++ FILE_ATTRIBUTE_READONLY |
++ FILE_ATTRIBUTE_HIDDEN |
++ FILE_ATTRIBUTE_SYSTEM |
++ FILE_ATTRIBUTE_ARCHIVE
++ );
++ SetFileAttributes(
++ dstPath,
++ basicAttributes == 0 ? FILE_ATTRIBUTE_NORMAL : basicAttributes
++ );
++ }
+ }
+
+-static enum CopyResult copyDirectoryNative(wchar_t* srcPath, wchar_t* dstPath) {
++static enum CopyResult copyDirectoryNative(
++ wchar_t* srcPath,
++ wchar_t* dstPath
++) {
++ bool createdDestination = false;
+ if (!CreateDirectory(dstPath, NULL)) {
+ DWORD error = GetLastError();
+- if (error != ERROR_ALREADY_EXISTS) return COPY_RESULT_FAILED;
++ if (error != ERROR_ALREADY_EXISTS) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ error,
++ L"The destination folder could not be created."
++ );
++ }
+
+ DWORD dstAttributes = GetFileAttributes(dstPath);
+- if (dstAttributes == INVALID_FILE_ATTRIBUTES ||
+- !(dstAttributes & FILE_ATTRIBUTE_DIRECTORY)) {
+- return COPY_RESULT_FAILED;
++ if (dstAttributes == INVALID_FILE_ATTRIBUTES) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ GetLastError(),
++ L"The existing destination could not be inspected."
++ );
++ }
++ if (!(dstAttributes & FILE_ATTRIBUTE_DIRECTORY)) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_ALREADY_EXISTS,
++ L"A file exists where the copied folder needs to be created."
++ );
++ }
++ if (dstAttributes & FILE_ATTRIBUTE_REPARSE_POINT) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_NOT_SUPPORTED,
++ L"Copying into symbolic links and other reparse points is not supported."
++ );
+ }
+ }
++ else createdDestination = true;
+
+ wchar_t pattern[MAX_PATH] = {0};
+- swprintf_s(pattern, MAX_PATH, L"%ls\\*", srcPath);
++ if (!joinCopyPath(srcPath, L"*", pattern, MAX_PATH)) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_FILENAME_EXCED_RANGE,
++ L"The source folder path is too long."
++ );
++ }
+
+ WIN32_FIND_DATA wfd = {0};
+ HANDLE handle = FindFirstFile(pattern, &wfd);
+ if (handle == INVALID_HANDLE_VALUE) {
+- return GetLastError() == ERROR_FILE_NOT_FOUND
+- ? COPY_RESULT_OK
+- : COPY_RESULT_FAILED;
++ DWORD error = GetLastError();
++ if (error == ERROR_FILE_NOT_FOUND) {
++ if (createdDestination) preserveDirectoryMetadata(srcPath, dstPath);
++ return COPY_RESULT_OK;
++ }
++ return failCopy(
++ srcPath,
++ dstPath,
++ error,
++ L"The source folder could not be enumerated."
++ );
+ }
+
+ enum CopyResult result = COPY_RESULT_OK;
+@@ -235,22 +596,67 @@ static enum CopyResult copyDirectoryNative(wchar_t* srcPath, wchar_t* dstPath) {
+
+ wchar_t childSrc[MAX_PATH] = {0};
+ wchar_t childDst[MAX_PATH] = {0};
+- swprintf_s(childSrc, MAX_PATH, L"%ls\\%ls", srcPath, wfd.cFileName);
+- swprintf_s(childDst, MAX_PATH, L"%ls\\%ls", dstPath, wfd.cFileName);
++ if (!joinCopyPath(srcPath, wfd.cFileName, childSrc, MAX_PATH) ||
++ !joinCopyPath(dstPath, wfd.cFileName, childDst, MAX_PATH)) {
++ result = failCopy(
++ srcPath,
++ dstPath,
++ ERROR_FILENAME_EXCED_RANGE,
++ L"A path inside this folder is too long."
++ );
++ break;
++ }
+
+ result = copyPathNative(childSrc, childDst);
++ if (result == COPY_RESULT_SKIPPED) result = COPY_RESULT_OK;
+ }
+ while (result == COPY_RESULT_OK &&
+ !actionData->cancel &&
+ FindNextFile(handle, &wfd));
+
++ if (result == COPY_RESULT_OK &&
++ !actionData->cancel &&
++ GetLastError() != ERROR_NO_MORE_FILES) {
++ result = failCopy(
++ srcPath,
++ dstPath,
++ GetLastError(),
++ L"The source folder could not be fully enumerated."
++ );
++ }
+ FindClose(handle);
+- return actionData->cancel ? COPY_RESULT_CANCELLED : result;
++ if (actionData->cancel) return COPY_RESULT_CANCELLED;
++ if (result == COPY_RESULT_OK && createdDestination) {
++ preserveDirectoryMetadata(srcPath, dstPath);
++ }
++ return result;
+ }
+
+ static enum CopyResult copyPathNative(wchar_t* srcPath, wchar_t* dstPath) {
++ if (actionData->cancel) return COPY_RESULT_CANCELLED;
++
+ DWORD attributes = GetFileAttributes(srcPath);
+- if (attributes == INVALID_FILE_ATTRIBUTES) return COPY_RESULT_FAILED;
++ if (attributes == INVALID_FILE_ATTRIBUTES) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ GetLastError(),
++ L"The source item could not be inspected."
++ );
++ }
++ if (attributes & FILE_ATTRIBUTE_REPARSE_POINT) {
++ actionData->skippedCount++;
++ return COPY_RESULT_SKIPPED;
++ }
++ if ((attributes & FILE_ATTRIBUTE_DIRECTORY) &&
++ isCopyDestinationDescendant(srcPath, dstPath)) {
++ return failCopy(
++ srcPath,
++ dstPath,
++ ERROR_INVALID_PARAMETER,
++ L"A folder cannot be copied into itself or one of its subfolders."
++ );
++ }
+
+ return attributes & FILE_ATTRIBUTE_DIRECTORY
+ ? copyDirectoryNative(srcPath, dstPath)
+@@ -278,12 +684,21 @@ static DWORD WINAPI fileActionTask(void* param) {
+ wchar_t basename[MAX_PATH] = {0};
+ wchar_t dstPath[MAX_PATH] = {0};
+ getBasenameFromPath(actionData->srcPaths[i], basename, false);
+- swprintf_s(dstPath, MAX_PATH, L"%ls\\%ls", actionData->dstPath, basename);
++ if (!joinCopyPath(actionData->dstPath, basename, dstPath, MAX_PATH)) {
++ failCopy(
++ actionData->srcPaths[i],
++ actionData->dstPath,
++ ERROR_FILENAME_EXCED_RANGE,
++ L"The destination path is too long."
++ );
++ break;
++ }
+
+ // Copying an item onto itself is a no-op.
+ if (wcsicmp(actionData->srcPaths[i], dstPath) == 0) continue;
+
+ enum CopyResult result = copyPathNative(actionData->srcPaths[i], dstPath);
++ if (result == COPY_RESULT_SKIPPED) continue;
+ if (result != COPY_RESULT_OK) break;
+ }
+ else if (actionData->action == ACTION_MOVE) {
+@@ -305,6 +720,18 @@ static DWORD WINAPI fileActionTask(void* param) {
+ lastTime = currTime;
+ }
+ }
++
++ if (actionData->action == ACTION_COPY && actionData->skippedCount > 0) {
++ wchar_t message[192] = {0};
++ _snwprintf_s(
++ message,
++ sizeof(message) / sizeof(message[0]),
++ _TRUNCATE,
++ L"Skipped %d symbolic link or reparse point item(s). The copy continued with the remaining items.",
++ actionData->skippedCount
++ );
++ MessageBox(hwndDlg, message, L"Copy Summary", MB_OK | MB_ICONINFORMATION);
++ }
+
+ SendMessage(hwndDlg, MSG_CLOSE, 0, 0);
+ return 0;
+@@ -378,6 +805,7 @@ void pasteFiles(wchar_t* dstDir) {
+ actionData->srcPaths = clipboard;
+ actionData->numSrcPaths = clipboardSize;
+ actionData->cancel = false;
++ actionData->skippedCount = 0;
+
+ hwndDlg = CreateDialogParam(globalHInstance, MAKEINTRESOURCE(IDD_FILE_ACTION), hwndMain, &FileActionDialogProc, 0);
+ actionData->conflictPolicy = CONFLICT_POLICY_ASK;
diff --git a/app/src/main/assets/wfm/native-copy.patch b/app/src/main/assets/wfm/native-copy.patch
new file mode 100644
index 0000000000..5e432d30eb
--- /dev/null
+++ b/app/src/main/assets/wfm/native-copy.patch
@@ -0,0 +1,144 @@
+From: GameNative local compatibility build
+Subject: [PATCH] Avoid shell32 for WFM copy operations
+
+Apply to https://github.com/brunodev85/wfm at commit
+cb1d078cef36d3d61eec822882febcde828f09e7.
+
+This intentionally changes only ACTION_COPY. The original WFM UI, directory
+enumeration, move behavior, and delete behavior remain unchanged.
+
+diff --git a/src/file_actions.c b/src/file_actions.c
+index 4961edb..0f03c6e 100644
+--- a/src/file_actions.c
++++ b/src/file_actions.c
+@@ -125,6 +125,104 @@ INT_PTR CALLBACK FileActionDialogProc(HWND hwndDlg, UINT msg, WPARAM wParam, LPA
+ return (INT_PTR)FALSE;
+ }
+
++enum CopyResult {
++ COPY_RESULT_FAILED,
++ COPY_RESULT_OK,
++ COPY_RESULT_CANCELLED
++};
++
++static enum CopyResult copyPathNative(wchar_t* srcPath, wchar_t* dstPath);
++
++static enum CopyResult confirmAndCopyFile(wchar_t* srcPath, wchar_t* dstPath) {
++ if (CopyFile(srcPath, dstPath, TRUE)) return COPY_RESULT_OK;
++
++ DWORD error = GetLastError();
++ if (error != ERROR_FILE_EXISTS && error != ERROR_ALREADY_EXISTS) {
++ return COPY_RESULT_FAILED;
++ }
++
++ wchar_t basename[MAX_PATH] = {0};
++ getBasenameFromPath(dstPath, basename, false);
++
++ wchar_t msg[MAX_PATH + 96] = {0};
++ swprintf_s(
++ msg,
++ MAX_PATH + 96,
++ L"\"%ls\" already exists.\n\nDo you want to replace it?",
++ basename
++ );
++
++ int response = MessageBox(
++ hwndDlg,
++ msg,
++ L"Confirm File Replace",
++ MB_YESNOCANCEL | MB_ICONQUESTION
++ );
++ if (response == IDCANCEL) {
++ actionData->cancel = true;
++ return COPY_RESULT_CANCELLED;
++ }
++ if (response == IDNO) return COPY_RESULT_OK;
++
++ return CopyFile(srcPath, dstPath, FALSE)
++ ? COPY_RESULT_OK
++ : COPY_RESULT_FAILED;
++}
++
++static enum CopyResult copyDirectoryNative(wchar_t* srcPath, wchar_t* dstPath) {
++ if (!CreateDirectory(dstPath, NULL)) {
++ DWORD error = GetLastError();
++ if (error != ERROR_ALREADY_EXISTS) return COPY_RESULT_FAILED;
++
++ DWORD dstAttributes = GetFileAttributes(dstPath);
++ if (dstAttributes == INVALID_FILE_ATTRIBUTES ||
++ !(dstAttributes & FILE_ATTRIBUTE_DIRECTORY)) {
++ return COPY_RESULT_FAILED;
++ }
++ }
++
++ wchar_t pattern[MAX_PATH] = {0};
++ swprintf_s(pattern, MAX_PATH, L"%ls\\*", srcPath);
++
++ WIN32_FIND_DATA wfd = {0};
++ HANDLE handle = FindFirstFile(pattern, &wfd);
++ if (handle == INVALID_HANDLE_VALUE) {
++ return GetLastError() == ERROR_FILE_NOT_FOUND
++ ? COPY_RESULT_OK
++ : COPY_RESULT_FAILED;
++ }
++
++ enum CopyResult result = COPY_RESULT_OK;
++ do {
++ if (wcscmp(wfd.cFileName, L".") == 0 ||
++ wcscmp(wfd.cFileName, L"..") == 0) {
++ continue;
++ }
++
++ wchar_t childSrc[MAX_PATH] = {0};
++ wchar_t childDst[MAX_PATH] = {0};
++ swprintf_s(childSrc, MAX_PATH, L"%ls\\%ls", srcPath, wfd.cFileName);
++ swprintf_s(childDst, MAX_PATH, L"%ls\\%ls", dstPath, wfd.cFileName);
++
++ result = copyPathNative(childSrc, childDst);
++ }
++ while (result == COPY_RESULT_OK &&
++ !actionData->cancel &&
++ FindNextFile(handle, &wfd));
++
++ FindClose(handle);
++ return actionData->cancel ? COPY_RESULT_CANCELLED : result;
++}
++
++static enum CopyResult copyPathNative(wchar_t* srcPath, wchar_t* dstPath) {
++ DWORD attributes = GetFileAttributes(srcPath);
++ if (attributes == INVALID_FILE_ATTRIBUTES) return COPY_RESULT_FAILED;
++
++ return attributes & FILE_ATTRIBUTE_DIRECTORY
++ ? copyDirectoryNative(srcPath, dstPath)
++ : confirmAndCopyFile(srcPath, dstPath);
++}
++
+ static DWORD WINAPI fileActionTask(void* param) {
+ struct ActionData* actionData = (struct ActionData*)param;
+ DWORD lastTime = GetTickCount();
+@@ -142,11 +240,23 @@ static DWORD WINAPI fileActionTask(void* param) {
+ int res = SHFileOperation(&sfo);
+ if (res != 0) break;
+ }
+- else if (actionData->action == ACTION_COPY || actionData->action == ACTION_MOVE) {
++ else if (actionData->action == ACTION_COPY) {
++ wchar_t basename[MAX_PATH] = {0};
++ wchar_t dstPath[MAX_PATH] = {0};
++ getBasenameFromPath(actionData->srcPaths[i], basename, false);
++ swprintf_s(dstPath, MAX_PATH, L"%ls\\%ls", actionData->dstPath, basename);
++
++ // Copying an item onto itself is a no-op.
++ if (wcsicmp(actionData->srcPaths[i], dstPath) == 0) continue;
++
++ enum CopyResult result = copyPathNative(actionData->srcPaths[i], dstPath);
++ if (result != COPY_RESULT_OK) break;
++ }
++ else if (actionData->action == ACTION_MOVE) {
+ SHFILEOPSTRUCT sfo;
+ memset(&sfo, 0, sizeof(sfo));
+ sfo.hwnd = hwndDlg;
+- sfo.wFunc = actionData->action == ACTION_COPY ? FO_COPY : FO_MOVE;
++ sfo.wFunc = FO_MOVE;
+ sfo.fFlags = FOF_SILENT;
+ sfo.pTo = actionData->dstPath;
+ sfo.pFrom = actionData->srcPaths[i];
diff --git a/app/src/main/assets/wfm/replace-all-conflicts.patch b/app/src/main/assets/wfm/replace-all-conflicts.patch
new file mode 100644
index 0000000000..80be99f9ef
--- /dev/null
+++ b/app/src/main/assets/wfm/replace-all-conflicts.patch
@@ -0,0 +1,74 @@
+From: GameNative local compatibility build
+Subject: [PATCH] Offer replace-all policy on first copy conflict
+
+Apply after native-copy.patch.
+
+Offer Replace All, Review Each, or Cancel on the first file conflict and reuse
+that policy for the rest of the paste operation.
+
+diff --git a/src/file_actions.c b/src/file_actions.c
+index 0f03c6e..45a2c60 100644
+--- a/src/file_actions.c
++++ b/src/file_actions.c
+@@ -15,12 +15,19 @@ enum FileAction {
+ ACTION_MOVE
+ };
+
++enum ConflictPolicy {
++ CONFLICT_POLICY_ASK,
++ CONFLICT_POLICY_REPLACE_ALL,
++ CONFLICT_POLICY_REVIEW_EACH
++};
++
+ struct ActionData {
+ enum FileAction action;
+ wchar_t** srcPaths;
+ int numSrcPaths;
+ wchar_t* dstPath;
+ bool cancel;
++ enum ConflictPolicy conflictPolicy;
+ };
+
+ static HWND hwndDlg;
+@@ -141,6 +148,33 @@ static enum CopyResult confirmAndCopyFile(wchar_t* srcPath, wchar_t* dstPath) {
+ return COPY_RESULT_FAILED;
+ }
+
++
++ if (actionData->conflictPolicy == CONFLICT_POLICY_ASK) {
++ int response = MessageBox(
++ hwndDlg,
++ L"Existing files were found.\n\n"
++ L"Yes: Replace all conflicts during this paste\n"
++ L"No: Review each conflict\n"
++ L"Cancel: Stop copying",
++ L"File Conflicts",
++ MB_YESNOCANCEL | MB_ICONQUESTION
++ );
++
++ if (response == IDCANCEL) {
++ actionData->cancel = true;
++ return COPY_RESULT_CANCELLED;
++ }
++
++ actionData->conflictPolicy = response == IDYES
++ ? CONFLICT_POLICY_REPLACE_ALL
++ : CONFLICT_POLICY_REVIEW_EACH;
++ }
++
++ if (actionData->conflictPolicy == CONFLICT_POLICY_REPLACE_ALL) {
++ return CopyFile(srcPath, dstPath, FALSE)
++ ? COPY_RESULT_OK
++ : COPY_RESULT_FAILED;
++ }
+ wchar_t basename[MAX_PATH] = {0};
+ getBasenameFromPath(dstPath, basename, false);
+
+@@ -346,6 +380,7 @@ void pasteFiles(wchar_t* dstDir) {
+ actionData->cancel = false;
+
+ hwndDlg = CreateDialogParam(globalHInstance, MAKEINTRESOURCE(IDD_FILE_ACTION), hwndMain, &FileActionDialogProc, 0);
++ actionData->conflictPolicy = CONFLICT_POLICY_ASK;
+ SetTimer(hwndDlg, ID_EVENT_PRELOADER, PRELOADER_PERIOD, NULL);
+ CreateThread(NULL, 0, fileActionTask, actionData, 0, NULL);
+ ShowWindow(hwndDlg, SW_SHOW);
diff --git a/app/src/main/assets/wfm/wfm.exe b/app/src/main/assets/wfm/wfm.exe
new file mode 100644
index 0000000000..2ff867bfcc
Binary files /dev/null and b/app/src/main/assets/wfm/wfm.exe differ
diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
index c713b9b97f..3292382f85 100644
--- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
+++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt
@@ -131,6 +131,7 @@ import app.gamenative.utils.SteamTokenLogin
import app.gamenative.utils.SteamUtils
import app.gamenative.utils.downloader.WinComponentDownloader
import app.gamenative.utils.WineProcessSnapshotHelper
+import app.gamenative.utils.WfmInstaller
import com.posthog.PostHog
import com.winlator.alsaserver.ALSAClient
import com.winlator.container.Container
@@ -3991,6 +3992,18 @@ private fun getWineStartCommand(
val isEpicGame = gameSource == GameSource.EPIC
val isSteamGame = gameSource == GameSource.STEAM
val gameId = ContainerUtils.extractGameIdFromContainerId(appId)
+ val wfmInstalled = WfmInstaller.install(context, container)
+ if (!wfmInstalled) {
+ Timber.tag("XServerScreen").e("Could not install the Open Container file manager update")
+ }
+
+ fun wfmCommand(command: String): String {
+ if (!wfmInstalled) {
+ SnackbarManager.show(context.getString(R.string.wfm_install_failed))
+ throw IllegalStateException("Open Container file manager installation failed")
+ }
+ return command
+ }
if (isSteamGame) {
// Steam-specific setup
@@ -4013,7 +4026,7 @@ private fun getWineStartCommand(
val args = if (testGraphics) {
"\"Z:/opt/apps/TestD3D.exe\""
} else if (bootToContainer) {
- "\"wfm.exe\""
+ wfmCommand("\"wfm.exe\"")
} else if (isGOGGame) {
// For GOG games, use GOGService to get the launch command
Timber.tag("XServerScreen").i("Launching GOG game: $gameId")
@@ -4303,7 +4316,7 @@ private fun getWineStartCommand(
// Attempt auto-detection only when we have the physical folder path
if (gameFolderPath == null) {
Timber.tag("XServerScreen").e("Could not find A: drive for Custom Game: $appId")
- return "winhandler.exe \"wfm.exe\""
+ return wfmCommand("winhandler.exe \"wfm.exe\"")
}
val auto = CustomGameScanner.findUniqueExeRelativeToFolder(gameFolderPath!!)
if (auto != null) {
@@ -4313,13 +4326,13 @@ private fun getWineStartCommand(
container.saveData()
} else {
Timber.tag("XServerScreen").w("No unique executable found for Custom Game: $appId")
- return "winhandler.exe \"wfm.exe\""
+ return wfmCommand("winhandler.exe \"wfm.exe\"")
}
}
if (gameFolderPath == null) {
Timber.tag("XServerScreen").e("Could not find A: drive for Custom Game: $appId")
- return "winhandler.exe \"wfm.exe\""
+ return wfmCommand("winhandler.exe \"wfm.exe\"")
}
// Set working directory to the game folder
@@ -4333,7 +4346,7 @@ private fun getWineStartCommand(
} else if (container.executablePath.isEmpty()) {
// For Steam games, we need appLaunchInfo
Timber.tag("XServerScreen").w("appLaunchInfo is null for Steam game: $appId")
- "\"wfm.exe\""
+ wfmCommand("\"wfm.exe\"")
} else {
if (container.isLaunchBionicSteam) {
// Bionic-Steam mode: launch the game executable directly.
diff --git a/app/src/main/java/app/gamenative/utils/WfmInstaller.kt b/app/src/main/java/app/gamenative/utils/WfmInstaller.kt
new file mode 100644
index 0000000000..3a0b850cb0
--- /dev/null
+++ b/app/src/main/java/app/gamenative/utils/WfmInstaller.kt
@@ -0,0 +1,107 @@
+package app.gamenative.utils
+
+import android.content.Context
+import com.winlator.container.Container
+import com.winlator.core.FileUtils
+import timber.log.Timber
+import java.io.File
+import java.io.InputStream
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.security.MessageDigest
+
+/**
+ * Installs the minimally patched original file manager used by Open Container.
+ *
+ * Only WFM's copy operation bypasses Wine's crashing SHFileOperation implementation. Keep this
+ * payload separate from the container pattern so app updates can repair existing containers
+ * without rebuilding ImageFS or deleting user data.
+ */
+object WfmInstaller {
+ private const val ASSET_DIRECTORY = "wfm"
+ private val payloads = listOf(
+ Payload(
+ filename = "wfm.exe",
+ sha256 = "a7bb48aa14c59ece23c011c43c8439869267b13387d4a972f462a06575deebbb",
+ ),
+ )
+
+ fun install(context: Context, container: Container): Boolean {
+ val windowsDirectory = File(container.rootDir, ".wine/drive_c/windows")
+ if (!windowsDirectory.isDirectory && !windowsDirectory.mkdirs()) {
+ Timber.e("Could not create WFM destination: %s", windowsDirectory)
+ return false
+ }
+
+ return payloads.all { payload ->
+ installPayload(context, windowsDirectory, payload)
+ }
+ }
+
+ private fun installPayload(
+ context: Context,
+ destinationDirectory: File,
+ payload: Payload,
+ ): Boolean {
+ val destination = File(destinationDirectory, payload.filename)
+ if (destination.isFile &&
+ runCatching { sha256(destination) }.getOrNull() == payload.sha256
+ ) {
+ return true
+ }
+
+ val temporary = File(destinationDirectory, ".${payload.filename}.installing")
+ return try {
+ Files.deleteIfExists(temporary.toPath())
+ context.assets.open("$ASSET_DIRECTORY/${payload.filename}").use { input ->
+ Files.copy(input, temporary.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+
+ check(sha256(temporary) == payload.sha256) {
+ "Bundled ${payload.filename} failed integrity verification"
+ }
+
+ try {
+ Files.move(
+ temporary.toPath(),
+ destination.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ } catch (_: AtomicMoveNotSupportedException) {
+ Files.move(
+ temporary.toPath(),
+ destination.toPath(),
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ }
+ FileUtils.chmod(destination, 493)
+ Timber.i("Installed minimally patched %s", payload.filename)
+ true
+ } catch (error: Exception) {
+ Timber.e(error, "Failed to install %s", payload.filename)
+ runCatching { Files.deleteIfExists(temporary.toPath()) }
+ false
+ }
+ }
+
+ private fun sha256(file: File): String =
+ file.inputStream().buffered().use(::sha256)
+
+ private fun sha256(input: InputStream): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ while (true) {
+ val count = input.read(buffer)
+ if (count < 0) break
+ digest.update(buffer, 0, count)
+ }
+ return digest.digest().joinToString(separator = "") { byte -> "%02x".format(byte) }
+ }
+
+ private data class Payload(
+ val filename: String,
+ val sha256: String,
+ )
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index defb0230ff..46641232a7 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2175,4 +2175,5 @@
Copied %1$s of %2$s
This managed mod has incomplete source information and cannot be retried.
Archive needs more memory than Android allows. Retry after updating GameNative or choose a smaller file.
+ Could not update the container file manager. Open Container was not started.