diff --git a/app/src/legacy/assets/container_pattern_common.tzst b/app/src/legacy/assets/container_pattern_common.tzst index 1acb14fe34..e4acd44a19 100644 Binary files a/app/src/legacy/assets/container_pattern_common.tzst and b/app/src/legacy/assets/container_pattern_common.tzst differ diff --git a/app/src/main/assets/container_files_download.json b/app/src/main/assets/container_files_download.json index c3945eca5b..6e6a2b116e 100644 --- a/app/src/main/assets/container_files_download.json +++ b/app/src/main/assets/container_files_download.json @@ -1,6 +1,6 @@ { - "version": 1, - "updatedAt": "2026-06-03", + "version": 2, + "updatedAt": "2026-08-12", "components": [ { "id": "extras", @@ -10,7 +10,9 @@ { "id": "container_pattern_common", "name": "container_pattern_common.tzst", - "url": "https://downloads.gamenative.app/container_files/container_pattern_common.tzst" + "url": "https://downloads.gamenative.app/container_files/container_pattern_common.tzst", + "version": 2, + "sha256": "c62311ac7a10a149f33cd1b2fcdf9f79c8a6b35d8b46066b33108c003b7e85c6" }, { "id": "container_pattern_gamenative", 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..3fdb4c67f5 --- /dev/null +++ b/app/src/main/assets/wfm/NOTICE.txt @@ -0,0 +1,15 @@ +Winlator File Manager +===================== + +Source: https://github.com/GameNative/wfm +Revision: 5c4ca97292067c522cf1ba79ce7ce832beb15a1d +Branch: gamenative/native-copy-paste + +The branch is based on upstream revision +cb1d078cef36d3d61eec822882febcde828f09e7 to retain the WFM interface and +behavior previously distributed by GameNative. Its copy implementation avoids +Wine shell-operation crashes between mapped drives and adds a replace-all +conflict choice plus filesystem edge-case handling. + +Winlator File Manager is licensed under the MIT License. The license is +included in LICENSE.WFM.txt. 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 e9c423e026..b6c38826f3 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 @@ -5155,6 +5155,10 @@ private suspend fun applyGeneralPatches( onExtractFileListener, ); } + Timber.i("Extracting WFM from container_pattern_common.tzst") + check(containerManager.extractContainerPatternCommonWfm(rootDir, onExtractFileListener)) { + "Failed to extract WFM from container_pattern_common.tzst" + } } else { Timber.i("Extracting container_pattern_common.tzst") containerManager.extractContainerPatternCommon(rootDir, onExtractFileListener) diff --git a/app/src/main/java/app/gamenative/utils/downloader/ContainerFilesDownloader.kt b/app/src/main/java/app/gamenative/utils/downloader/ContainerFilesDownloader.kt index 216c31e8e2..6766988f8d 100644 --- a/app/src/main/java/app/gamenative/utils/downloader/ContainerFilesDownloader.kt +++ b/app/src/main/java/app/gamenative/utils/downloader/ContainerFilesDownloader.kt @@ -10,6 +10,8 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import timber.log.Timber import java.io.File +import java.io.IOException +import java.security.MessageDigest /** * Utility for downloading container pattern files (extras, container patterns, proton patterns) from the manifest server. @@ -19,6 +21,7 @@ object ContainerFilesDownloader { const val CONTAINER_FILES_MANIFEST_FILE = "container_files_download.json" const val CONTAINER_FILES_CACHE_DIR = "assets/container_files" + const val LEGACY_CACHE_VERSION = 1 private val json = Json { ignoreUnknownKeys = true } @@ -33,9 +36,54 @@ object ContainerFilesDownloader { data class ContainerFileComponent( val id: String, val name: String, - val url: String + val url: String, + val version: Int = LEGACY_CACHE_VERSION, + val sha256: String? = null ) + internal fun getCachedComponentVersion(versionFile: File): Int? { + if (!versionFile.exists()) return LEGACY_CACHE_VERSION + return runCatching { versionFile.readText().trim().toInt() }.getOrNull() + } + + internal fun isCachedComponentCurrent( + destination: File, + versionFile: File, + expectedVersion: Int, + expectedSha256: String? = null, + ): Boolean { + val versionIsCurrent = expectedVersion > 0 && + destination.isFile && + destination.length() > 0 && + getCachedComponentVersion(versionFile) == expectedVersion + if (!versionIsCurrent || expectedSha256 == null) return versionIsCurrent + + return runCatching { hasExpectedSha256(destination, expectedSha256) } + .onFailure { error -> + Timber.w(error, "Failed to verify cached container file ${destination.name}") + } + .getOrDefault(false) + } + + private fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().use { stream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val count = stream.read(buffer) + if (count < 0) break + if (count > 0) digest.update(buffer, 0, count) + } + } + return digest.digest().joinToString("") { + (it.toInt() and 0xff).toString(16).padStart(2, '0') + } + } + + internal fun hasExpectedSha256(file: File, expectedHash: String): Boolean { + return sha256(file).equals(expectedHash, ignoreCase = true) + } + /** * Ensures a container file component is available, either from cache, server download, or bundled assets. * @@ -53,6 +101,9 @@ object ContainerFilesDownloader { val manifest = loadContainerFilesManifest(context) val component = manifest.components.find { it.id == componentId } ?: throw Exception("Container file $componentId not found in $CONTAINER_FILES_MANIFEST_FILE") + require(component.version > 0) { + "Container file $componentId has an invalid cache version" + } // Legacy variant: use bundled assets if (!BuildConfig.MODERN_ANDROID) { @@ -62,16 +113,25 @@ object ContainerFilesDownloader { // Modern variant: download from server // Check if already downloaded and cached - val destFile = File(context.filesDir, "$CONTAINER_FILES_CACHE_DIR/$componentId.tzst") - if (destFile.exists() && destFile.length() > 0) { + val cacheDir = File(context.filesDir, CONTAINER_FILES_CACHE_DIR) + val destFile = File(cacheDir, "$componentId.tzst") + val versionFile = File(cacheDir, "$componentId.version") + if (isCachedComponentCurrent(destFile, versionFile, component.version, component.sha256)) { Timber.d("Using cached container file: $componentId at ${destFile.absolutePath}") return@withContext destFile } + if (destFile.exists()) { + Timber.i( + "Cached container file $componentId is outdated; " + + "downloading version ${component.version}", + ) + } + // Download from server using local manifest Timber.i("Downloading container file: $componentId from server") - destFile.parentFile?.mkdirs() + cacheDir.mkdirs() try { SteamService.fetchFileWithFallback( @@ -80,6 +140,20 @@ object ContainerFilesDownloader { context = context, onProgress = onProgress ) + + component.sha256?.let { expectedHash -> + if (!hasExpectedSha256(destFile, expectedHash)) { + destFile.delete() + throw IOException( + "Downloaded container file $componentId failed its integrity check", + ) + } + } + + runCatching { versionFile.writeText(component.version.toString()) } + .onFailure { error -> + Timber.w(error, "Failed to record cache version for $componentId") + } Timber.i("Successfully downloaded container file: $componentId") } catch (e: Exception) { Timber.e(e, "Failed to download container file: $componentId") diff --git a/app/src/main/java/com/winlator/container/ContainerManager.java b/app/src/main/java/com/winlator/container/ContainerManager.java index 13b45c0dd7..7df3e2f23c 100644 --- a/app/src/main/java/com/winlator/container/ContainerManager.java +++ b/app/src/main/java/com/winlator/container/ContainerManager.java @@ -318,6 +318,33 @@ private void extractCommonDlls(WineInfo wineInfo, String srcName, String dstName } public boolean extractContainerPatternCommon(File containerDir, OnExtractFileListener onExtractFileListener) { + return extractContainerPatternCommonArchive(containerDir, onExtractFileListener); + } + + public boolean extractContainerPatternCommonWfm(File containerDir, OnExtractFileListener onExtractFileListener) { + File expectedWfm = new File( + containerDir, + "home/xuser/.wine/drive_c/windows/wfm.exe" + ).getAbsoluteFile(); + File parentDir = expectedWfm.getParentFile(); + if (parentDir == null || + (!parentDir.isDirectory() && !parentDir.mkdirs() && !parentDir.isDirectory())) { + Log.e("Extraction", "Failed to create WFM destination directory"); + return false; + } + final File[] selectedWfm = {null}; + boolean extracted = extractContainerPatternCommonArchive(containerDir, (file, size) -> { + if (!file.getAbsoluteFile().equals(expectedWfm)) return null; + selectedWfm[0] = onExtractFileListener != null + ? onExtractFileListener.onExtractFile(file, size) + : file; + return selectedWfm[0]; + }); + return extracted && selectedWfm[0] != null && + selectedWfm[0].isFile() && selectedWfm[0].length() > 0; + } + + private boolean extractContainerPatternCommonArchive(File containerDir, OnExtractFileListener onExtractFileListener) { Log.d("Extraction", "extracting container_pattern_common.tzst"); File componentFile = ContainerFilesDownloaderKt.ensureContainerFileAvailableBlocking(context, "container_pattern_common", new ProgressCallback() { @Override diff --git a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java index 0b743b9998..996d769263 100644 --- a/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java +++ b/app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java @@ -43,7 +43,7 @@ import java.util.concurrent.atomic.AtomicLong; public abstract class ImageFsInstaller { - public static final byte LATEST_VERSION = 30; + public static final byte LATEST_VERSION = 31; private static void resetContainerImgVersions(Context context) { ContainerManager manager = new ContainerManager(context); diff --git a/app/src/test/java/app/gamenative/utils/downloader/ContainerFilesDownloaderTest.kt b/app/src/test/java/app/gamenative/utils/downloader/ContainerFilesDownloaderTest.kt index d8cc6ef115..18e5c3ad2b 100644 --- a/app/src/test/java/app/gamenative/utils/downloader/ContainerFilesDownloaderTest.kt +++ b/app/src/test/java/app/gamenative/utils/downloader/ContainerFilesDownloaderTest.kt @@ -2,11 +2,16 @@ package app.gamenative.utils.downloader import android.content.Context import androidx.test.core.app.ApplicationProvider +import app.gamenative.BuildConfig import app.gamenative.PrefManager +import com.winlator.container.ContainerManager +import com.winlator.core.OnExtractFileListener import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import org.junit.After +import org.junit.Assume.assumeFalse import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -82,6 +87,131 @@ class ContainerFilesDownloaderTest { val component = manifest.components.find { it.id == "container_pattern_common" } assertNotNull("container_pattern_common should exist in manifest", component) assertEquals("Name should match", "container_pattern_common.tzst", component!!.name) + assertEquals("Common pattern cache version should be bumped", 2, component.version) + assertEquals( + "Common pattern hash should match the updated archive", + "c62311ac7a10a149f33cd1b2fcdf9f79c8a6b35d8b46066b33108c003b7e85c6", + component.sha256, + ) + } + + @Test + fun testUnversionedManifestComponentsUseLegacyCacheVersion() { + val manifestJson = context.assets.open(ContainerFilesDownloader.CONTAINER_FILES_MANIFEST_FILE).bufferedReader().use { it.readText() } + val manifest = Json { ignoreUnknownKeys = true } + .decodeFromString(manifestJson) + + val extras = manifest.components.first { it.id == "extras" } + assertEquals(ContainerFilesDownloader.LEGACY_CACHE_VERSION, extras.version) + assertEquals(null, extras.sha256) + } + + @Test + fun testVersionedCacheInvalidatesOnlyOutdatedComponent() { + cacheDir.mkdirs() + val cachedFile = File(cacheDir, "container_pattern_common.tzst") + val versionFile = File(cacheDir, "container_pattern_common.version") + cachedFile.writeText("cached archive") + + assertTrue( + "An existing unmarked cache should remain compatible with version 1", + ContainerFilesDownloader.isCachedComponentCurrent( + cachedFile, + versionFile, + ContainerFilesDownloader.LEGACY_CACHE_VERSION, + ), + ) + assertFalse( + "An existing unmarked cache should be invalidated by version 2", + ContainerFilesDownloader.isCachedComponentCurrent(cachedFile, versionFile, 2), + ) + + versionFile.writeText("2") + assertTrue( + "A matching version marker should reuse the cache", + ContainerFilesDownloader.isCachedComponentCurrent(cachedFile, versionFile, 2), + ) + + versionFile.writeText("invalid") + assertFalse( + "A malformed version marker should invalidate the cache", + ContainerFilesDownloader.isCachedComponentCurrent(cachedFile, versionFile, 2), + ) + } + + @Test + fun testDownloadedComponentHashValidation() { + cacheDir.mkdirs() + val cachedFile = File(cacheDir, "hash-test.tzst") + cachedFile.writeText("abc") + + assertTrue( + ContainerFilesDownloader.hasExpectedSha256( + cachedFile, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ) + assertFalse(ContainerFilesDownloader.hasExpectedSha256(cachedFile, "0".repeat(64))) + } + + @Test + fun testCurrentVersionCacheRequiresExpectedHash() { + cacheDir.mkdirs() + val cachedFile = File(cacheDir, "hash-test.tzst") + val versionFile = File(cacheDir, "hash-test.version") + cachedFile.writeText("abc") + versionFile.writeText("2") + + assertTrue( + ContainerFilesDownloader.isCachedComponentCurrent( + cachedFile, + versionFile, + 2, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + ), + ) + assertFalse( + ContainerFilesDownloader.isCachedComponentCurrent( + cachedFile, + versionFile, + 2, + "0".repeat(64), + ), + ) + } + + @Test + fun testWfmOnlyExtractionCreatesMissingParentDirectories() { + assumeFalse("Bundled archive is only available in the legacy variant", BuildConfig.MODERN_ANDROID) + + val destination = File(context.cacheDir, "wfm-only-extraction") + destination.deleteRecursively() + try { + assertTrue(ContainerManager(context).extractContainerPatternCommonWfm(destination, null)) + val wfm = File(destination, "home/xuser/.wine/drive_c/windows/wfm.exe") + assertTrue("WFM should be extracted into a newly created prefix", wfm.isFile) + assertTrue("Extracted WFM should not be empty", wfm.length() > 0) + } finally { + destination.deleteRecursively() + } + } + + @Test + fun testWfmOnlyExtractionFailsWhenWfmIsNotSelected() { + assumeFalse("Bundled archive is only available in the legacy variant", BuildConfig.MODERN_ANDROID) + + val destination = File(context.cacheDir, "wfm-rejected-extraction") + destination.deleteRecursively() + try { + val rejectWfm = OnExtractFileListener { _, _ -> null } + assertFalse( + ContainerManager(context).extractContainerPatternCommonWfm(destination, rejectWfm), + ) + val wfm = File(destination, "home/xuser/.wine/drive_c/windows/wfm.exe") + assertFalse("Rejected WFM should not be reported as extracted", wfm.exists()) + } finally { + destination.deleteRecursively() + } } @Test @@ -205,13 +335,16 @@ class ContainerFilesDownloaderTest { } @Test - fun testCachedComponentReuse() = runBlocking { - val componentId = "container_pattern_common" + fun testUnhashedCachedComponentReuse() = runBlocking { + val componentId = "extras" // Create a mock cached file cacheDir.mkdirs() val cachedFile = File(cacheDir, "$componentId.tzst") cachedFile.writeText("mock cached content") + File(cacheDir, "$componentId.version").writeText( + ContainerFilesDownloader.LEGACY_CACHE_VERSION.toString(), + ) val retrievedFile = ContainerFilesDownloader.ensureContainerFileAvailable( context,