diff --git a/README.md b/README.md index 81ccb5b1b..7086c2cc1 100644 --- a/README.md +++ b/README.md @@ -287,8 +287,43 @@ Files in `/sdcard/Download`, for a device that cannot be reached with a debugger | `steamdeck-env` | `KEY=VALUE` lines added to the session's environment as written, after the app's own — Zink and Turnip tunables (`ZINK_DESCRIPTORS=lazy`, `MESA_*`), gamescope's, the client's; `#` comments allowed | | `steamdeck-tu-debug` | Turnip's `TU_DEBUG` for the runtime's driver, verbatim (`sysmem`, `sysmem,deck_emu`). Without it, an imported A710/A720/A722 driver gets `sysmem` on its own | -Session logs land in `/sdcard/Download/SteamDeck/`, one file per session, and are the first -thing to look at when something does not start. Creating `~/.steamdeck-desktop-debug` inside the +Session logs land in `/sdcard/Download/SteamDeck/`, **one folder per session**, and are the first +thing to look at when something does not start: + +``` +Download/SteamDeck/session-20260921-161256/ + device.txt what this device is, and every setting the session ran with + session.log the guest session: proot, gamescope, the client's own output + wayland.log the app's compositor - what it presented, and how fast + steam.log the Steam client's log, scrubbed (Steam mode) + steam/ the rest of the client's logs, scrubbed (Steam mode) + desktop.log labwc, the panel and the programs on it (desktop mode) +``` + +`device.txt` is written before the session starts, so a session that dies in its first second +still says what it ran on: model, SoC, Android and kernel, cores and their ceilings, RAM, the GPU +as KGSL names it, the panel, **which driver each half of the session chose**, the runtime version, +and every switch that was in effect. **Nothing identifying is collected** - no serial number, no +device or advertising id, no account name, no network names - and the Steam logs are scrubbed line +by line on the way in: session tokens, machine-auth GUIDs, WebAPI keys, Guard codes and e-mail +addresses are replaced, and a SteamID is masked to its last four digits so lines can still be +correlated. `loginusers.vdf`, `config.vdf` and the `ssfn` files are never copied at all. A session +folder is meant to be attachable to a bug report exactly as it is. + +### If Steam dies with nothing in the log + +That is usually not Steam. **Android 12 and later kill the extra processes an app starts for +itself** once there are more than a few, and a session is made of dozens - proot, gamescope, +Xwayland, the client and its helpers, Wine, FEX. Where that is left on, the OS ends the session and +no log of ours says why, because nothing in the session did it. Some phones expose it in Developer +options as a **"restrict child processes"** switch; turn it off. Otherwise, once per device: + +```sh +adb shell settings put global settings_enable_monitor_phantom_procs false +``` + +`device.txt` records what this phone reports, and the Performance dialog says so when it is not +disabled. Creating `~/.steamdeck-desktop-debug` inside the runtime additionally makes the desktop log labwc at debug level, each program's window-protocol traffic, and a name lookup plus an IPv4 and IPv6 fetch from inside the session — which is how to tell a broken network apart from a program that only believes it has one. diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c index 6589066ad..d0071a8d0 100644 --- a/app/src/main/cpp/waylandcomp/src/compositor.c +++ b/app/src/main/cpp/waylandcomp/src/compositor.c @@ -101,10 +101,17 @@ static void open_session_log(void) { localtime_r(&now, &tm); strftime(stamp, sizeof(stamp), "%Y-%m-%d_%H-%M-%S", &tm); - mkdir("/storage/emulated/0/Download", 0775); - if (mkdir(SESSION_LOG_DIR, 0775) != 0 && errno != EEXIST) - WLOGE("can't create %s: %s", SESSION_LOG_DIR, strerror(errno)); - snprintf(path, sizeof(path), "%s/wayland-%s.log", SESSION_LOG_DIR, stamp); + /* The app names the file when it has a session folder to put it in, so one session's logs sit + * together; without that we keep our own dated file. The folder is made on the Java side. */ + const char *given = getenv("BL_WAYLAND_LOG"); + if (given && *given) { + snprintf(path, sizeof(path), "%s", given); + } else { + mkdir("/storage/emulated/0/Download", 0775); + if (mkdir(SESSION_LOG_DIR, 0775) != 0 && errno != EEXIST) + WLOGE("can't create %s: %s", SESSION_LOG_DIR, strerror(errno)); + snprintf(path, sizeof(path), "%s/wayland-%s.log", SESSION_LOG_DIR, stamp); + } if (!(f = fopen(path, "w"))) { WLOGE("can't open session log %s: %s", path, strerror(errno)); return; diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 86299f83b..ad8479a40 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -85,6 +85,8 @@ class MainActivity : ComponentActivity() { private var tuSysmem by mutableStateOf(false) private var zinkLazy by mutableStateOf(false) private var noXalia by mutableStateOf(false) + private var prootNoSeccomp by mutableStateOf(false) + private var phantomWarning by mutableStateOf(null) private var directAudio by mutableStateOf(false) private var mic by mutableStateOf(false) private var linuxRows by mutableStateOf>(emptyList()) @@ -176,10 +178,12 @@ class MainActivity : ComponentActivity() { cores = CpuCores.all.map { c -> CoreRow(c, "cpu$c" + (CpuCores.maxGhz(c)?.let { String.format(java.util.Locale.US, " · %.1f GHz", it) } ?: "")) }, clientOverride = clientOverride, clientCores = clientCores, gameCores = gameCores, tuSysmem = tuSysmem, zinkLazy = zinkLazy, noXalia = noXalia, + prootNoSeccomp = prootNoSeccomp, phantomWarning = phantomWarning, onClientOverride = { on -> SessionPrefs.setClientCpusOverride(this, on); clientOverride = on }, onTuSysmem = { on -> SessionPrefs.setTuSysmem(this, on); tuSysmem = on }, onZinkLazy = { on -> SessionPrefs.setZinkLazy(this, on); zinkLazy = on }, onNoXalia = { on -> SessionPrefs.setNoXalia(this, on); noXalia = on }, + onProotNoSeccomp = { on -> SessionPrefs.setProotNoSeccomp(this, on); prootNoSeccomp = on }, onClientCore = { core, on -> clientCores = if (on) clientCores + core else clientCores - core SessionPrefs.setClientCpus(this, CpuCores.format(clientCores)) @@ -330,6 +334,18 @@ class MainActivity : ComponentActivity() { tuSysmem = SessionPrefs.tuSysmem(this) zinkLazy = SessionPrefs.zinkLazy(this) noXalia = SessionPrefs.noXalia(this) + prootNoSeccomp = SessionPrefs.prootNoSeccomp(this) + // Android 12 kills the children an app forks itself once there are more than a handful. + // A session is nothing but those, so where this is on the OS ends the session and no log + // of ours says why. We cannot change a secure setting from here - only say so. + phantomWarning = runCatching { + android.provider.Settings.Global.getString(contentResolver, "settings_enable_monitor_phantom_procs") + }.getOrNull().let { v -> + when (v?.lowercase()) { + "false", "0" -> null + else -> "Android 12 and later kill the extra processes an app starts for itself once there are more than a few, and a session is made of dozens: proot, gamescope, the client and its helpers, Wine. Where that is left on, the client dies with nothing in its log, because nothing in the session did it. Some phones have a \"restrict child processes\" switch in Developer options — turn it off. Otherwise, over adb:\n\n adb shell settings put global settings_enable_monitor_phantom_procs false\n\nThis phone " + (if (v == null) "has not been set either way, so the ROM's default applies." else "currently reports it as on.") + } + } } private fun refreshProtons() { diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt index 3e6705f6c..f88f38180 100644 --- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt @@ -36,6 +36,7 @@ import com.steamdeck.launcher.session.LoadingState import com.steamdeck.launcher.session.PerfHud import com.steamdeck.launcher.session.ProtonExtras import com.steamdeck.launcher.session.SessionPrefs +import com.steamdeck.launcher.session.SessionPaths import com.steamdeck.launcher.session.SessionService import com.steamdeck.launcher.session.SessionState import com.steamdeck.launcher.ui.CursorOverlay @@ -272,6 +273,18 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // Letterbox, never stretch or crop: the output can be a different shape from the panel, // and a game's picture must keep its proportions with bars, not lose its edges. WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER) + // The session's folder, claimed here because the compositor starts before the service and + // opens its log once. The compositor reads the path from its environment; setting it after + // it has started changes nothing, which is why the service copies the file in at teardown. + if (!SessionState.running) { + val waylandLog = File(SessionPaths.beginOrCurrent(this), "wayland.log") + WaylandCompositor.setSessionLogFile(waylandLog) + try { + android.system.Os.setenv("BL_WAYLAND_LOG", waylandLog.path, true) + } catch (e: Exception) { + Log.w(TAG, "could not point the compositor's log at $waylandLog", e) + } + } CompositorHost.startOrAttach( holder.surface, runtimeDir.path, driverId?.let { turnip.driverPath(it) }, driverId?.let { turnip.libraryName(it) }, diff --git a/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt new file mode 100644 index 000000000..2fbae3859 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt @@ -0,0 +1,182 @@ +package com.steamdeck.launcher.core + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import android.os.Environment +import android.os.StatFs +import android.provider.Settings +import android.util.Log +import com.steamdeck.launcher.gpu.LinuxVulkanDriver +import com.steamdeck.launcher.gpu.LinuxVulkanDriverManager +import com.steamdeck.launcher.gpu.TurnipDriver +import com.steamdeck.launcher.runtime.DesktopCatalog +import com.steamdeck.launcher.runtime.LinuxRuntime +import com.steamdeck.launcher.runtime.LinuxRuntimeInstaller +import com.steamdeck.launcher.session.SessionPrefs +import com.steamdeck.launcher.session.SessionService +import com.steamdeck.launcher.session.SessionState +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * `device.txt`: what this device is and what the session was told to do, written the moment a + * session starts so a log can be read months later without asking the reporter a single question. + * + * **Nothing identifying goes in here.** No serial number, no Android ID, no IMEI, no account name, + * no network names - only what the hardware is, what the ROM is, and which of our own settings + * were in effect. The one thing that comes close is the build fingerprint, which names the ROM and + * its build date and is the same string on every unit of that model. + */ +object DeviceReport { + private const val TAG = "DeviceReport" + + fun write(context: Context, target: File, mode: String) { + try { + target.writeText(build(context, mode)) + } catch (e: Exception) { + Log.w(TAG, "could not write $target", e) + } + } + + fun build(context: Context, mode: String): String { + val b = StringBuilder() + fun h(title: String) { + b.append('\n').append(title).append('\n').append("-".repeat(title.length)).append('\n') + } + fun k(key: String, value: Any?) { + b.append(key.padEnd(24)).append(value ?: "unknown").append('\n') + } + + b.append("SteamDeck session report\n") + b.append("========================\n") + k("Written", SimpleDateFormat("yyyy-MM-dd HH:mm:ss zzz", Locale.US).format(Date())) + k("Session mode", if (mode == SessionService.MODE_DESKTOP) "desktop (labwc/LXQt)" else "Steam client (gamescope)") + + h("App") + runCatching { + val info = context.packageManager.getPackageInfo(context.packageName, 0) + k("Version", "${info.versionName} (${info.longVersionCode})") + } + k("Package", context.packageName) + k("targetSdk", context.applicationInfo.targetSdkVersion) + k("Native lib dir", context.applicationInfo.nativeLibraryDir) + + h("Device") + k("Model", "${Build.MANUFACTURER} ${Build.MODEL}") + k("Device / product", "${Build.DEVICE} / ${Build.PRODUCT}") + k("Board / hardware", "${Build.BOARD} / ${Build.HARDWARE}") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + k("SoC", "${Build.SOC_MANUFACTURER} ${Build.SOC_MODEL}") + } + k("Android", "${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})") + k("Security patch", Build.VERSION.SECURITY_PATCH) + k("Build", Build.DISPLAY) + k("Fingerprint", Build.FINGERPRINT) + k("Kernel", System.getProperty("os.version")) + k("ABIs", Build.SUPPORTED_ABIS.joinToString(", ")) + + h("CPU and memory") + k("Cores", CpuCores.all.size) + b.append("Core ceilings ") + b.append(CpuCores.all.joinToString(", ") { c -> + "cpu$c " + (CpuCores.maxGhz(c)?.let { String.format(Locale.US, "%.2f GHz", it) } ?: "?") + }) + b.append('\n') + runCatching { + val mi = ActivityManager.MemoryInfo() + (context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(mi) + k("RAM total", FileUtils.sizeToString(mi.totalMem)) + k("RAM available", FileUtils.sizeToString(mi.availMem)) + } + runCatching { + val fs = StatFs(context.filesDir.path) + k("App storage free", FileUtils.sizeToString(fs.availableBlocksLong * fs.blockSizeLong)) + } + runCatching { + val fs = StatFs(Environment.getExternalStorageDirectory().path) + k("Shared storage free", FileUtils.sizeToString(fs.availableBlocksLong * fs.blockSizeLong)) + } + + h("GPU") + k("KGSL gpu_model", readSys("/sys/class/kgsl/kgsl-3d0/gpu_model")) + k("KGSL chip id", readSys("/sys/class/kgsl/kgsl-3d0/gpu_chipid")) + k("System Vulkan ICD", if (File("/vendor/lib64/hw/vulkan.adreno.so").exists()) "/vendor/lib64/hw/vulkan.adreno.so" else "not at the usual path") + + h("Display") + k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" }) + k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz)) + k("Shape setting", SessionPrefs.shapeMode(context)) + k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle")) + + h("Drivers") + val turnip = TurnipDriver(context) + val androidChoice = SessionPrefs.androidDriver(context) + k("Display driver (chosen)", if (androidChoice.isEmpty()) "Auto -> ${turnip.autoId()}" else androidChoice) + k(" name / version", "${turnip.displayName(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)} ${turnip.driverVersion(if (androidChoice.isEmpty()) turnip.autoId() else androidChoice)}".trim()) + k(" imported available", turnip.enumerateImported().ifEmpty { listOf("none") }.joinToString(", ")) + val lm = LinuxVulkanDriverManager(context) + for (m in listOf(SessionService.MODE_STEAM, SessionService.MODE_DESKTOP)) { + val id = SessionPrefs.linuxDriver(context, m) + val label = if (id.isEmpty()) "runtime default (the Turnip built into the runtime)" + else "${lm.getDriverName(id)} ${lm.getDriverVersion(id)}".trim() + + lm.getMinGlibc(id).let { if (it.isEmpty()) "" else " (glibc $it+)" } + + if (lm.isInstalled(id)) "" else " [MISSING - falls back to the runtime's own]" + k("Linux driver ($m)", label) + } + k("Runtime's own ICD", LinuxRuntime.vulkanIcd(context)?.path) + + h("Runtime") + k("Installed version", LinuxRuntimeInstaller.installedVersion(context)) + k("Runtime ready", LinuxRuntime.isInstalled(context)) + k("Desktop installed", DesktopCatalog.desktopInstalled(context)) + k("Runtime root", LinuxRuntime.rootDir(context).path) + + h("Settings in effect") + k("Client core override", SessionPrefs.clientCpusOverride(context)) + k(" client cores", CpuCores.listOrAll(SessionPrefs.clientCpus(context))) + k(" game cores", CpuCores.restrictionOrEmpty(SessionPrefs.gameCpus(context)).ifEmpty { "every core (nothing sent)" }) + k("Turnip sysmem", SessionPrefs.tuSysmem(context)) + k("Zink lazy descriptors", SessionPrefs.zinkLazy(context)) + k("Skip xalia", SessionPrefs.noXalia(context)) + k("proot without seccomp", SessionPrefs.prootNoSeccomp(context)) + k("DirectAudio for games", SessionPrefs.directAudio(context)) + k("Microphone", SessionPrefs.micEnabled(context)) + k("On-screen controls", SessionPrefs.oscMode(context)) + k("Touch mode", SessionPrefs.touchMode(context)) + k("Performance HUD", SessionPrefs.hudEnabled(context)) + + h("Android process limits") + // Android 12 kills "phantom" processes - the children an app forks itself rather than + // starting through the framework - once there are more than a handful of them. A session is + // nothing but those: proot, gamescope, Xwayland, the client, its helpers, Wine, FEX. Where + // this is left on, the OS kills the session and nothing in our logs says why, because + // nothing in the session did it. Some ROMs expose it in Developer options as a + // "restrict child processes" switch; otherwise it is + // adb shell settings put global settings_enable_monitor_phantom_procs false + val phantom = runCatching { + Settings.Global.getString(context.contentResolver, "settings_enable_monitor_phantom_procs") + }.getOrNull() + k("Phantom proc monitor", when (phantom?.lowercase()) { + "false", "0" -> "disabled (good - the OS will not kill the session's children)" + null, "" -> "not set (ROM default, usually ENABLED - see the note below)" + else -> "ENABLED (the OS may kill the session with no log; turn off \"restrict child processes\")" + }) + + h("Device switch files in Download") + for (name in listOf("steamdeck-env", "steamdeck-tu-debug", "steamdeck-driver", + "steamdeck-osc", "steamdeck-no-pad", "steamdeck-pad-log", + "steamdeck-no-hud", "steamdeck-wlr-renderer")) { + val f = File(Environment.getExternalStorageDirectory(), "Download/$name") + if (f.isFile) k(name, FileUtils.readString(f)?.trim()?.replace('\n', ' ')?.ifEmpty { "(present, empty)" } ?: "(present)") + } + + b.append("\nNothing identifying is collected here: no serial number, no device or advertising\n") + b.append("id, no account name, no network names. Safe to attach to a bug report as it is.\n") + return b.toString() + } + + private fun readSys(path: String): String? = FileUtils.readString(File(path))?.trim()?.ifEmpty { null } +} diff --git a/app/src/main/java/com/steamdeck/launcher/core/LogRedactor.kt b/app/src/main/java/com/steamdeck/launcher/core/LogRedactor.kt new file mode 100644 index 000000000..a4f774173 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/core/LogRedactor.kt @@ -0,0 +1,79 @@ +package com.steamdeck.launcher.core + +/** + * Scrubs credentials out of a log line before it is written somewhere a user will share. + * + * The Steam client's own logs are the reason this exists: they carry the account's session token + * (logged as a bare number after "Using JWT"), machine-auth GUIDs, `token=`/`sessionid=` pairs, + * Steam Guard codes, 32-hex WebAPI keys and the account's SteamID. A session folder from this app + * is meant to be attached to a bug report as it is, so none of that may be in it. + * + * Ported from Bannerlator's {@code SteamLogRedactor} (GPL-3.0), which was written against real + * client logs; the account-name registration is left out because this app never learns it. + * + * What is deliberately KEPT, because it is what makes a log worth reading: EResult codes, CM host + * names and IP addresses, timings, token expiry dates, app IDs, pids, connection-state changes and + * file paths. A SteamID is masked rather than deleted - first four and last four digits survive - + * so a reader can still tell two accounts' lines apart without the value identifying either. + */ +object LogRedactor { + private val EMAIL = Regex("[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}") + /** "Using JWT 25484942796017334" - the client logs its session token as digits, not base64. */ + private val JWT_LABELLED = Regex("(?i)(\\bJWT[\\s=:]+)(\\d{8,})") + /** A 3-part base64url JWT logged verbatim (a refresh or access token). */ + private val JWT_BASE64 = Regex("ey[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}\\.[A-Za-z0-9_\\-]{6,}") + /** Machine-auth and other GUIDs. */ + private val GUID = Regex("\\b[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\\b") + /** + * key=value secrets. A bare `key` is excluded on purpose so "key=english" survives; a real + * WebAPI key is caught by [WEBAPI_KEY]. The value class excludes `<` and `>` so running this + * twice over an already-scrubbed line changes nothing. + */ + private val SECRET_KV = Regex( + "(?i)\\b(access[_-]?token|refresh[_-]?token|auth[_-]?token|authtoken|token|authcode|" + + "auth[_-]?ticket|ticket|sessionid|steamloginsecure|webapikey|api[_-]?key|" + + "machine[_-]?auth(?:[_-]?token)?|machineauth|password|passwd|pwd|secret)" + + "(\\s*[=:]\\s*|=)([^\\s\"'<>&;,]{4,})" + ) + /** A Steam Guard code, only where the text around it says that is what it is. */ + private val GUARD_CODE = Regex( + "(?i)((?:steam\\s*)?guard\\s*code[\\s:=]*|two[\\s-]?factor[\\s:=]*|2fa[\\s:=]*)([A-Za-z0-9]{5})" + ) + /** Exactly 32 hex - a WebAPI key. Bounded so a 40-hex depot chunk id is left alone. */ + private val WEBAPI_KEY = Regex("\\b[0-9A-Fa-f]{32}\\b") + /** A long opaque run after a sensitive word, for anything the rules above missed. */ + private val RESIDUAL = Regex( + "(?i)\\b(jwt|token|ticket|sessionid|steamloginsecure|machineauth)\\b[\\s=:]*([A-Za-z0-9+/=_\\-]{12,})" + ) + private val LONG_TOKEN = Regex("[A-Za-z0-9_\\-]{88,}") + private val STEAMID64 = Regex("\\b(76561)(\\d{8})(\\d{4})\\b") + private val STEAMID3 = Regex("\\[U:1:(\\d+)]") + + /** [line] with every credential shape replaced. Null- and exception-safe by construction. */ + fun redact(line: String): String { + if (line.isEmpty()) return line + return try { + var out = line + out = GUID.replace(out, "") + out = JWT_LABELLED.replace(out) { "${it.groupValues[1]}" } + out = JWT_BASE64.replace(out, "") + out = SECRET_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}" } + out = GUARD_CODE.replace(out) { "${it.groupValues[1]}" } + out = WEBAPI_KEY.replace(out, "") + // Mask, not delete: the last four digits let a reader correlate lines to one account. + out = STEAMID64.replace(out) { "${it.groupValues[1]}********${it.groupValues[3]}" } + out = STEAMID3.replace(out) { m -> + val id = m.groupValues[1] + "[U:1:${if (id.length > 4) "*".repeat(id.length - 4) + id.takeLast(4) else id}]" + } + out = EMAIL.replace(out, "") + out = RESIDUAL.replace(out) { "${it.groupValues[1]}=" } + out = LONG_TOKEN.replace(out, "") + out + } catch (t: Throwable) { + // A log line is never worth crashing a session over, but an unscrubbed one must not + // reach the file either. + "" + } + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPaths.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPaths.kt new file mode 100644 index 000000000..e5d2490ed --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPaths.kt @@ -0,0 +1,56 @@ +package com.steamdeck.launcher.session + +import android.content.Context +import android.util.Log +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * One folder per session under `Download/SteamDeck/`, holding everything that session recorded: + * + * ``` + * Download/SteamDeck/session-20260921-161256/ + * device.txt what this device is, and every setting the session ran with + * session.log the guest session: proot, gamescope, the client's stdout + * wayland.log the app's compositor + * steam.log the Steam client's own log, scrubbed (Steam mode) + * steam/ the rest of the client's logs, scrubbed (Steam mode) + * desktop.log labwc, the panel and the programs on it (desktop mode) + * ``` + * + * The folder is claimed by whoever starts first - the activity starts the compositor before the + * service starts the session - so both write into the same one, and a recreated activity (a + * foldable opening mid-session) joins the folder in progress instead of opening another. + */ +object SessionPaths { + private const val TAG = "SessionPaths" + + @Volatile + private var dir: File? = null + + /** The folder for the session now starting, or the one already in progress. */ + @Synchronized + fun beginOrCurrent(context: Context): File { + dir?.let { if (it.isDirectory) return it } + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + val made = File(SessionFiles.logDirectory(context), "session-$stamp") + if (!made.isDirectory && !made.mkdirs()) Log.e(TAG, "could not create $made") + dir = made + Log.i(TAG, "session logs: $made") + return made + } + + /** The current session's folder, or null between sessions. */ + fun current(): File? = dir + + /** A file in the current session's folder, or null between sessions. */ + fun file(name: String): File? = dir?.let { File(it, name) } + + /** Let the next session claim a new folder. Called when a session ends. */ + @Synchronized + fun release() { + dir = null + } +} diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index ee21db875..305bafd11 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -119,6 +119,21 @@ object SessionPrefs { prefs(context).edit().putBoolean("noXalia", on).apply() } + /** + * Whether proot runs without its seccomp acceleration (PROOT_NO_SECCOMP=1). + * + * proot normally installs a seccomp filter so only the syscalls it must rewrite stop in the + * tracer; everything else runs untraced, which is most of proot's speed. Where a vendor kernel + * handles that filter badly the wrong calls are trapped or refused - ENOSYS from calls that + * plainly exist is the signature - and the fallback is to trace everything instead: slower, + * but correct. Max's advice for devices whose kernels "don't work well with it". + */ + fun prootNoSeccomp(context: Context): Boolean = prefs(context).getBoolean("prootNoSeccomp", false) + + fun setProotNoSeccomp(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("prootNoSeccomp", on).apply() + } + /** Turnip's sysmem rendering (TU_DEBUG=sysmem) for the runtime's driver: bypasses GMEM tiling. */ fun tuSysmem(context: Context): Boolean = prefs(context).getBoolean("tuSysmem", false) diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 0b54ab9c7..23cd786aa 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -22,13 +22,16 @@ import com.steamdeck.launcher.SessionActivity import com.steamdeck.launcher.audio.DirectAudioRelayComponent import com.steamdeck.launcher.audio.PulseAudioComponent import com.steamdeck.launcher.core.CpuCores +import com.steamdeck.launcher.core.DeviceReport import com.steamdeck.launcher.core.EnvVars +import com.steamdeck.launcher.core.LogRedactor import com.steamdeck.launcher.core.EnvironmentComponent import com.steamdeck.launcher.core.FileUtils import com.steamdeck.launcher.core.ProcessHelper import com.steamdeck.launcher.input.FakeInputWriter import com.steamdeck.launcher.runtime.LinuxNetworkLinkComponent import com.steamdeck.launcher.runtime.LinuxRuntime +import com.steamdeck.launcher.wayland.WaylandCompositor import java.io.File import java.text.SimpleDateFormat import java.util.Date @@ -75,6 +78,44 @@ class SessionService : Service() { return START_NOT_STICKY } + /** + * Pull everything a report needs into the session's folder, after the session has stopped: the + * compositor's log (it writes for as long as the compositor lives, which outlasts one session) + * and the Steam client's own logs, scrubbed line by line. The guest script copies Steam's logs + * too, at a clean exit; this one runs whatever killed the session, which is when they matter. + */ + private fun collectSessionArtifacts() { + val dir = SessionPaths.current() ?: return + try { + // The compositor's own file, wherever it opened it. + val wayland = File(dir, "wayland.log") + if (!wayland.exists()) { + WaylandCompositor.currentLogFile()?.takeIf { it.isFile }?.let { src -> + src.copyTo(wayland, overwrite = true) + } + } + // Steam's logs: redacted into steam/, never copied verbatim. + val logs = File(LinuxRuntime.rootDir(this), "root/.local/share/Steam/logs") + if (logs.isDirectory) { + val out = File(dir, "steam").apply { mkdirs() } + logs.listFiles { f -> f.isFile && f.length() < 8L * 1024 * 1024 }?.forEach { src -> + try { + File(out, src.name).bufferedWriter().use { w -> + src.forEachLine { line -> w.write(LogRedactor.redact(line)); w.newLine() } + } + } catch (e: Exception) { + Log.w(TAG, "could not scrub ${src.name}", e) + } + } + Log.i(TAG, "collected ${out.listFiles()?.size ?: 0} Steam log(s), scrubbed, into $out") + } + } catch (e: Exception) { + Log.w(TAG, "collecting session artifacts", e) + } finally { + SessionPaths.release() + } + } + private fun extraEnv(): List { val file = File(Environment.getExternalStorageDirectory(), ENV_SWITCH).takeIf { it.isFile } ?: return emptyList() val lines = FileUtils.readString(file)?.lines().orEmpty() @@ -111,10 +152,13 @@ class SessionService : Service() { killStragglers() SessionFiles.stage(this, root) - val logDir = SessionFiles.logDirectory(this) - val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) - val sessionLog = File(logDir, "session-$stamp.log") + // One folder per session, claimed by whoever started first - the activity starts the + // compositor before this service runs - so the compositor's log lands in the same place. + val sessionDir = SessionPaths.beginOrCurrent(this) + val sessionLog = File(sessionDir, "session.log") SessionState.logFile = sessionLog + // Written first, so a session that dies in its first second still says what it ran on. + DeviceReport.write(this, File(sessionDir, "device.txt"), SessionState.mode) val size = SessionState.outputSize val guest = ArrayList() @@ -211,7 +255,7 @@ class SessionService : Service() { guest.add("BL_FPS=0") guest.add("BL_REFRESH=" + Math.round(SessionState.refreshHz)) guest.add("BL_LOG=" + sessionLog.path) - guest.add("BL_DEBUG_DIR=" + File(logDir, "session-$stamp").path) + guest.add("BL_DEBUG_DIR=" + sessionDir.path) val fakeInputDir = File(sessionRoot, "dev/input").apply { mkdirs() } val controllersOn = !File(Environment.getExternalStorageDirectory(), NO_PAD_SWITCH).exists() @@ -264,6 +308,11 @@ class SessionService : Service() { // proot links against a libtalloc beside it, and Android's linker does not search an // executable's own directory: unnamed, the process dies before it starts and says so only // in `logcat -b crash`. + // proot reads this itself, so it belongs in proot's own environment rather than the guest's. + if (SessionPrefs.prootNoSeccomp(this)) { + hostEnv.put("PROOT_NO_SECCOMP", "1") + Log.i(TAG, "proot: seccomp acceleration off by request") + } val prootLibs = LinuxRuntime.prootLibraryPath(this) if (prootLibs.isNotEmpty()) hostEnv.put("LD_LIBRARY_PATH", prootLibs) @@ -385,6 +434,7 @@ class SessionService : Service() { sessionPid = -1 Thread({ teardown(prootPid) }, "session-teardown").start() } + collectSessionArtifacts() components.reversed().forEach { try { it.stop() diff --git a/app/src/main/java/com/steamdeck/launcher/ui/PerformanceDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/PerformanceDialog.kt index ced51d65e..56b670ee4 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/PerformanceDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/PerformanceDialog.kt @@ -37,10 +37,13 @@ fun PerformanceDialog( tuSysmem: Boolean, zinkLazy: Boolean, noXalia: Boolean, + prootNoSeccomp: Boolean, + phantomWarning: String?, onClientOverride: (Boolean) -> Unit, onTuSysmem: (Boolean) -> Unit, onZinkLazy: (Boolean) -> Unit, onNoXalia: (Boolean) -> Unit, + onProotNoSeccomp: (Boolean) -> Unit, onClientCore: (Int, Boolean) -> Unit, onGameCore: (Int, Boolean) -> Unit, onDismiss: () -> Unit, @@ -123,6 +126,26 @@ fun PerformanceDialog( } Switch(checked = noXalia, onCheckedChange = onNoXalia) } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text("Run proot without seccomp", style = MaterialTheme.typography.bodyMedium) + Text( + "proot normally lets most system calls run untraced, which is most of its speed. Some kernels handle that badly and refuse calls that plainly exist — "Function not implemented" in the log where it makes no sense. This traces everything instead: slower, but correct. Try it if a session will not start or dies with those errors.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch(checked = prootNoSeccomp, onCheckedChange = onProotNoSeccomp) + } + if (phantomWarning != null) { + Spacer(Modifier.height(10.dp)) + Text("Android is set to kill this session", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.error) + Text( + phantomWarning, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } }, confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } }, diff --git a/app/src/main/java/com/steamdeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/steamdeck/launcher/wayland/WaylandCompositor.java index d4e77cc6f..75053fc6c 100644 --- a/app/src/main/java/com/steamdeck/launcher/wayland/WaylandCompositor.java +++ b/app/src/main/java/com/steamdeck/launcher/wayland/WaylandCompositor.java @@ -227,6 +227,18 @@ public final class WaylandCompositor { * the session log. A no-op on Android versions without {@code ASurfaceTransaction_setFrameRate}. */ public static native void nativeSetLayerFrameRate(float hz); + /** Where the compositor was told to write its log, for the session collector; null if it was + * left to choose. Set before the compositor starts - it opens the file once, at start. */ + private static volatile java.io.File sessionLogFile; + + public static void setSessionLogFile(java.io.File file) { + sessionLogFile = file; + } + + public static java.io.File currentLogFile() { + return sessionLogFile; + } + /** Write one line into the compositor's session log (Download/Wayland-logs) under the "display" * area, from Java. Used for facts the platform knows and the native side does not — the panel's * HDR capability, which lives behind android.view.Display. Safe before the compositor thread is diff --git a/tools/linuxfs/desktop/steamdeck-desktop b/tools/linuxfs/desktop/steamdeck-desktop index be0f8bbe5..e420d27ff 100644 --- a/tools/linuxfs/desktop/steamdeck-desktop +++ b/tools/linuxfs/desktop/steamdeck-desktop @@ -17,6 +17,13 @@ export MOZ_DISABLE_SOCKET_PROCESS_SANDBOX=1 MOZ_DISABLE_UTILITY_SANDBOX=1 # wlroots allocates through gbm on a real DRM node, which the KGSL stand-in is not: pixman # (software, shm) is what boots here. BL_WLR_RENDERER=vulkan/gles2 tries acceleration. export WLR_RENDERER=${BL_WLR_RENDERER:-pixman} +# The desktop's own log, beside the session's, so labwc and the programs on it can be read apart +# from the session that started them. The session log keeps the milestones either way. +if [ -n "${BL_DEBUG_DIR:-}" ]; then + mkdir -p "$BL_DEBUG_DIR" 2>/dev/null || true + exec >>"$BL_DEBUG_DIR/desktop.log" 2>&1 + echo "== steamdeck-desktop $(date -u '+%Y-%m-%d %H:%M:%S UTC') renderer=$WLR_RENDERER" +fi export WLR_BACKENDS=wayland mkdir -p "$HOME/.config/labwc" "$HOME/.config/lxqt" # Our defaults for labwc and the panel, refreshed every session. What a user changes in the diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 59cf5b3b6..07f90b6c0 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -31,12 +31,18 @@ fi collect_steam_logs() { [ -n "${BL_DEBUG_DIR:-}" ] || return 0 local steam_root=${XDG_DATA_HOME:-$HOME/.local/share}/Steam - mkdir -p "$BL_DEBUG_DIR" 2>/dev/null || return 0 - [ -d "$steam_root/logs" ] && cp -r "$steam_root/logs" "$BL_DEBUG_DIR/steam-logs" 2>/dev/null - [ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/" 2>/dev/null - [ -f "$steam_root/steam.pid" ] && cp "$steam_root/steam.pid" "$BL_DEBUG_DIR/" 2>/dev/null - echo "== steam logs copied to $BL_DEBUG_DIR" + mkdir -p "$BL_DEBUG_DIR/steam" 2>/dev/null || return 0 + # Into steam/ beside this session's own logs. The app scrubs these line by line after the + # session ends - they carry the account's session token and id - so nothing here is the last + # word on what the user ends up sharing; this is just the copy that survives the runtime. + [ -d "$steam_root/logs" ] && cp -r "$steam_root/logs/." "$BL_DEBUG_DIR/steam/" 2>/dev/null + [ -f "$HOME/.steam/steam/logs/steamwebhelper.log" ] && cp "$HOME/.steam/steam/logs/steamwebhelper.log" "$BL_DEBUG_DIR/steam/" 2>/dev/null + # NOT copied, ever: loginusers.vdf, config.vdf and the ssfn* files, which hold the credentials + # themselves rather than a mention of them. + echo "== steam logs copied to $BL_DEBUG_DIR/steam" } +# A session that is killed never reaches the copy below, and those are the sessions worth reading. +trap 'collect_steam_logs 2>/dev/null || true' TERM INT HUP # The driver everything in this session draws with: gamescope's compositing, the client's UI # (OpenGL through Zink), every game Proton launches, and in desktop mode every program on it. # BL_VK_DRIVER names an imported driver's ICD manifest on the Android side, which is bound into the