From 9d8e1f8bfff197f90e8aff13962b4211a9f04694 Mon Sep 17 00:00:00 2001 From: Alexey Lysenko Date: Thu, 6 Aug 2026 20:06:34 +0300 Subject: [PATCH 1/5] Detailed Progress for containers start --- .../main/java/app/gamenative/ui/PluviaMain.kt | 1 + .../java/app/gamenative/ui/data/MainState.kt | 1 + .../app/gamenative/ui/model/MainViewModel.kt | 4 + .../ui/screen/xserver/XAudioUtils.kt | 5 +- .../ui/screen/xserver/XServerScreen.kt | 39 +-- .../java/app/gamenative/utils/BootProgress.kt | 234 ++++++++++++++++++ .../com/winlator/core/TarCompressorUtils.java | 78 +++++- 7 files changed, 338 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/app/gamenative/utils/BootProgress.kt diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index cf7b27d8f3..d497a24478 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1547,6 +1547,7 @@ fun PluviaMain( BootingSplash( visible = state.showBootingSplash, text = state.bootingSplashText, + progress = state.bootingSplashProgress, heroImageUrl = state.bootingSplashHeroImageUrl, bootAd = state.bootAd, ) diff --git a/app/src/main/java/app/gamenative/ui/data/MainState.kt b/app/src/main/java/app/gamenative/ui/data/MainState.kt index d2c8076b2b..22bf335792 100644 --- a/app/src/main/java/app/gamenative/ui/data/MainState.kt +++ b/app/src/main/java/app/gamenative/ui/data/MainState.kt @@ -25,6 +25,7 @@ data class MainState( val debugRun: Boolean = false, val showBootingSplash: Boolean = false, val bootingSplashText: String = "Booting...", + val bootingSplashProgress: Float = -1f, val bootingSplashHeroImageUrl: String = "", val bootAd: BootAdItem? = null, diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index 069230ce15..646c7f5f6d 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -34,6 +34,7 @@ import app.gamenative.ui.data.MainState import app.gamenative.ui.enums.ConnectionState import app.gamenative.ui.screen.PluviaScreen import app.gamenative.ui.util.SnackbarManager +import app.gamenative.utils.BootProgress import app.gamenative.utils.ContainerUtils import app.gamenative.utils.DebugReportUtils import app.gamenative.utils.IntentLaunchManager @@ -259,6 +260,7 @@ class MainViewModel @Inject constructor( private val onSetBootingSplashText: (AndroidEvent.SetBootingSplashText) -> Unit = { setBootingSplashText(it.text) + _state.update { state -> state.copy(bootingSplashProgress = it.progress) } setShowBootingSplash(true) } @@ -369,6 +371,8 @@ class MainViewModel @Inject constructor( } fun setShowBootingSplash(value: Boolean) { + // Single choke point for every dismissal path, so boot reporting can never outlive the splash. + if (!value) BootProgress.stop() val wasShowing = _state.value.showBootingSplash if (value && !wasShowing) { // The splash hides and re-shows between boot phases; a quick re-show is the same diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt index 15c4a1f4bd..754deb73e8 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt @@ -1,13 +1,12 @@ package app.gamenative.ui.screen.xserver import android.content.Context -import app.gamenative.PluviaApp import app.gamenative.data.GameSource -import app.gamenative.events.AndroidEvent import app.gamenative.service.SteamService import app.gamenative.service.amazon.AmazonService import app.gamenative.service.epic.EpicService import app.gamenative.service.gog.GOGService +import app.gamenative.utils.BootProgress import app.gamenative.utils.ContainerUtils import app.gamenative.utils.CustomGameScanner import app.gamenative.utils.FileUtils @@ -134,7 +133,7 @@ object XAudioUtils { return } - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Extracting XAudio DLLs...")) + BootProgress.detail("extracting XAudio DLLs") val batFile = File(tempDir, "extract_dx_audio_dlls.bat") val batContent = buildCabarcBatchScript( 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 da27863c23..e4038d44b2 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 @@ -119,6 +119,7 @@ import app.gamenative.ui.component.QuickMenu import app.gamenative.ui.component.QuickMenuAction import app.gamenative.ui.component.SteamInviteState import app.gamenative.ui.component.parseBooleanExtra +import app.gamenative.utils.BootProgress import app.gamenative.ui.component.parsePositiveFpsLimit import app.gamenative.ui.data.PerformanceHudConfig import app.gamenative.ui.data.PerformanceHudSize @@ -2226,6 +2227,7 @@ fun XServerScreen( setupExecutor.submit { try { + BootProgress.start() val containerManager = ContainerManager(context) // Configure WinHandler with container's input API settings val handler = getxServer().winHandler @@ -2308,6 +2310,7 @@ fun XServerScreen( val envVars = EnvVars() immersiveHooks?.windowsVr?.beforeWineSystemSetup(container) + BootProgress.phase(BootProgress.Phase.WINE_FILES) runBlocking { setupWineSystemFiles( context, @@ -2324,6 +2327,7 @@ fun XServerScreen( extractArm64ecInputDLLs(context, container) // REQUIRED: Uses updated xinput1_3 main.c from x86_64 build, prevents crashes with 3+ players, avoids need for input shim dlls. extractx86_64InputDlls(context, container) + BootProgress.phase(BootProgress.Phase.GRAPHICS) runBlocking { extractGraphicsDriverFiles( context, @@ -3830,6 +3834,7 @@ private fun setupXEnvironment( offline: Boolean = false, immersiveHooks: app.gamenative.ui.screen.xr.ImmersiveSessionHooks? = null, ): XEnvironment { + BootProgress.phase(BootProgress.Phase.ENVIRONMENT) ProcessHelper.hardKillStaleWineProcesses() val gameSource = ContainerUtils.extractGameSourceFromContainerId(appId) @@ -4017,9 +4022,9 @@ private fun setupXEnvironment( onError = onGameLaunchError ) if (preInstallCommands.isNotEmpty()) { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites...")) + BootProgress.phase(BootProgress.Phase.PREREQS, "1/${preInstallCommands.size}") } else { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Launching game...")) + BootProgress.phase(BootProgress.Phase.LAUNCH) } } @@ -4120,9 +4125,10 @@ private fun setupXEnvironment( } val nextRemaining = remaining.drop(1) if (nextRemaining.isEmpty()) { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Launching game...")) + BootProgress.phase(BootProgress.Phase.LAUNCH) } else { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites...")) + val step = preInstallCommands.size - nextRemaining.size + 1 + BootProgress.phase(BootProgress.Phase.PREREQS, "$step/${preInstallCommands.size}") } chainPreInstallSteps(nextRemaining) guestProgramLauncherComponent.start() @@ -4905,7 +4911,9 @@ private fun unpackExecutableFile( var output = StringBuilder() if (needsUnpacking || containerVariantChanged){ try { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing Mono...")) + BootProgress.phase(BootProgress.Phase.MONO) + // msiexec reports nothing back, so track the prefix directory it writes into instead. + BootProgress.watchOutput(File(imageFs.wineprefix, "drive_c/windows/mono")) val monoCmd = "wine msiexec /i Z:\\opt\\mono-gecko-offline\\wine-mono-11.0.0-x86.msi && wineserver -k" Timber.i("Install mono command $monoCmd") val monoOutput = guestProgramLauncherComponent.execShellCommand(monoCmd) @@ -4935,7 +4943,7 @@ private fun unpackExecutableFile( val rootDir: File = imageFs.getRootDir() try { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM...")) + BootProgress.phase(BootProgress.Phase.DRM, "reading interfaces") // a:/.../GameDir/orig_dll_path.txt (same dir as the EXE inside A:) val origTxtFile = File("${imageFs.wineprefix}/dosdevices/a:/orig_dll_path.txt") @@ -4999,11 +5007,12 @@ private fun unpackExecutableFile( if (exePaths.isEmpty()) { Timber.w("No executable path set, skipping Steamless") } else { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM...")) + BootProgress.phase(BootProgress.Phase.DRM) for ((index, executablePath) in exePaths.withIndex()) { - if (exePaths.size > 1) { - PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Handling DRM (${index + 1}/${exePaths.size})")) - } + BootProgress.update( + index.toFloat() / exePaths.size, + "${index + 1}/${exePaths.size}: ${extractExecutableBasename(executablePath)}", + ) var batchFile: File? = null try { // Normalize path: use forward slashes for Unix format, backslashes for Windows @@ -5209,7 +5218,7 @@ private suspend fun setupWineSystemFiles( // Download or use cached/bundled openal component val openalFile = WinComponentDownloader.ensureWinComponentAvailable(context, "openal") { progress -> - Timber.d("Downloading openal component: ${(progress * 100).toInt()}%") + BootProgress.download("OpenAL", progress) } if (openalFile == null) { @@ -5351,7 +5360,7 @@ private suspend fun extractGraphicsDriverComponent( onExtractFileListener: OnExtractFileListener? = null ) { val componentFile = GraphicsDriverDownloader.ensureGraphicsDriverAvailable(context, componentId) { progress -> - Timber.d("Downloading graphics driver $componentId: ${(progress * 100).toInt()}%") + BootProgress.download("graphics driver $componentId", progress) } if (componentFile == null) { @@ -5387,7 +5396,7 @@ private suspend fun extractDXWrapperComponent( onExtractFileListener: OnExtractFileListener? ) { val componentFile = DXWrapperDownloader.ensureDXWrapperAvailable(context, componentId) { progress -> - Timber.d("Downloading dxwrapper $componentId: ${(progress * 100).toInt()}%") + BootProgress.download("dxwrapper $componentId", progress) } if (componentFile == null) { @@ -5624,7 +5633,7 @@ private suspend fun extractWinComponentFiles( val componentFile = WinComponentDownloader.ensureWinComponentAvailable( context, identifier ) { progress -> - Timber.d("Downloading wincomponent $identifier: ${(progress * 100).toInt()}%") + BootProgress.download("component $identifier", progress) } if (componentFile == null) { @@ -5782,7 +5791,7 @@ private suspend fun extractGraphicsDriverFiles( // Download or get cached core driver val driverFile = CoreDriverDownloader.ensureCoreDriverAvailable(context, assetZip) { progress -> - Timber.d("Downloading core driver $assetZip: ${(progress * 100).toInt()}%") + BootProgress.download("core driver $assetZip", progress) } // Read manifest name from zip to determine folder name diff --git a/app/src/main/java/app/gamenative/utils/BootProgress.kt b/app/src/main/java/app/gamenative/utils/BootProgress.kt new file mode 100644 index 0000000000..54bd08f13e --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/BootProgress.kt @@ -0,0 +1,234 @@ +package app.gamenative.utils + +import app.gamenative.PluviaApp +import app.gamenative.events.AndroidEvent +import com.winlator.core.TarCompressorUtils +import java.io.File +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import timber.log.Timber + +/** + * Weighted progress model behind the container boot splash. + * + * The splash used to be a single indeterminate bar with a handful of static labels, so a + * multi-minute first boot was indistinguishable from a stalled one. Each phase here reports a + * real fraction when the underlying work exposes one (download bytes, archive bytes) and a + * creeping estimate plus elapsed time when it does not (wine msiexec has no progress output). + * + * Phase weights are estimates, not measurements: which phases run at all depends on the + * container (first boot, changed variant, cached driver), so the bar moves unevenly by design. + * + * Nothing is emitted unless [start] has been called, so extractions and downloads that happen + * outside a boot (library installs) can share the same hooks without popping the splash up. + */ +object BootProgress { + + enum class Phase(val label: String, val weight: Float) { + PREPARING("Preparing container", 0.05f), + WINE_FILES("Setting up Wine files", 0.25f), + GRAPHICS("Setting up graphics driver", 0.18f), + ENVIRONMENT("Starting Wine environment", 0.07f), + MONO("Installing Mono", 0.18f), + DRM("Handling DRM", 0.12f), + PREREQS("Installing prerequisites", 0.10f), + LAUNCH("Launching game", 0.05f), + } + + /** Creep ceiling inside a phase with no measurable fraction. Never reaches the next phase. */ + private const val CREEP_CEILING = 0.85f + + /** How far creep may run past the last measured fraction, to cover gaps between reports. */ + private const val CREEP_OVERSHOOT = 0.15f + private const val CREEP_RATE = 0.04f + private const val TICK_MS = 500L + + /** Elapsed time is noise on a fast step; it only helps once a step visibly drags. */ + private const val ELAPSED_AFTER_MS = 5000L + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var ticker: Job? = null + + @Volatile private var active = false + private var phase: Phase = Phase.PREPARING + private var base = 0f + private var local = 0f + private var measured = false + private var measuredFloor = 0f + private var detail: String? = null + private var phaseStartedAt = 0L + private var watchedDir: File? = null + private var watchedBytes = 0L + private var segmentKey: String? = null + private var segmentStart = 0f + private var segmentSpan = 0f + private var ticks = 0 + + private var lastText = "" + private var lastProgress = 0f + + /** Marks the beginning of a boot. Safe to call more than once. */ + @Synchronized + fun start() { + TarCompressorUtils.extractProgressListener = TarCompressorUtils.ExtractProgressListener(::extracting) + active = true + base = 0f + local = 0f + measured = false + measuredFloor = 0f + detail = null + watchedDir = null + watchedBytes = 0L + segmentKey = null + lastText = "" + lastProgress = 0f + phase = Phase.PREPARING + phaseStartedAt = System.currentTimeMillis() + ticker?.cancel() + ticker = scope.launch { + while (isActive) { + delay(TICK_MS) + tick() + } + } + emit() + } + + /** Boot is over (game window is up, or the splash was dismissed). Stops all emission. */ + @Synchronized + fun stop() { + active = false + ticker?.cancel() + ticker = null + watchedDir = null + } + + /** + * Enters [next]. Phases may be skipped entirely, so the base only ever moves forward. + */ + @Synchronized + fun phase(next: Phase, detail: String? = null) { + if (!active) return + phase = next + base = maxOf(base, Phase.entries.takeWhile { it != next }.sumOf { it.weight.toDouble() }.toFloat()) + local = 0f + measured = false + measuredFloor = 0f + this.detail = detail + watchedDir = null + watchedBytes = 0L + segmentKey = null + phaseStartedAt = System.currentTimeMillis() + emit() + } + + /** Reports a real fraction (0..1) inside the current phase. */ + @Synchronized + fun update(fraction: Float, detail: String? = null) { + if (!active) return + measured = true + measuredFloor = fraction.coerceIn(0f, 1f) + local = maxOf(local, measuredFloor) + if (detail != null) this.detail = detail + emit() + } + + /** Replaces the sub-label without touching the fraction. */ + @Synchronized + fun detail(text: String?) { + if (!active) return + detail = text + emit() + } + + /** + * Watches a directory the current step writes into, so an opaque step (the Mono MSI) at + * least reports how much it has produced so far. + */ + @Synchronized + fun watchOutput(dir: File?) { + if (!active) return + watchedDir = dir + watchedBytes = 0L + } + + /** + * Maps one item's own fraction into the room left in the phase. A phase can hold any number + * of archives and downloads and none of them knows how many follow, so each takes half of + * what is left: the bar keeps advancing and never claims the phase is done early. + */ + @Synchronized + private fun updateSegment(key: String, fraction: Float, detail: String) { + if (!active) return + if (key != segmentKey) { + segmentKey = key + segmentStart = local + segmentSpan = (1f - segmentStart) * 0.5f + } + update(segmentStart + segmentSpan * fraction.coerceIn(0f, 1f), detail) + } + + /** Download progress hook for the component downloaders. */ + fun download(what: String, fraction: Float) { + Timber.d("Downloading %s: %d%%", what, (fraction * 100).toInt()) + updateSegment(what, fraction, "downloading $what ${(fraction * 100).toInt()}%") + } + + /** + * Archive extraction hook, called from `TarCompressorUtils` for every archive in the app. + * [totalBytes] is -1 when the source size is unknown (streams, compressed assets). + */ + fun extracting(sourceName: String?, bytesRead: Long, totalBytes: Long) { + if (!active) return + val name = sourceName?.substringAfterLast('/')?.substringBefore(".tzst")?.substringBefore(".tar") + if (totalBytes > 0 && name != null) { + updateSegment(name, bytesRead.toFloat() / totalBytes, "unpacking $name") + } else if (name != null) { + detail("unpacking $name") + } + } + + private fun tick() = synchronized(this) { + if (!active) return@synchronized + // Creep runs in every phase: a measured step still goes quiet between reports (one + // Steamless pass per executable), and a frozen bar reads as a hang. + val ceiling = if (measured) minOf(measuredFloor + CREEP_OVERSHOOT, 1f) else CREEP_CEILING + if (local < ceiling) local += (ceiling - local) * CREEP_RATE + // Walking the tree is the expensive part of a tick, so sample it a quarter as often. + ticks++ + watchedDir?.takeIf { ticks % 4 == 0 }?.let { dir -> + watchedBytes = runCatching { dirSize(dir) }.getOrDefault(watchedBytes) + } + emit() + } + + private fun dirSize(dir: File): Long = + dir.walkTopDown().maxDepth(6).filter { it.isFile }.sumOf { it.length() } + + private fun emit() { + if (!active) return + val progress = maxOf(lastProgress, (base + phase.weight * local).coerceIn(0f, 0.99f)) + val text = buildString { + append(phase.label) + val parts = mutableListOf() + detail?.let { parts.add(it) } + if (watchedBytes > 0) parts.add("${watchedBytes / (1024 * 1024)} MB") + val elapsed = System.currentTimeMillis() - phaseStartedAt + if (elapsed >= ELAPSED_AFTER_MS) { + val seconds = elapsed / 1000 + parts.add("%d:%02d".format(seconds / 60, seconds % 60)) + } + if (parts.isNotEmpty()) parts.joinTo(this, prefix = " (", postfix = ")") + } + // The extraction hook fires per tar entry; without this the splash would churn per file. + if (text == lastText && progress - lastProgress < 0.005f) return + lastText = text + lastProgress = progress + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(text, progress)) + } +} diff --git a/app/src/main/java/com/winlator/core/TarCompressorUtils.java b/app/src/main/java/com/winlator/core/TarCompressorUtils.java index 18d1e21162..1046cfe261 100644 --- a/app/src/main/java/com/winlator/core/TarCompressorUtils.java +++ b/app/src/main/java/com/winlator/core/TarCompressorUtils.java @@ -1,6 +1,7 @@ package com.winlator.core; import android.content.Context; +import android.content.res.AssetFileDescriptor; import android.content.res.AssetManager; import android.net.Uri; import android.util.Log; @@ -22,6 +23,7 @@ import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; +import java.io.FilterInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; @@ -29,6 +31,17 @@ public abstract class TarCompressorUtils { public enum Type {XZ, ZSTD} + /** + * Reports how far an extraction has consumed its source. Every extract() overload funnels + * through the same private method, so a single listener covers all of them. + */ + public interface ExtractProgressListener { + //! totalBytes is -1 when the source size is unknown (raw streams, compressed assets) + void onExtractProgress(String sourceName, long bytesRead, long totalBytes); + } + + public static volatile ExtractProgressListener extractProgressListener = null; + private static void addFile(ArchiveOutputStream tar, File file, String entryName) { try { tar.putArchiveEntry(tar.createArchiveEntry(file, entryName)); @@ -108,20 +121,30 @@ public static boolean extract(Type type, AssetManager assetManager, String asset public static boolean extract(Type type, AssetManager assetManager, String assetFile, File destination, OnExtractFileListener onExtractFileListener) { try { - return extract(type, assetManager.open(assetFile), destination, onExtractFileListener); + return extract(type, assetManager.open(assetFile), destination, onExtractFileListener, assetFile, assetLength(assetManager, assetFile)); } catch (IOException e) { return false; } } + //! Compressed assets have no file descriptor, so their size is only knowable after inflation + private static long assetLength(AssetManager assetManager, String assetFile) { + try (AssetFileDescriptor fd = assetManager.openFd(assetFile)) { + return fd.getLength(); + } + catch (IOException e) { + return -1; + } + } + public static boolean extract(Type type, Context context, String assetFile, File destination) { return extract(type, context, assetFile, destination, null); } public static boolean extract(Type type, Context context, String assetFile, File destination, OnExtractFileListener onExtractFileListener) { try { - return extract(type, context.getAssets().open(assetFile), destination, onExtractFileListener); + return extract(type, context.getAssets().open(assetFile), destination, onExtractFileListener, assetFile, assetLength(context.getAssets(), assetFile)); } catch (IOException e) { return false; @@ -136,9 +159,10 @@ public static boolean extract(Type type, Context context, Uri source, File desti if (source == null) return false; try { if (source.toString().startsWith("/")) { - return extract(type, new FileInputStream(source.toString()), destination, onExtractFileListener); + File file = new File(source.toString()); + return extract(type, new FileInputStream(file), destination, onExtractFileListener, file.getName(), file.length()); } else { - return extract(type, context.getContentResolver().openInputStream(source), destination, onExtractFileListener); + return extract(type, context.getContentResolver().openInputStream(source), destination, onExtractFileListener, source.getLastPathSegment(), -1); } } catch (FileNotFoundException e) { @@ -157,7 +181,7 @@ public static boolean extract(Type type, InputStream source, File destination) { public static boolean extract(Type type, File source, File destination, OnExtractFileListener onExtractFileListener) { if (source == null || !source.isFile()) return false; try { - return extract(type, new BufferedInputStream(new FileInputStream(source), StreamUtils.BUFFER_SIZE), destination, onExtractFileListener); + return extract(type, new BufferedInputStream(new FileInputStream(source), StreamUtils.BUFFER_SIZE), destination, onExtractFileListener, source.getName(), source.length()); } catch (FileNotFoundException e) { return false; @@ -165,8 +189,13 @@ public static boolean extract(Type type, File source, File destination, OnExtrac } private static boolean extract(Type type, InputStream source, File destination, OnExtractFileListener onExtractFileListener) { + return extract(type, source, destination, onExtractFileListener, null, -1); + } + + private static boolean extract(Type type, InputStream source, File destination, OnExtractFileListener onExtractFileListener, String sourceName, long totalBytes) { if (source == null) return false; - try (InputStream inStream = getCompressorInputStream(type, source); + CountingInputStream countingSource = new CountingInputStream(source); + try (InputStream inStream = getCompressorInputStream(type, countingSource); ArchiveInputStream tar = new TarArchiveInputStream(inStream)) { TarArchiveEntry entry; while ((entry = (TarArchiveEntry)tar.getNextEntry()) != null) { @@ -200,6 +229,9 @@ private static boolean extract(Type type, InputStream source, File destination, } FileUtils.chmod(file, 0771); + + ExtractProgressListener progressListener = extractProgressListener; + if (progressListener != null) progressListener.onExtractProgress(sourceName, countingSource.getCount(), totalBytes); } return true; } @@ -209,6 +241,40 @@ private static boolean extract(Type type, InputStream source, File destination, } } + //! Counts the compressed bytes consumed, which is what the source size can be compared against + private static class CountingInputStream extends FilterInputStream { + private long count; + + CountingInputStream(InputStream in) { + super(in); + } + + long getCount() { + return count; + } + + @Override + public int read() throws IOException { + int value = super.read(); + if (value != -1) count++; + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int read = super.read(buffer, offset, length); + if (read > 0) count += read; + return read; + } + + @Override + public long skip(long n) throws IOException { + long skipped = super.skip(n); + if (skipped > 0) count += skipped; + return skipped; + } + } + //! Detects Mac Prefixes - Some builds are done with Macs and it trips up extraction algorithm private static boolean isMacPrefixFile(String entryName, String fileName) { return fileName.startsWith("._") || entryName.contains("__MACOSX/"); From 58d4af228d7a886971e4c87bd5ea728cfe6bccf3 Mon Sep 17 00:00:00 2001 From: Alexey Lysenko Date: Thu, 6 Aug 2026 20:32:19 +0300 Subject: [PATCH 2/5] detailed progress switch in debug menu --- .../main/java/app/gamenative/PrefManager.kt | 5 ++ .../ui/screen/settings/SettingsGroupDebug.kt | 15 +++++ .../ui/screen/xserver/XAudioUtils.kt | 2 +- .../ui/screen/xserver/XServerScreen.kt | 1 + .../java/app/gamenative/utils/BootProgress.kt | 58 ++++++++++++++----- app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 7 files changed, 71 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/app/gamenative/PrefManager.kt b/app/src/main/java/app/gamenative/PrefManager.kt index 48deba84d8..1552a93faf 100644 --- a/app/src/main/java/app/gamenative/PrefManager.kt +++ b/app/src/main/java/app/gamenative/PrefManager.kt @@ -1455,6 +1455,11 @@ object PrefManager { setPref(CUSTOM_GAME_MANUAL_FOLDERS, Json.encodeToString(value)) } + // Detailed per-step reporting on the boot splash; off means the plain indeterminate bar + private val VERBOSE_BOOT_PROGRESS = booleanPreferencesKey("verbose_boot_progress") + var verboseBootProgress: Boolean + get() = getPref(VERBOSE_BOOT_PROGRESS, false) + set(value) = setPref(VERBOSE_BOOT_PROGRESS, value) private val FAVORITE_APP_IDS = stringPreferencesKey("favorite_app_ids") var favoriteAppIds: Set get() { diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt index 66b46b0c1b..9ac88b77c1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupDebug.kt @@ -87,6 +87,9 @@ fun SettingsGroupDebug() { var enableBox86Logs by rememberSaveable { mutableStateOf( if (isPreview) false else WinlatorPrefManager.getBoolean("enable_box86_64_logs", false) ) } + var verboseBootProgress by rememberSaveable { + mutableStateOf(if (isPreview) false else PrefManager.verboseBootProgress) + } var latestCrashFile: File? by rememberSaveable { mutableStateOf(null) } LaunchedEffect(Unit) { val crashDir = File(context.getExternalFilesDir(null), "crash_logs") @@ -222,6 +225,18 @@ fun SettingsGroupDebug() { } }, ) + SettingsSwitch( + colors = settingsTileColorsAlt(), + state = verboseBootProgress, + title = { Text(text = stringResource(R.string.settings_debug_verbose_boot_title)) }, + subtitle = { Text(text = stringResource(R.string.settings_debug_verbose_boot_subtitle)) }, + onCheckedChange = { + verboseBootProgress = it + if (!isPreview) { + PrefManager.verboseBootProgress = it + } + }, + ) SettingsMenuLink( colors = settingsTileColors(), title = { Text(text = stringResource(R.string.settings_debug_view_crash_title)) }, diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt index 754deb73e8..20c6e43698 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XAudioUtils.kt @@ -133,7 +133,7 @@ object XAudioUtils { return } - BootProgress.detail("extracting XAudio DLLs") + BootProgress.detail("extracting XAudio DLLs", legacy = "Extracting XAudio DLLs...") val batFile = File(tempDir, "extract_dx_audio_dlls.bat") val batContent = buildCabarcBatchScript( 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 e4038d44b2..0acd233014 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 @@ -5012,6 +5012,7 @@ private fun unpackExecutableFile( BootProgress.update( index.toFloat() / exePaths.size, "${index + 1}/${exePaths.size}: ${extractExecutableBasename(executablePath)}", + legacy = "Handling DRM (${index + 1}/${exePaths.size})".takeIf { exePaths.size > 1 }, ) var batchFile: File? = null try { diff --git a/app/src/main/java/app/gamenative/utils/BootProgress.kt b/app/src/main/java/app/gamenative/utils/BootProgress.kt index 54bd08f13e..a538e243e6 100644 --- a/app/src/main/java/app/gamenative/utils/BootProgress.kt +++ b/app/src/main/java/app/gamenative/utils/BootProgress.kt @@ -1,6 +1,7 @@ package app.gamenative.utils import app.gamenative.PluviaApp +import app.gamenative.PrefManager import app.gamenative.events.AndroidEvent import com.winlator.core.TarCompressorUtils import java.io.File @@ -26,18 +27,23 @@ import timber.log.Timber * * Nothing is emitted unless [start] has been called, so extractions and downloads that happen * outside a boot (library installs) can share the same hooks without popping the splash up. + * + * All of this sits behind PrefManager.verboseBootProgress and is off by default. With the setting + * off, only the phases that reported something before this existed emit, with their original text + * and an indeterminate bar, so the splash behaves exactly as it used to. */ object BootProgress { - enum class Phase(val label: String, val weight: Float) { + /** [legacy] is what the phase put on the splash before this existed, null if it said nothing. */ + enum class Phase(val label: String, val weight: Float, val legacy: String? = null) { PREPARING("Preparing container", 0.05f), WINE_FILES("Setting up Wine files", 0.25f), GRAPHICS("Setting up graphics driver", 0.18f), ENVIRONMENT("Starting Wine environment", 0.07f), - MONO("Installing Mono", 0.18f), - DRM("Handling DRM", 0.12f), - PREREQS("Installing prerequisites", 0.10f), - LAUNCH("Launching game", 0.05f), + MONO("Installing Mono", 0.18f, "Installing Mono..."), + DRM("Handling DRM", 0.12f, "Handling DRM..."), + PREREQS("Installing prerequisites", 0.10f, "Installing prerequisites..."), + LAUNCH("Launching game", 0.05f, "Launching game..."), } /** Creep ceiling inside a phase with no measurable fraction. Never reaches the next phase. */ @@ -55,6 +61,8 @@ object BootProgress { private var ticker: Job? = null @Volatile private var active = false + + @Volatile private var verbose = false private var phase: Phase = Phase.PREPARING private var base = 0f private var local = 0f @@ -75,8 +83,11 @@ object BootProgress { /** Marks the beginning of a boot. Safe to call more than once. */ @Synchronized fun start() { - TarCompressorUtils.extractProgressListener = TarCompressorUtils.ExtractProgressListener(::extracting) + verbose = PrefManager.verboseBootProgress active = true + lastText = "" + if (!verbose) return + TarCompressorUtils.extractProgressListener = TarCompressorUtils.ExtractProgressListener(::extracting) base = 0f local = 0f measured = false @@ -85,7 +96,6 @@ object BootProgress { watchedDir = null watchedBytes = 0L segmentKey = null - lastText = "" lastProgress = 0f phase = Phase.PREPARING phaseStartedAt = System.currentTimeMillis() @@ -114,6 +124,10 @@ object BootProgress { @Synchronized fun phase(next: Phase, detail: String? = null) { if (!active) return + if (!verbose) { + next.legacy?.let { emitLegacy(it) } + return + } phase = next base = maxOf(base, Phase.entries.takeWhile { it != next }.sumOf { it.weight.toDouble() }.toFloat()) local = 0f @@ -127,10 +141,17 @@ object BootProgress { emit() } - /** Reports a real fraction (0..1) inside the current phase. */ + /** + * Reports a real fraction (0..1) inside the current phase. [legacy] is what this call site + * used to put on the splash, emitted verbatim while detailed progress is off. + */ @Synchronized - fun update(fraction: Float, detail: String? = null) { + fun update(fraction: Float, detail: String? = null, legacy: String? = null) { if (!active) return + if (!verbose) { + legacy?.let { emitLegacy(it) } + return + } measured = true measuredFloor = fraction.coerceIn(0f, 1f) local = maxOf(local, measuredFloor) @@ -140,8 +161,12 @@ object BootProgress { /** Replaces the sub-label without touching the fraction. */ @Synchronized - fun detail(text: String?) { + fun detail(text: String?, legacy: String? = null) { if (!active) return + if (!verbose) { + legacy?.let { emitLegacy(it) } + return + } detail = text emit() } @@ -152,7 +177,7 @@ object BootProgress { */ @Synchronized fun watchOutput(dir: File?) { - if (!active) return + if (!active || !verbose) return watchedDir = dir watchedBytes = 0L } @@ -164,7 +189,7 @@ object BootProgress { */ @Synchronized private fun updateSegment(key: String, fraction: Float, detail: String) { - if (!active) return + if (!active || !verbose) return if (key != segmentKey) { segmentKey = key segmentStart = local @@ -184,7 +209,7 @@ object BootProgress { * [totalBytes] is -1 when the source size is unknown (streams, compressed assets). */ fun extracting(sourceName: String?, bytesRead: Long, totalBytes: Long) { - if (!active) return + if (!active || !verbose) return val name = sourceName?.substringAfterLast('/')?.substringBefore(".tzst")?.substringBefore(".tar") if (totalBytes > 0 && name != null) { updateSegment(name, bytesRead.toFloat() / totalBytes, "unpacking $name") @@ -210,6 +235,13 @@ object BootProgress { private fun dirSize(dir: File): Long = dir.walkTopDown().maxDepth(6).filter { it.isFile }.sumOf { it.length() } + /** Pre-existing behaviour: the original label, indeterminate bar, no sub-detail. */ + private fun emitLegacy(text: String) { + if (text == lastText) return + lastText = text + PluviaApp.events.emit(AndroidEvent.SetBootingSplashText(text)) + } + private fun emit() { if (!active) return val progress = maxOf(lastProgress, (base + phase.weight * local).coerceIn(0f, 0.99f)) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 8e2dc37dae..32ea070227 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1073,6 +1073,8 @@ https://gamenative.app Настройки Записать вывод отладки Box86 & Box64 в файл Включить логи Box86/64 + Подробный прогресс + Показывать текущий шаг и реальный прогресс при запуске контейнера Удалить все загруженные изображения. Очистить кэш изображений [Закрывает приложение] Может помочь исправить проблемы с элементами библиотеки или сообщениями. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89eeeac659..4ad61dd782 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1099,6 +1099,8 @@ Select Wine Debug Channels Enable Wine Debug Logs Write Wine debug output to file + Detailed launch progress + Show the current step and a real progress bar while a container boots Enable Box86/64 Logs Write Box86 & Box64 debug output to file View latest crash From 4d4c849ae59918cc09540219164d131c3834daed Mon Sep 17 00:00:00 2001 From: Alexey Lysenko Date: Sat, 22 Aug 2026 13:46:19 +0300 Subject: [PATCH 3/5] missed part after rebase --- app/src/main/java/app/gamenative/events/AndroidEvent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/events/AndroidEvent.kt b/app/src/main/java/app/gamenative/events/AndroidEvent.kt index 7b9f454f63..ca9fd9c44f 100644 --- a/app/src/main/java/app/gamenative/events/AndroidEvent.kt +++ b/app/src/main/java/app/gamenative/events/AndroidEvent.kt @@ -20,7 +20,7 @@ interface AndroidEvent : Event { data class ShowGameFeedback(val appId: String) : AndroidEvent data class ShowLaunchingOverlay(val appName: String) : AndroidEvent data object HideLaunchingOverlay : AndroidEvent - data class SetBootingSplashText(val text: String) : AndroidEvent + data class SetBootingSplashText(val text: String, val progress: Float = -1f) : AndroidEvent data object ClearBootingSplash : AndroidEvent data class DownloadPausedDueToConnectivity(val appId: Int) : AndroidEvent data class DownloadStatusChanged(val appId: Int, val isDownloading: Boolean) : AndroidEvent From 9de5b50f2c84b39df55e131f79266fbd44d4b7b9 Mon Sep 17 00:00:00 2001 From: Alexey Lysenko Date: Sat, 22 Aug 2026 15:15:12 +0300 Subject: [PATCH 4/5] after review fixes --- .../app/gamenative/ui/model/MainViewModel.kt | 21 ++++++++++-- .../java/app/gamenative/utils/BootProgress.kt | 34 +++++++++++-------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt index 646c7f5f6d..c74e5f4b20 100644 --- a/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt +++ b/app/src/main/java/app/gamenative/ui/model/MainViewModel.kt @@ -340,6 +340,7 @@ class MainViewModel @Inject constructor( PluviaApp.events.off(onLoggedOut) PluviaApp.events.off(onServiceReady) connectionTimeoutJob?.cancel() + BootProgress.stop() } fun setTheme(value: AppTheme) { @@ -416,10 +417,26 @@ class MainViewModel @Inject constructor( } // bootAd stays in state so the exit fade keeps rendering it; the next show replaces it. PluviaApp.isBootingSplashShowing = false - _state.update { it.copy(showBootingSplash = false) } + _state.update { + it.copy( + showBootingSplash = false, + bootingSplashText = MainState().bootingSplashText, + bootingSplashProgress = MainState().bootingSplashProgress, + ) + } } else { PluviaApp.isBootingSplashShowing = value - _state.update { it.copy(showBootingSplash = value) } + _state.update { + if (value) { + it.copy(showBootingSplash = true) + } else { + it.copy( + showBootingSplash = false, + bootingSplashText = MainState().bootingSplashText, + bootingSplashProgress = MainState().bootingSplashProgress, + ) + } + } } } diff --git a/app/src/main/java/app/gamenative/utils/BootProgress.kt b/app/src/main/java/app/gamenative/utils/BootProgress.kt index a538e243e6..8aef9423a0 100644 --- a/app/src/main/java/app/gamenative/utils/BootProgress.kt +++ b/app/src/main/java/app/gamenative/utils/BootProgress.kt @@ -86,6 +86,8 @@ object BootProgress { verbose = PrefManager.verboseBootProgress active = true lastText = "" + ticker?.cancel() + ticker = null if (!verbose) return TarCompressorUtils.extractProgressListener = TarCompressorUtils.ExtractProgressListener(::extracting) base = 0f @@ -99,7 +101,6 @@ object BootProgress { lastProgress = 0f phase = Phase.PREPARING phaseStartedAt = System.currentTimeMillis() - ticker?.cancel() ticker = scope.launch { while (isActive) { delay(TICK_MS) @@ -116,6 +117,7 @@ object BootProgress { ticker?.cancel() ticker = null watchedDir = null + TarCompressorUtils.extractProgressListener = null } /** @@ -123,11 +125,11 @@ object BootProgress { */ @Synchronized fun phase(next: Phase, detail: String? = null) { - if (!active) return if (!verbose) { next.legacy?.let { emitLegacy(it) } return } + if (!active) return phase = next base = maxOf(base, Phase.entries.takeWhile { it != next }.sumOf { it.weight.toDouble() }.toFloat()) local = 0f @@ -147,11 +149,11 @@ object BootProgress { */ @Synchronized fun update(fraction: Float, detail: String? = null, legacy: String? = null) { - if (!active) return if (!verbose) { legacy?.let { emitLegacy(it) } return } + if (!active) return measured = true measuredFloor = fraction.coerceIn(0f, 1f) local = maxOf(local, measuredFloor) @@ -162,11 +164,11 @@ object BootProgress { /** Replaces the sub-label without touching the fraction. */ @Synchronized fun detail(text: String?, legacy: String? = null) { - if (!active) return if (!verbose) { legacy?.let { emitLegacy(it) } return } + if (!active) return detail = text emit() } @@ -218,18 +220,20 @@ object BootProgress { } } - private fun tick() = synchronized(this) { - if (!active) return@synchronized - // Creep runs in every phase: a measured step still goes quiet between reports (one - // Steamless pass per executable), and a frozen bar reads as a hang. - val ceiling = if (measured) minOf(measuredFloor + CREEP_OVERSHOOT, 1f) else CREEP_CEILING - if (local < ceiling) local += (ceiling - local) * CREEP_RATE - // Walking the tree is the expensive part of a tick, so sample it a quarter as often. - ticks++ - watchedDir?.takeIf { ticks % 4 == 0 }?.let { dir -> - watchedBytes = runCatching { dirSize(dir) }.getOrDefault(watchedBytes) + private fun tick() { + val dir = synchronized(this) { + if (!active) return + ticks++ + watchedDir?.takeIf { ticks % 4 == 0 } + } + val bytes = dir?.let { runCatching { dirSize(it) }.getOrNull() } + synchronized(this) { + if (!active) return + if (bytes != null && watchedDir === dir) watchedBytes = bytes + val ceiling = if (measured) minOf(measuredFloor + CREEP_OVERSHOOT, 1f) else CREEP_CEILING + if (local < ceiling) local += (ceiling - local) * CREEP_RATE + emit() } - emit() } private fun dirSize(dir: File): Long = From a72bc85f3dd6bba71e89f44e25fba6aeb865b00b Mon Sep 17 00:00:00 2001 From: Alexey Lysenko Date: Sun, 6 Sep 2026 15:24:54 +0300 Subject: [PATCH 5/5] translate the detailed progress toggle into every shipped locale The branch added settings_debug_verbose_boot_title/subtitle to values/ and values-ru/ only, so the other thirteen locales fell back to English. Add both strings to each of them, next to the Box86/64 log toggle they sit beside in the debug settings group. --- app/src/main/res/values-da/strings.xml | 2 ++ app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-es/strings.xml | 2 ++ app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja/strings.xml | 2 ++ app/src/main/res/values-ko/strings.xml | 2 ++ app/src/main/res/values-pl/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-ro/strings.xml | 2 ++ app/src/main/res/values-uk/strings.xml | 2 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values-zh-rTW/strings.xml | 2 ++ 13 files changed, 26 insertions(+) diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 754ef5f1c6..e183c73df6 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -780,6 +780,8 @@ Skriv Wine debug-output til fil Aktivér Box86/64-logs Skriv Box86 & Box64 debug-output til fil + Detaljeret opstartsforløb + Vis det aktuelle trin og en reel forløbslinje, mens en container starter Vis seneste crash Viser den seneste crash-rapport Ingen nylige crash-rapporter fundet diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 95591d2399..3d473d0973 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -930,6 +930,8 @@ Wine-Debug-Ausgaben in Datei schreiben Box86/64-Logs aktivieren Box86 & Box64-Debug-Ausgaben in Datei schreiben + Detaillierter Startfortschritt + Aktuellen Schritt und einen echten Fortschrittsbalken anzeigen, während ein Container startet Letzten Absturz anzeigen Zeigt das neueste Absturzprotokoll Keine kürzlichen Absturzprotokolle gefunden diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 353ee4cb0f..b2b470b6f3 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -972,6 +972,8 @@ Escribe la salida de depuración de Wine en un archivo. Activar registros de Box86/64 Escribe la salida de depuración de Box86 y Box64 en un archivo. + Progreso de inicio detallado + Muestra el paso actual y una barra de progreso real mientras arranca un contenedor Ver último fallo Muestra el registro de fallos más reciente. No se encontraron registros de fallos recientes. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index bd0e92dc42..9f9eaf7ae6 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -982,6 +982,8 @@ Écrire la sortie de débogage Wine dans un fichier Activer les journaux Box86/64 Écrire la sortie de débogage Box86 & Box64 dans un fichier + Progression de démarrage détaillée + Afficher l\'étape en cours et une vraie barre de progression pendant le démarrage d\'un conteneur Voir le dernier plantage Affiche le journal de plantage le plus récent Aucun journal de plantage récent trouvé diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 96931f1a23..c8708869c6 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -956,6 +956,8 @@ Scrivi output debug Wine su file Abilita Log Box86/64 Scrivi output debug Box86 & Box64 su file + Avanzamento avvio dettagliato + Mostra il passaggio corrente e una barra di avanzamento reale durante l\'avvio di un container Visualizza ultimo crash Mostra il log di crash più recente Nessun log di crash recente trovato diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 566d5c8a2f..0cfbc9ae56 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -934,6 +934,8 @@ Wine デバッグ出力をファイルに書き込む Box86/64 ログを有効にする Box86 & Box64 デバッグ出力をファイルに書き込む + 詳細な起動進捗 + コンテナの起動中に現在のステップと実際の進捗バーを表示する 最新のクラッシュを表示 最新のクラッシュログを表示します 最近のクラッシュ ログは見つかりませんでした diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index c1b5e0a369..aed6e3aed0 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -963,6 +963,8 @@ Wine 디버그 출력을 파일에 쓰기 Box86/64 로그 활성화 Box86 & Box64 디버그 출력을 파일에 쓰기 + 상세 실행 진행 상황 + 컨테이너가 부팅되는 동안 현재 단계와 실제 진행 표시줄을 표시합니다 최신 충돌 보기 가장 최근 충돌 로그 표시 최근 충돌 로그를 찾을 수 없음 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 193cd015c9..9aa9eb8a71 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -973,6 +973,8 @@ Zapisz wyjście debugowania Wine do pliku Włącz logi Box86/64 Zapisz wyjście debugowania Box86 i Box64 do pliku + Szczegółowy postęp uruchamiania + Pokazuj bieżący krok i rzeczywisty pasek postępu podczas uruchamiania kontenera Zobacz ostatni błąd Pokazuje najnowszy log awarii Nie znaleziono ostatnich logów awarii diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 65dacd8720..11a2bf2666 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -780,6 +780,8 @@ Gravar saída de debug do Wine em arquivo Habilitar logs do Box86/64 Gravar saída de debug do Box86 & Box64 em arquivo + Progresso detalhado da inicialização + Mostrar a etapa atual e uma barra de progresso real enquanto um contêiner inicia Ver última falha Mostra o log de falha mais recente Nenhum log de falha recente encontrado diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index e8c648651a..84650a4f42 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -965,6 +965,8 @@ Scrie ieșirea de debug Wine în fișier Activează logurile Box86/64 Scrie ieșirea de debug Box86 & Box64 în fișier + Progres detaliat la pornire + Afișează pasul curent și o bară de progres reală în timpul pornirii unui container Vezi ultimul crash Afișează cel mai recent raport de crash Nu s-au găsit rapoarte de crash recente diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 46c7c50a60..02a4b6dee1 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -959,6 +959,8 @@ Записувати виведення налагодження Wine у файл Увімкнути журнали Box86/64 Записувати виведення налагодження Box86 та Box64 у файл + Докладний прогрес запуску + Показувати поточний крок і реальний прогрес під час запуску контейнера Переглянути останній збій Показує останній звіт про збій Не знайдено нещодавніх звітів про збій diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 81566c4666..c882ea4dea 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -949,6 +949,8 @@ 将 Wine 调试输出写入文件 启用 Box86/64 日志 将 Box86 与 Box64 的调试输出写入文件 + 详细启动进度 + 在容器启动时显示当前步骤和真实进度条 查看最新崩溃日志 显示最新的崩溃报告 未找到最近的崩溃报告 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 42fcea9f01..0416a4da33 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -951,6 +951,8 @@ 將 Wine 除錯輸出寫入檔案 啟用 Box86/64 日誌 將 Box86 與 Box64 的除錯輸出寫入檔案 + 詳細啟動進度 + 在容器啟動時顯示目前步驟和實際進度列 檢視最新崩潰日誌 顯示最新的崩潰報告 未找到最近的崩潰報告