From aa047ce942d253ec93e5d0eded148307e09d3996 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Mon, 21 Sep 2026 12:56:25 -0400 Subject: [PATCH] Logs: one folder per session, a device report, and credentials scrubbed A session used to leave a loose session-.log in Download/SteamDeck and a compositor log in a different folder entirely, with Steam's own logs copied beside the first one only if the session exited cleanly - which the sessions worth reading never do. Reports arrived needing three questions answered before anything could be read: what device, which drivers, which settings. Now each session claims Download/SteamDeck/session-/ and everything lands in it: device.txt, session.log, wayland.log, steam.log plus steam/ in Steam mode, desktop.log in desktop mode. The folder is claimed by whoever starts first, since the activity starts the compositor before the service starts the session, and the compositor is told where to write through its environment - it opens that file once, so the service also copies it in at teardown, which covers the second and later sessions of one process. 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, kernel, cores and ceilings, RAM, the GPU as KGSL names it, the panel, the driver each half of the session chose, the runtime version, every switch in effect, and whether Android is set to kill the session's child processes. Nothing identifying is collected - no serial, no device or advertising id, no account name, no network names - and Steam's logs are scrubbed line by line on the way in, with Bannerlator's patterns: session tokens (including the bare numeric "Using JWT" form), machine-auth GUIDs, WebAPI keys, Guard codes, emails, and SteamIDs masked to their last four digits so lines still correlate. loginusers.vdf, config.vdf and the ssfn files are never copied. Also from Max: a switch to run proot without its seccomp acceleration (PROOT_NO_SECCOMP=1), for kernels that handle that filter badly - ENOSYS from calls that plainly exist is the signature, which is what the Fold's storm looks like; and the phantom-process warning above, which is his "your OS killed it". --- README.md | 39 +++- app/src/main/cpp/waylandcomp/src/compositor.c | 15 +- .../com/steamdeck/launcher/MainActivity.kt | 16 ++ .../com/steamdeck/launcher/SessionActivity.kt | 13 ++ .../steamdeck/launcher/core/DeviceReport.kt | 182 ++++++++++++++++++ .../steamdeck/launcher/core/LogRedactor.kt | 79 ++++++++ .../launcher/session/SessionPaths.kt | 56 ++++++ .../launcher/session/SessionPrefs.kt | 15 ++ .../launcher/session/SessionService.kt | 58 +++++- .../launcher/ui/PerformanceDialog.kt | 23 +++ .../launcher/wayland/WaylandCompositor.java | 12 ++ tools/linuxfs/desktop/steamdeck-desktop | 7 + .../overlay/usr/local/bin/bannerlator-session | 16 +- 13 files changed, 516 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt create mode 100644 app/src/main/java/com/steamdeck/launcher/core/LogRedactor.kt create mode 100644 app/src/main/java/com/steamdeck/launcher/session/SessionPaths.kt 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