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.