Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified app/src/legacy/assets/container_pattern_common.tzst
Binary file not shown.
8 changes: 5 additions & 3 deletions app/src/main/assets/container_files_download.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": 1,
"updatedAt": "2026-06-03",
"version": 2,
"updatedAt": "2026-08-12",
"components": [
{
"id": "extras",
Expand All @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions app/src/main/assets/wfm/LICENSE.WFM.txt
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions app/src/main/assets/wfm/NOTICE.txt
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -5155,6 +5155,10 @@ private suspend fun applyGeneralPatches(
onExtractFileListener,
);
}
Timber.i("Extracting WFM from container_pattern_common.tzst")
check(containerManager.extractContainerPatternCommonWfm(rootDir, onExtractFileListener)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the archive lacks the expected WFM entry, TarCompressorUtils.extract still returns true after the listener skips that entry, so this check treats an incomplete extraction as successful. Make extractContainerPatternCommonWfm verify that expectedWfm.isFile() exists after extraction, and add coverage for an archive without a selectable WFM entry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt, line 5159:

<comment>When the archive lacks the expected WFM entry, `TarCompressorUtils.extract` still returns `true` after the listener skips that entry, so this check treats an incomplete extraction as successful. Make `extractContainerPatternCommonWfm` verify that `expectedWfm.isFile()` exists after extraction, and add coverage for an archive without a selectable WFM entry.</comment>

<file context>
@@ -5156,7 +5156,9 @@ private suspend fun applyGeneralPatches(
         }
         Timber.i("Extracting WFM from container_pattern_common.tzst")
-        containerManager.extractContainerPatternCommonWfm(rootDir, onExtractFileListener)
+        check(containerManager.extractContainerPatternCommonWfm(rootDir, onExtractFileListener)) {
+            "Failed to extract WFM from container_pattern_common.tzst"
+        }
</file context>

"Failed to extract WFM from container_pattern_common.tzst"
}
} else {
Timber.i("Extracting container_pattern_common.tzst")
containerManager.extractContainerPatternCommon(rootDir, onExtractFileListener)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 }

Expand All @@ -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.
*
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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")
Expand Down
27 changes: 27 additions & 0 deletions app/src/main/java/com/winlator/container/ContainerManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading