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
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.winlator.xenvironment;

import android.content.Context;
import android.util.Log;

import com.winlator.core.FileUtils;

import java.io.File;

public final class ImageFSLegacyMigrator {
private ImageFSLegacyMigrator() {}

public static boolean migrateLegacyDirsIfNeeded(Context context, File legacyImageFsRoot) {
if (!migrateLegacyHomeToShared(context, legacyImageFsRoot)) {
return false;
}
return true;
}

/**
* Before deleting legacy files/imagefs, preserve /home contents by moving them into
* files/imagefs_shared/home so first-run sync can reuse xuser/.wine safely.
*/
private static boolean migrateLegacyHomeToShared(Context context, File legacyImageFsRoot) {
File legacyHome = new File(legacyImageFsRoot, "home");
File sharedHomeRoot = new File(ImageFs.getImageFsSharedDir(context), "home");

if (FileUtils.isSymlink(legacyHome)) {
// Already migrated: /imagefs/home is a symlink to imagefs_shared/home.
return true;
}

if (!legacyHome.exists() || !legacyHome.isDirectory()) {
// No need to migrate.
return true;
}

if (sharedHomeRoot.exists()) {
Log.w("ImageFSLegacyMigrator", "Shared home already exists; overwriting with legacy home migration.");
FileUtils.delete(sharedHomeRoot);
}

if (!legacyHome.renameTo(sharedHomeRoot)) {
Log.w("ImageFSLegacyMigrator", "Direct move failed for legacy home; falling back to copy+delete.");
boolean copied = FileUtils.copy(legacyHome, sharedHomeRoot);
if (copied) {
FileUtils.delete(legacyHome);
Log.i("ImageFSLegacyMigrator", "Migrated legacy home via copy+delete to: " + sharedHomeRoot.getAbsolutePath());
return true;
} else {
Log.w("ImageFSLegacyMigrator", "Failed to migrate legacy home directory: " + legacyHome.getAbsolutePath());
return false;
}
} else {
Log.i("ImageFSLegacyMigrator", "Migrated legacy home via direct move to: " + sharedHomeRoot.getAbsolutePath());
return true;
}
}
}
8 changes: 8 additions & 0 deletions app/src/main/java/com/winlator/xenvironment/ImageFs.java
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,12 @@ public File getFilesDir() {
public String toString() {
return rootDir.getPath();
}

public static File getImageFsSharedDir(Context context) {
File sharedDir = new File(context.getFilesDir(), "imagefs_shared");
if (!sharedDir.exists()) {
sharedDir.mkdirs();
}
return sharedDir;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ private static Future<Boolean> installFromAssetsFuture(final Context context, As
// dialog.show(R.string.installing_system_files);
return Executors.newSingleThreadExecutor().submit(() -> {
clearRootDir(context, rootDir);
ensureSharedHomeRoot(context, rootDir);

final byte compressionRatio = 22;
String imagefsFile = containerVariant.equals(Container.GLIBC) ? "imagefs_gamenative.txz" : "imagefs_bionic.txz";
File downloaded = new File(imageFs.getFilesDir(), imagefsFile);
Expand Down Expand Up @@ -202,12 +204,19 @@ public static Future<Boolean> installIfNeededFuture(final Context context, Asset
}
public static Future<Boolean> installIfNeededFuture(final Context context, AssetManager assetManager, Container container, Callback<Integer> onProgress) {
ImageFs imageFs = ImageFs.find(context);
if (!ImageFSLegacyMigrator.migrateLegacyDirsIfNeeded(context, imageFs.getRootDir())) {
Log.w("ImageFsInstaller", "Failed to migrate legacy directories before installation.");
return Executors.newSingleThreadExecutor().submit(() -> false);
}
if (!imageFs.isValid() || imageFs.getVersion() < LATEST_VERSION || !imageFs.getVariant().equals(container.getContainerVariant())) {
Log.d("ImageFsInstaller", "Installing image from assets");
return installFromAssetsFuture(context, assetManager, container.getContainerVariant(), onProgress);
} else {
Log.d("ImageFsInstaller", "Image FS already valid and at latest version");
return Executors.newSingleThreadExecutor().submit(() -> true);
return Executors.newSingleThreadExecutor().submit(() -> {
ensureSharedHomeRoot(context, imageFs.getRootDir());
return true;
});
}
}

Expand Down Expand Up @@ -378,4 +387,26 @@ private static void clearSteamDllMarkers(Context context, ContainerManager conta
Log.e("ImageFsInstaller", "Error clearing Steam DLL markers: " + e.getMessage());
}
}

/**
* Ensures that:
* - A shared home backing directory exists at imagefs_shared/home (containing xuser, etc.)
* - The given imagefs rootDir exposes /home as a symlink to that shared root.
*
* This allows the same user home (e.g. .wine, .cache) to be shared across variants.
*/
private static void ensureSharedHomeRoot(Context context, File rootDir) {
File sharedHomeRoot = new File(ImageFs.getImageFsSharedDir(context), "home");
if (!sharedHomeRoot.exists()) {
sharedHomeRoot.mkdirs();
}

File homePathInImageFs = new File(rootDir, "home");
if (FileUtils.isSymlink(homePathInImageFs)) {
// Already symlinked: /imagefs/home is a symlink to imagefs_shared/home.
return;
}

FileUtils.symlink(sharedHomeRoot.getPath(), homePathInImageFs.getPath());
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.winlator.xenvironment

import androidx.test.core.app.ApplicationProvider
import java.io.File
import java.nio.file.Files
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class ImageFSLegacyMigratorTest {
private lateinit var filesDir: File
private lateinit var legacyImageFsRoot: File
private lateinit var sharedDir: File

@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
filesDir = context.filesDir
legacyImageFsRoot = File(filesDir, "legacy-imagefs-test-${System.nanoTime()}").apply { mkdirs() }
sharedDir = File(filesDir, "imagefs_shared").apply { deleteRecursively() }
}

@After
fun tearDown() {
legacyImageFsRoot.deleteRecursively()
sharedDir.deleteRecursively()
}

@Test
fun migrateLegacyDirsIfNeeded_returnsTrueWhenLegacyHomeMissing() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()

val migrated = ImageFSLegacyMigrator.migrateLegacyDirsIfNeeded(context, legacyImageFsRoot)

assertTrue(migrated)
assertFalse(File(legacyImageFsRoot, "home").exists())
}

@Test
fun migrateLegacyDirsIfNeeded_movesLegacyHomeToSharedHome() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val legacyHome = File(legacyImageFsRoot, "home").apply { mkdirs() }
val legacyFile = File(legacyHome, "marker.txt").apply { writeText("legacy-content") }

val migrated = ImageFSLegacyMigrator.migrateLegacyDirsIfNeeded(context, legacyImageFsRoot)

assertTrue(migrated)
assertFalse("Legacy home should have been moved away", legacyHome.exists())
val sharedHome = File(sharedDir, "home")
assertTrue(sharedHome.exists())
assertEquals("legacy-content", File(sharedHome, legacyFile.name).readText())
}

@Test
fun migrateLegacyDirsIfNeeded_overwritesExistingSharedHomeWithLegacyHome() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val sharedHome = File(sharedDir, "home").apply { mkdirs() }
File(sharedHome, "old.txt").writeText("old-shared")

val legacyHome = File(legacyImageFsRoot, "home").apply { mkdirs() }
File(legacyHome, "new.txt").writeText("new-legacy")

val migrated = ImageFSLegacyMigrator.migrateLegacyDirsIfNeeded(context, legacyImageFsRoot)

assertTrue(migrated)
assertFalse(File(sharedHome, "old.txt").exists())
assertEquals("new-legacy", File(sharedHome, "new.txt").readText())
}

@Test
fun migrateLegacyDirsIfNeeded_noopWhenLegacyHomeIsSymlink() {
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
val realHome = File(legacyImageFsRoot, "real-home").apply { mkdirs() }
val symlinkPath = File(legacyImageFsRoot, "home").toPath()
Files.createSymbolicLink(symlinkPath, realHome.toPath())

val migrated = ImageFSLegacyMigrator.migrateLegacyDirsIfNeeded(context, legacyImageFsRoot)

assertTrue(migrated)
assertTrue("Symlink should remain untouched", Files.isSymbolicLink(symlinkPath))
}
}
120 changes: 120 additions & 0 deletions app/src/test/java/com/winlator/xenvironment/ImageFsInstallerTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package com.winlator.xenvironment

import androidx.test.core.app.ApplicationProvider
import com.winlator.core.FileUtils
import java.io.File
import java.lang.reflect.Method
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import io.mockk.verify
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class ImageFsInstallerTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private val filesDir = context.filesDir
private val sharedDir = File(filesDir, "imagefs_shared")

@After
fun tearDown() {
sharedDir.deleteRecursively()
}

@Test
fun ensureSharedHomeRoot_callsSymlinkWhenHomeIsNotSymlink() {
val rootDir = File(filesDir, "imagefs-root-${System.nanoTime()}").apply { mkdirs() }
val sharedHome = File(sharedDir, "home")
val imageFsHome = File(rootDir, "home")
val expectedTarget = sharedHome.path
val expectedLink = imageFsHome.path

mockkStatic(FileUtils::class)
try {
every { FileUtils.isSymlink(any()) } returns false
every { FileUtils.symlink(any<String>(), any<String>()) } returns Unit

invokeEnsureSharedHomeRoot(context, rootDir)

assertTrue("Shared home should be created", sharedHome.exists())
verify(exactly = 1) { FileUtils.symlink(expectedTarget, expectedLink) }
} finally {
unmockkStatic(FileUtils::class)
}

rootDir.deleteRecursively()
}

@Test
fun ensureSharedHomeRoot_doesNotCallSymlinkWhenHomeAlreadySymlink() {
val rootDir = File(filesDir, "imagefs-root-symlink-${System.nanoTime()}").apply { mkdirs() }
val sharedHome = File(sharedDir, "home")
val imageFsHome = File(rootDir, "home")

mockkStatic(FileUtils::class)
try {
every { FileUtils.isSymlink(imageFsHome) } returns true
every { FileUtils.symlink(any<String>(), any<String>()) } returns Unit

invokeEnsureSharedHomeRoot(context, rootDir)

assertTrue("Shared home should still be created", sharedHome.exists())
verify(exactly = 0) { FileUtils.symlink(any<String>(), any<String>()) }
} finally {
unmockkStatic(FileUtils::class)
}

rootDir.deleteRecursively()
}

@Test
fun ensureSharedHomeRoot_alwaysCreatesSharedHomeDirectory() {
val rootDir = File(filesDir, "imagefs-root-shared-home-${System.nanoTime()}").apply { mkdirs() }
val sharedHome = File(sharedDir, "home")

invokeEnsureSharedHomeRoot(context, rootDir)

assertTrue("Shared home should always be created", sharedHome.exists())
assertTrue("Shared home should be a directory", sharedHome.isDirectory)

rootDir.deleteRecursively()
}

@Test
fun ensureSharedHomeRoot_usesExpectedLinkAndTargetPaths() {
val rootDir = File(filesDir, "imagefs-root-paths-${System.nanoTime()}").apply { mkdirs() }
val sharedHome = File(sharedDir, "home")
val imageFsHome = File(rootDir, "home")

mockkStatic(FileUtils::class)
try {
every { FileUtils.isSymlink(any()) } returns false
every { FileUtils.symlink(any<String>(), any<String>()) } returns Unit

invokeEnsureSharedHomeRoot(context, rootDir)

verify(exactly = 1) { FileUtils.symlink(sharedHome.path, imageFsHome.path) }
assertEquals(sharedHome.path, File(sharedDir, "home").path)
} finally {
unmockkStatic(FileUtils::class)
}

rootDir.deleteRecursively()
}

private fun invokeEnsureSharedHomeRoot(context: android.content.Context, rootDir: File) {
val method: Method = ImageFsInstaller::class.java.getDeclaredMethod(
"ensureSharedHomeRoot",
android.content.Context::class.java,
File::class.java,
)
method.isAccessible = true
method.invoke(null, context, rootDir)
}

}
31 changes: 31 additions & 0 deletions app/src/test/java/com/winlator/xenvironment/ImageFsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.winlator.xenvironment

import androidx.test.core.app.ApplicationProvider
import java.io.File
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class ImageFsTest {
private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
private val sharedDir = File(context.filesDir, "imagefs_shared")

@After
fun tearDown() {
sharedDir.deleteRecursively()
}

@Test
fun getImageFsSharedDir_createsAndReturnsSharedDirectory() {
val actual = ImageFs.getImageFsSharedDir(context)
val expected = File(context.filesDir, "imagefs_shared")

assertTrue("Shared dir should exist after call", actual.exists())
assertTrue("Shared dir should be a directory", actual.isDirectory)
assertEquals(expected.absolutePath, actual.absolutePath)
}
}
Loading