Compare commits

...
Author SHA1 Message Date
The412Banner ec2f6e1461 Logs: fix the scrub marker's return; LAN addresses out of the zip
markScrubbed returns its try/catch result. The zip pass replaces private
IPv4 addresses (10/8, 172.16/12, 192.168/16, 169.254/16) with
<lan-address>; public server addresses stay.
2026-10-09 13:00:30 -04:00
The412Banner facb4ce74c Logs: scrub every older folder in one start, a few at a time
On device, 27 of 31 migrated folders still had .scrubbed-2 a minute after
start: the pass was still running, ~30 s per folder (some 30 MB of Steam
logs each), one folder after another. The start pass now runs folders in
parallel (half the cores, at most 4, background priority) and logs
"logs: scrubbed N folders under r3". Migration strips the moved folders'
markers so that same pass takes them; old markers go whether or not a
folder's pass succeeds, and an unfinished folder is taken up next start.
2026-10-09 12:56:03 -04:00
The412Banner 79a40d93b3 Logs: scrub Steam account names everywhere, keep Proton exception codes
Older session folders kept the Steam login account in steam/webhelper_js.txt
(OnLoginStateChange <account>): their scrub marker predated the account rules
and never expired. The marker now carries the rules version, so folders
scrubbed under older rules are scrubbed again at app start (every subfolder),
and folders moved out of Download/ are scrubbed after the move.

New rules: OnLoginStateChange <anything>, AccountName / account_name /
username / login fields. A bare code= is blanked only in a query string or
beside OAuth keys, never as an 8-hex exception code.
2026-10-09 12:38:42 -04:00
The412Banner 8d94f0c948 Logs: one set of scrubbing rules for the stores' log and the shared zip
StoreLog.redactLine and the zip's last pass each carried their own URL
and token rules. Both now use SecretScrub: URLs lose query, fragment and
user:password - the stores' lines keep only the host and a plain first
segment, the zip keeps paths with token-bearing segments blanked - and
token values and Authorization / Cookie headers are blanked everywhere.
2026-10-09 12:38:42 -04:00
The412Banner 56bc2cd3f7 Logs: every log in app-private storage, shared as one scrubbed zip
Session folders, tools/ and the stores' log now live in files/logs
(LinuxRuntime.logDir) instead of Download/DroidDeck. The files directory
is bound into every session at the same path, so the session script and
the guest tools write there as before. Thirty sessions kept; the Logs
switch off still means a cache folder deleted at session end. Game save
backups stay public.

On the first start, session folders, tools/ and loose .log files move
from Download/DroidDeck into files/logs (rename, else copy and delete; a
failure leaves the item where it was). Saves/ and anything else there is
never touched, and the folder is not read again.

Share logs builds one zip in cache/share/: the session folder, with the
last seven days of stores/ and tools/. Every text file goes through
LogRedactor.redactForShare on the way in - the existing rules, plus URLs
without query, fragment or userinfo and with token-bearing path segments
blanked, __token__ / f_token / hdnts / OAuth code= values, and
Authorization / Cookie headers - so a file written before a rule existed
is clean in the zip. The zip goes at the next share or app start. No
existing scrub is relaxed. The file manager's Session logs shortcut opens
files/logs.
2026-10-09 12:38:42 -04:00
24 changed files with 598 additions and 121 deletions

No files matched your search

+2 -2
View File
@@ -21,7 +21,7 @@ Before Steam launches, you must turn off **Restrict child processes** in Develop
## Community
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the session folder from `Download/DroidDeck/` so the logs come with it.
Join the [DroidDeck Discord](https://discord.gg/JRGAvawjsm) for help, Preview builds, and device reports. Bug reports go in its **#bug-reports** forum; attach the zip from **Share logs** (Setup, or the session drawer) so the logs come with it.
## Build
@@ -29,7 +29,7 @@ Run `tools/build_local.sh` with Docker, Java 17, the Android SDK/NDK, and `zstd`
## Limits
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. See the session logs in `Download/DroidDeck/` when diagnosing problems.
Compatibility and performance vary by device; hardware validation is limited. Desktop compositing uses software rendering. Firefox sandboxing is reduced under proot. Logs are kept app-private (`files/logs/`, the last 30 sessions); **Share logs** packs the newest session, with the stores and tool logs, into one scrubbed zip.
## Credits and licence
@@ -868,6 +868,8 @@ class MainActivity : ComponentActivity() {
if (wanted.isNotEmpty()) requestPermissions(wanted.toTypedArray(), 1)
// A session folder left without its ending - the process was killed - gets it now.
if (!SessionState.running) Thread({
com.droiddeck.launcher.session.LogMigration.run(this)
SessionLogShare.clear(this)
SessionArtifacts.finishAbandoned(this)
SessionArtifacts.scrubOlder(this)
SessionArtifacts.prune(this)
@@ -1,5 +1,6 @@
package com.droiddeck.launcher.agent
import com.droiddeck.launcher.session.SessionPaths
import android.Manifest
import android.content.ContentProvider
import android.content.ContentValues
@@ -107,7 +108,7 @@ class AgentBridgeProvider : ContentProvider() {
private fun state(context: Context): JSONObject {
val runtimeVersion = LinuxRuntimeInstaller.installedVersion(context)
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: latestSessionDirectory()
val dir = SessionState.logDirectory ?: SessionState.logFile?.parentFile ?: SessionPaths.sessionFolders(context).lastOrNull()
val session = JSONObject()
.put("id", SessionState.sessionId ?: dir?.name ?: JSONObject.NULL)
.put("phase", SessionState.phase.name)
@@ -163,12 +164,6 @@ class AgentBridgeProvider : ContentProvider() {
.put("session", session)
}
private fun latestSessionDirectory(): File? {
val parent = LinuxRuntime.debugLogDir()
return parent.listFiles { file -> file.isDirectory && file.name.startsWith("session-") }
?.maxWithOrNull(compareBy<File> { it.lastModified() }.thenBy { it.name })
}
private fun stop(context: Context): JSONObject {
if (SessionState.running) {
SessionService.stop(context)
@@ -75,9 +75,24 @@ object LogRedactor {
* user who signs in with a plain account name, not an email, had it in every log. The single
* space then a non-space keeps "OnLoginStateChange 0 1 0 0" (no account yet) as it is.
*/
private val LOGIN_STATE = Regex("(OnLoginStateChange )(\\S+)")
private val LOGIN_STATE = Regex("(?i)(OnLoginStateChange:? )([^\\s<]\\S*)")
private val LOGIN_USERS = Regex("(OnLoginUsersChanged )(\\S.*)$")
/**
* An account name as a field: `AccountName`, `account_name`, `username` (`"AccountName"
* "someone"` in a VDF, `account_name=...`, `"username": "..."`) and `login` - the last only
* as `login=`, a quoted key or a VDF pair, so "Login: OnLoginStateChange ..." keeps its label.
* A quoted value is blanked whole; one already blanked is left as it is.
*/
private const val ACCOUNT_VALUE = "(\"[^\"<\\r\\n]+\"|'[^'<\\r\\n]+'|[^\\s\"'<>&;,}]+)"
private val ACCOUNT_KV = Regex(
"(?i)(?<![A-Za-z0-9_])(account[_-]?name|user[_-]?name|epicusername)" +
"([\"']?\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
)
private val LOGIN_KV = Regex(
"(?i)(?<![A-Za-z0-9_])(login)(\\s*=\\s*|[\"']\\s*[=:]\\s*|[\"']\\s+)" + ACCOUNT_VALUE
)
/** The device's Steam accounts and persona names as patterns (see [learnAccounts]). */
@Volatile
private var accounts: List<Regex> = emptyList()
@@ -205,13 +220,49 @@ object LogRedactor {
* only when that matches, so a folder scrubbed under older rules is scrubbed again on the way
* out.
*/
const val RULES_VERSION = 2
const val RULES_VERSION = 3
/**
* A private IPv4 address (10/8, 172.16/12, 192.168/16, link-local 169.254/16): the home
* network's layout, e.g. its router as the resolver. Blanked in a shared zip only; public
* server addresses stay. Not part of a longer dotted number (a version like 10.0.19041.1).
*/
private val LAN_IPV4 = Regex(
"(?<![\\d.])(?:10\\.\\d{1,3}|192\\.168|172\\.(?:1[6-9]|2\\d|3[01])|169\\.254)\\.\\d{1,3}\\.\\d{1,3}(?!\\.?\\d)"
)
/** [src]'s lines, scrubbed, to [out]. */
fun scrubTo(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redact(line)); out.write("\n") }
}
/**
* The last pass for a file going into a shared zip: [redact], then the rules the stores' log
* uses too ([SecretScrub]) - URLs without query, fragment and user:password and with any
* token-bearing path segment blanked, token values and Authorization / Cookie headers blanked
* - and private IPv4 addresses as `<lan-address>`. A line already clean comes out unchanged.
*/
fun redactForShare(line: String): String {
if (line.isEmpty()) return line
return try {
val out = SecretScrub.scrub(redact(line), SecretScrub.Urls.KEEP_PATH, "<redacted:token>")
LAN_IPV4.replace(out, "<lan-address>")
} catch (t: Throwable) {
"<redaction failed; line withheld>"
}
}
/** [src]'s lines through [redactForShare], to [out]: the pass every text file in a shared zip gets. */
fun scrubForShare(src: java.io.File, out: java.io.Writer) {
src.forEachLine { line -> out.write(redactForShare(line)); out.write("\n") }
}
/** `<redacted:account>`, inside [value]'s quotes when it had them. */
private fun quoted(value: String): String {
val q = value.first().takeIf { it == '"' || it == '\'' }?.toString().orEmpty()
return "$q<redacted:account>$q"
}
/** [line] with every credential shape replaced. Null- and exception-safe by construction. */
fun redact(line: String): String {
if (line.isEmpty()) return line
@@ -240,6 +291,8 @@ object LogRedactor {
for (r in own) out = r.replace(out, "<redacted:ip>")
out = LOGIN_STATE.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = LOGIN_USERS.replace(out) { "${it.groupValues[1]}<redacted:account>" }
out = ACCOUNT_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
out = LOGIN_KV.replace(out) { "${it.groupValues[1]}${it.groupValues[2]}${quoted(it.groupValues[3])}" }
for (r in accounts) out = r.replace(out, "<redacted:account>")
out = EMAIL.replace(out, "<redacted:email>")
out = RESIDUAL.replace(out) { "${it.groupValues[1]}=<redacted:token>" }
@@ -0,0 +1,72 @@
package com.droiddeck.launcher.core
/**
* The one set of rules that keeps URLs and tokens out of anything the app writes or shares: the
* stores' log ([com.droiddeck.launcher.stores.StoreLog.redactLine]) and the last pass over every
* text file in a shared log zip ([LogRedactor.redactForShare]).
*
* - Every URL loses its query, fragment and `user:password@`. [Urls.SHORT] then keeps only the host
* and a plain first path segment (the stores' lines, where a GOG secure link carries its token in
* the path); [Urls.KEEP_PATH] keeps the path but blanks any segment that carries a token.
* - Outside URLs, the values of token-like keys (`token`, `__token__`, `f_token`, `hdnts`, access /
* refresh / id tokens; exchange and authorization codes of four characters or more, and a bare
* `code=` where it is one: in a query string or on a line with OAuth keys such as `client_id`,
* `redirect_uri` or `state`, never an 8-hex exception code like Proton's `code=c0000005`) and of
* Authorization / Cookie headers are blanked with [mark].
*
* A line already scrubbed comes out unchanged.
*/
object SecretScrub {
enum class Urls { SHORT, KEEP_PATH }
private val URL = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
private val TOKEN_SEGMENT = Regex("""(?i)(token|hdnts|hmac|signature|sig|exp)=""")
private val TOKEN = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'<>}]+""")
/** Codes that are only ever OAuth grants. */
private val GRANT_CODE = Regex("""(?i)(?<![A-Za-z0-9_])(exchange_code|authorization_?code)(["']?\s*=\s*["']?)[^\s&;,"'<>}]{4,}""")
/** A bare `code=`: a grant only right after `?` / `&` (a query string) or beside OAuth keys ([OAUTH_CONTEXT]). */
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code)(["']?\s*=\s*["']?)([^\s&;,"'<>}]{4,})""")
private val OAUTH_CONTEXT = Regex("""(?i)(?<![A-Za-z0-9_])(client_id|client_secret|redirect_uri|state|grant_type|response_type|access_token|refresh_token|id_token)["']?\s*[=:]""")
/** A Windows exception or status code (`code=c0000005`, `code=0x80000003`): never a grant. */
private val STATUS_CODE = Regex("""(?i)(0x)?[0-9a-f]{8}""")
private val HEADER = Regex("""(?i)\b(authorization|cookie|set-cookie)(\s*[:=]\s*).+""")
fun scrub(text: String, urls: Urls, mark: String): String {
var out = URL.replace(text) { m -> if (urls == Urls.SHORT) short(m.value) else keepPath(m.value, mark) }
out = TOKEN.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
out = GRANT_CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
val oauth = OAUTH_CONTEXT.containsMatchIn(text)
val src = out
out = CODE.replace(src) { m ->
val inQuery = m.range.first > 0 && src[m.range.first - 1] in "?&"
val grant = !STATUS_CODE.matches(m.groupValues[3]) && (oauth || inQuery)
if (grant) m.groupValues[1] + m.groupValues[2] + mark else m.value
}
out = HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + mark }
return out
}
private fun split(url: String): Triple<String, String, String?> {
val schemeEnd = url.indexOf("://")
val rest = url.substring(schemeEnd + 3).substringBefore('#').substringBefore('?')
val slash = rest.indexOf('/')
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
return Triple(url.substring(0, schemeEnd), authority, if (slash >= 0) rest.substring(slash + 1) else null)
}
private fun short(url: String): String {
val (scheme, authority, path) = split(url)
if (path == null) return "$scheme://$authority"
val first = path.substringBefore('/')
if (first.isEmpty()) return "$scheme://$authority/"
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
return "$scheme://$authority/$shown" + if (path.length > first.length && shown != "…") "/…" else ""
}
private fun keepPath(url: String, mark: String): String {
val (scheme, authority, path) = split(url)
val clean = path?.split('/')?.joinToString("/") { seg -> if (TOKEN_SEGMENT.containsMatchIn(seg)) mark else seg }
return "$scheme://$authority" + (clean?.let { "/$it" } ?: "")
}
}
@@ -1020,7 +1020,8 @@ fun FileManagerScreen(
// The ROMs folder chosen on the main screen: what the session shows as /root/ROMs.
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let(::File)?.takeIf { it.isDirectory }
?.let { add(locItem(stringResource(R.string.fm_rail_roms), Icons.Filled.SportsEsports, it)) }
File("/storage/emulated/0/Download/DroidDeck").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
// The app's own logs folder: private, which this file manager (the app itself) can read.
com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_session_logs), Icons.Filled.Description, it)) }
File("/storage/emulated/0/Pictures").takeIf { it.isDirectory }?.let { add(locItem(stringResource(R.string.fm_rail_pictures), Icons.Filled.Image, it)) }
}
val favItems = remember(favTick) { FavoritesStore.list(context).map(::File).filter { it.exists() } }
@@ -87,7 +87,7 @@ object FlatpakManager {
?.maxByOrNull { it.first }?.second
}
/** Runs [argv] in the runtime, its output also in Download/DroidDeck/flatpak-<verb>.log. */
/** Runs [argv] in the runtime, its output also in files/logs/tools/flatpak-<verb>.log. */
private fun runGuest(context: Context, argv: List<String>, fakeRoot: Boolean, onLine: (String) -> Unit): Int {
val name = "flatpak-" + (argv.getOrNull(2)?.takeIf { argv.getOrNull(1) == HELPER } ?: "setup")
return GuestCommand.run(context, argv, fakeRoot, name, onLine = onLine)
@@ -17,7 +17,7 @@ object GuestCommand {
/**
* Runs [argv] and hands each output line to [onLine]; returns the exit status. [fakeRoot] is
* for the package tools, which refuse any uid but 0. With [logName], everything the command
* says also goes to Download/DroidDeck/tools/<logName>.log beside the session logs, so a problem can
* says also goes to files/logs/tools/<logName>.log beside the session logs, so a problem can
* be handed over like a session's. With [linkDir], hard links (which Android denies apps)
* become symlinks to files proot keeps there (its link2symlink).
*/
@@ -25,7 +25,7 @@ object GuestCommand {
linkDir: File? = null, onLine: (String) -> Unit): Int {
val log = logName?.let {
try {
File(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
File(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR).apply { mkdirs() }, "$it.log").printWriter()
} catch (e: Exception) { null }
}
log?.println("== ${java.util.Date()} ${argv.joinToString(" ")}")
@@ -44,10 +44,20 @@ public final class LinuxRuntime {
public static final String GUEST_RUNTIME_DIR = "/run/droiddeck";
private static final String SYSTEM_FONTS = "/system/fonts";
private static final String GUEST_SYSTEM_FONTS = "/usr/local/share/fonts/android";
/** Where every Linux session's debug log lands: public, so a user can just hand the folder over. */
/** The folder under Downloads where builds before private logs wrote them; read only to move them. */
public static final String DEBUG_LOG_DIR = "DroidDeck";
public static File debugLogDir() {
/**
* Where every log lands: app-private, {@code files/logs}. Session folders, tools/ (one-off
* commands) and stores/ sit under it; the files directory is bound into every session at the
* same path, so the guest writes there too. Share logs is how a folder leaves the device.
*/
public static File logDir(Context context) {
return new File(context.getFilesDir(), "logs");
}
/** {@code Download/DroidDeck}, where earlier builds wrote logs. Only LogMigration reads it. */
public static File legacyLogDir() {
return new File(android.os.Environment.getExternalStoragePublicDirectory(
android.os.Environment.DIRECTORY_DOWNLOADS), DEBUG_LOG_DIR);
}
@@ -0,0 +1,73 @@
package com.droiddeck.launcher.session
import android.content.Context
import android.util.Log
import com.droiddeck.launcher.core.FileUtils
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
/**
* Once, on the first start of a build with private logs: the session folders, tools/ and the loose
* tool logs that earlier builds left in `Download/DroidDeck/` move into `files/logs/`. Best effort:
* an item that cannot be moved stays where it is. Moved session folders lose their old scrub markers
* ([SessionArtifacts.unmarkMoved]), so [SessionArtifacts.scrubOlder] scrubs them under the current rules. Nothing else in that folder is touched - game
* save backups (Saves/) stay public - and after this pass the app never reads it again.
*/
object LogMigration {
private const val TAG = "LogMigration"
private const val MARKER = ".moved-from-downloads"
@Synchronized
fun run(context: Context) {
val target = LinuxRuntime.logDir(context).apply { mkdirs() }
val marker = File(target, MARKER)
if (marker.exists()) return
val legacy = LinuxRuntime.legacyLogDir()
val items = if (legacy.isDirectory) legacy.listFiles() else emptyArray()
// Unreadable (no storage access yet): try again next start rather than give up on them.
if (items == null) { Log.w(TAG, "$legacy cannot be listed; logs there stay until it can"); return }
var moved = 0
val movedFolders = ArrayList<File>()
for (f in items) {
val isLog = SessionPaths.isSessionFolder(f) ||
(f.isDirectory && f.name == SessionPaths.TOOLS_DIR) ||
(f.isFile && f.name.endsWith(".log"))
if (!isLog) continue
val dest = File(if (f.isFile) File(target, SessionPaths.TOOLS_DIR).apply { mkdirs() } else target, f.name)
if (dest.exists() && f.name != SessionPaths.TOOLS_DIR) continue
if (move(f, dest)) {
moved++
if (SessionPaths.isSessionFolder(dest)) movedFolders.add(dest)
} else Log.w(TAG, "could not move ${f.name}; left in place")
}
// Earlier builds scrubbed these under older rules (or not at all) and marked them done:
// without their markers, the pass over older folders that follows at the same start puts
// them through the current redactor.
SessionArtifacts.unmarkMoved(movedFolders)
try { marker.writeText("moved $moved item(s)\n") } catch (e: Exception) { Log.w(TAG, "could not mark the move: ${e.message}") }
if (moved > 0) Log.i(TAG, "moved $moved log item(s) from $legacy into $target")
}
/** A rename where it works (same volume), else a copy then the source's removal; false = left as it was. */
private fun move(src: File, dest: File): Boolean {
if (src.renameTo(dest)) return true
return try {
copy(src, dest)
FileUtils.delete(src)
true
} catch (e: Exception) {
Log.w(TAG, "copy of ${src.name} failed: ${e.message}")
false
}
}
private fun copy(src: File, dest: File) {
if (src.isDirectory) {
dest.mkdirs()
src.listFiles()?.forEach { copy(it, File(dest, it.name)) }
} else {
dest.parentFile?.mkdirs()
src.inputStream().use { input -> dest.outputStream().use { input.copyTo(it) } }
}
}
}
@@ -31,16 +31,20 @@ object SessionArtifacts {
/** Written last; a folder without it did not get its ending. */
const val COMPLETE_MARKER = ".complete"
/** Every file in the folder has been through the redactor, own addresses and accounts included (-2: accounts added). */
private const val SCRUBBED_MARKER = ".scrubbed-2"
/**
* Every file in the folder has been through the redactor under its current rules. Named after
* [LogRedactor.RULES_VERSION], so a folder scrubbed under older rules is scrubbed again.
*/
private val SCRUBBED_MARKER = ".scrubbed-r${LogRedactor.RULES_VERSION}"
private val OLD_SCRUBBED_MARKER = Regex("""\.scrubbed-(r?\d+)""")
/**
* Written by a session's own ending, and only when every file in the folder, subfolders
* included (whatever the session script copied into steam/ and droiddeck-esync/ as well as what
* the app wrote), went through the redactor without a failure. It lists each of those files
* with its size and modification time, under the redactor's rules version: a share copies a
* file as it is only when all three still match ([scrubbedFiles]), and scrubs anything else -
* a file added or changed since, a hidden one, or every file of a folder without it.
* with its size and modification time, under the redactor's rules version ([scrubbedFiles]).
* A share no longer relies on it: every text file goes through the share pass on the way into
* the zip regardless.
*/
const val SCRUBBED_TREE_MARKER = ".scrubbed-tree"
@@ -67,24 +71,48 @@ object SessionArtifacts {
}
/**
* Once, for session folders written before every file was scrubbed and before the device's own
* addresses were (network.txt listed them; the client's IPv6 check logs "external address"
* into steam/connection_log.txt): the whole folder, steam/ included, through the redactor.
* A marker records it. Runs at app start with [finishAbandoned].
* Every session folder not yet scrubbed under the redactor's current rules - written before
* every file was scrubbed, before the device's own addresses or the Steam account were, or
* moved in from Download/ ([LogMigration] strips their markers first, [unmarkMoved]): the
* whole folder, steam/ and every other subfolder included, through the redactor, and the
* current marker written in place of the old ones. Runs at app start with [finishAbandoned].
*
* A folder holds some 30 MB of Steam logs, about half a minute's work, so folders go through
* a few at a time; one the process does not get to keeps its old marker and is taken up at
* the next start.
*/
@Synchronized
fun scrubOlder(context: Context) {
val current = SessionPaths.current()
val dirs = LinuxRuntime.debugLogDir().listFiles { f ->
val dirs = LinuxRuntime.logDir(context).listFiles { f ->
SessionPaths.isSessionFolder(f) && f != current && !File(f, SCRUBBED_MARKER).exists()
} ?: return
}?.toList() ?: return
if (dirs.isEmpty()) return
LogRedactor.learnFromRuntime(LinuxRuntime.rootDir(context))
dirs.forEach { dir ->
Record(dir).let { r -> scrubFolder(dir, r); File(dir, "steam").takeIf { it.isDirectory }?.let { scrubFolder(it, r) } }
try { File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n") } catch (e: Exception) {}
val workers = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4)
val pool = java.util.concurrent.Executors.newFixedThreadPool(workers) { r ->
Thread({ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND); r.run() }, "scrub-logs")
}
val done = try {
pool.invokeAll(dirs.map { dir -> java.util.concurrent.Callable { scrubAndMark(dir) } })
.count { f -> try { f.get() } catch (e: Exception) { false } }
} finally {
pool.shutdown()
}
val failed = dirs.size - done
Log.i(TAG, "logs: scrubbed $done folders under r${LogRedactor.RULES_VERSION}" +
if (failed > 0) ", $failed not fully (again at the next start)" else "")
}
/**
* Folders [LogMigration] just moved in lose every scrub marker an earlier build left, so
* [scrubOlder], which runs right after it, puts them through the current rules whatever those
* markers claimed.
*/
fun unmarkMoved(dirs: List<File>) {
dirs.filter { SessionPaths.isSessionFolder(it) }.forEach { dir ->
dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) || f.name == SCRUBBED_TREE_MARKER }?.forEach { it.delete() }
}
Log.i(TAG, "scrubbed ${dirs.size} older session folder(s)")
}
/**
@@ -143,15 +171,17 @@ object SessionArtifacts {
* Records [record]'s folder as scrubbed - every file went through without a failure - or
* leaves it unmarked, so the next share and the pass over older folders scrub it again.
*/
private fun markScrubbed(record: Record) {
private fun markScrubbed(record: Record): Boolean {
val dir = record.dir
val tree = File(dir, SCRUBBED_TREE_MARKER)
// A marker from older rules vouches for nothing now, whether or not this pass succeeded.
dir.listFiles { f -> OLD_SCRUBBED_MARKER.matches(f.name) }?.forEach { it.delete() }
if (record.failed) {
tree.delete()
Log.w(TAG, "$dir is not fully scrubbed; it is scrubbed again when shared")
return
return false
}
try {
return try {
File(dir, SCRUBBED_MARKER).writeText("scrubbed ${now()}\n")
val lines = StringBuilder("rules ${LogRedactor.RULES_VERSION}\n")
record.files.forEach { (f, s) ->
@@ -159,8 +189,11 @@ object SessionArtifacts {
.append(f.relativeTo(dir).path).append('\n')
}
tree.writeText(lines.toString())
true
} catch (e: Exception) {
tree.delete()
File(dir, SCRUBBED_MARKER).delete()
false
}
}
@@ -181,9 +214,8 @@ object SessionArtifacts {
}.toSet()
}
/** The end-of-session scrub of [dir] on its own (steam/ already in place), for tests. */
@androidx.annotation.VisibleForTesting
internal fun scrubAndMark(dir: File) = markScrubbed(scrubTree(Record(dir)))
/** The end-of-session scrub of [dir] on its own (steam/ already in place); also the pass over older and moved folders. */
internal fun scrubAndMark(dir: File): Boolean = markScrubbed(scrubTree(Record(dir)))
/**
* [record]'s folder and every folder under it (steam/, droiddeck-esync/): the session script
@@ -223,7 +255,7 @@ object SessionArtifacts {
*/
@Synchronized
fun finishAbandoned(context: Context) {
val parent = LinuxRuntime.debugLogDir()
val parent = LinuxRuntime.logDir(context)
val abandoned = parent.listFiles { f ->
SessionPaths.isSessionFolder(f) && !File(f, COMPLETE_MARKER).exists()
}?.sortedWith(SessionPaths.chronological) ?: return
@@ -282,9 +314,7 @@ object SessionArtifacts {
val current = SessionPaths.current()
val gone = SessionPaths.sessionFolders(context).filter { it != current }
gone.forEach { com.droiddeck.launcher.core.FileUtils.delete(it) }
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.debugLogDir(), SessionPaths.TOOLS_DIR))
// Before tools/, those logs sat loose beside the session folders.
LinuxRuntime.debugLogDir().listFiles { f -> f.isFile && f.name.endsWith(".log") }?.forEach { it.delete() }
com.droiddeck.launcher.core.FileUtils.delete(File(LinuxRuntime.logDir(context), SessionPaths.TOOLS_DIR))
Log.i(TAG, "cleared ${gone.size} session folder(s)")
return gone.size
}
@@ -361,25 +361,9 @@ object SessionFiles {
).joinToString(newline)
/**
* Where the session writes its log. Downloads is the point - a failed run is handed over as a
* folder rather than dug out of app-private storage - but the session script redirects its own
* output there with `exec`, and a redirection a non-interactive shell cannot open ends that
* shell. So a public directory is used only once it is proven writable; otherwise the app's
* own files directory, which is bound into the session anyway, stands in.
* Where the session writes its log: app-private `files/logs`, bound into the session at the same
* path, so the session script's own redirection always opens. A folder leaves the device only
* through Share logs, which scrubs every file again on the way into the zip.
*/
fun logDirectory(context: Context): File {
val public = LinuxRuntime.debugLogDir()
if (public.isDirectory || public.mkdirs()) {
val probe = File(public, ".writable")
try {
if (probe.createNewFile() || probe.isFile) {
probe.delete()
return public
}
} catch (ignored: Exception) {
}
}
Log.w(TAG, "$public is not writable (storage permission?); logging to files/logs")
return File(context.filesDir, "logs").apply { mkdirs() }
}
fun logDirectory(context: Context): File = LinuxRuntime.logDir(context).apply { mkdirs() }
}
@@ -18,9 +18,11 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Zips the most recent session's log folder and hands it to Android's share sheet. Works where
* the folder could not be written to Downloads (no storage permission, or a device that refuses
* it) and landed in the app's private files instead, which a user cannot otherwise reach.
* Share logs: one zip, made when asked, of a session folder (the newest by default) with the last
* seven days of the stores' log and the one-off command logs beside it, handed to Android's share
* sheet. Logs live in app-private `files/logs`; this is how they leave the device. Every text file
* goes through [LogRedactor.redactForShare] on the way in, so even a file written before a rule
* existed is clean in the zip. The zip sits in `cache/share/` until the next share or app start.
*/
object SessionLogShare {
private val main = Handler(Looper.getMainLooper())
@@ -62,7 +64,7 @@ object SessionLogShare {
val files = folder.walkTopDown().filter { it.isFile }
.filterNot { it.parentFile?.name == "steam" && it.length() > STEAM_LOG_MAX_BYTES }.toList()
if (files.isEmpty()) return null
val out = File(context.cacheDir, "shared-logs").apply { deleteRecursively(); mkdirs() }
val out = shareDir(context).apply { deleteRecursively(); mkdirs() }
val zip = File(out, "DroidDeck-${folder.name}.zip")
// Scrubbed on the way into the zip: a session shared while it runs has not had its end-of-
// session pass yet, and the redactor changes nothing in a line that is already clean.
@@ -71,11 +73,11 @@ object SessionLogShare {
// since, goes in as it is; anything else (added or changed since, hidden, or any file of a
// session still running) is scrubbed again. Scrubbing tens of MB of the client's logs on
// every share kept the share sheet from appearing for ten seconds or more.
val scrubbed = SessionArtifacts.scrubbedFiles(folder)
val live = liveSteamLogs(context, folder)
// The stores' own log (last 7 days, app-private, already redacted), under stores/.
val storeLogs = com.droiddeck.launcher.stores.StoreLogFiles.files(context)
val total = (files + live + storeLogs).sumOf { it.length() }.coerceAtLeast(1L)
val logs = LinuxRuntime.logDir(context)
val stores = recent(File(logs, STORES_DIR))
val tools = recent(File(logs, SessionPaths.TOOLS_DIR))
val total = (files + live + stores + tools).sumOf { it.length() }.coerceAtLeast(1L)
var done = 0L
var shown = -1
fun advance(f: File) {
@@ -84,18 +86,20 @@ object SessionLogShare {
if (percent != shown) { shown = percent; onProgress?.invoke(percent / 100f) }
}
ZipOutputStream(zip.outputStream().buffered()).use { z ->
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f, f.relativeTo(folder).path in scrubbed); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f, false); advance(f) }
storeLogs.forEach { f -> addEntry(z, folder.name + "/stores/" + f.name, f, false); advance(f) }
files.forEach { f -> addEntry(z, folder.name + "/" + f.relativeTo(folder).path, f); advance(f) }
live.forEach { f -> addEntry(z, folder.name + "/steam/" + f.name, f); advance(f) }
stores.forEach { f -> addEntry(z, "$STORES_DIR/" + f.name, f); advance(f) }
tools.forEach { f -> addEntry(z, SessionPaths.TOOLS_DIR + "/" + f.name, f); advance(f) }
}
return zip
}
private fun addEntry(z: ZipOutputStream, name: String, f: File, scrubbed: Boolean) {
private fun addEntry(z: ZipOutputStream, name: String, f: File) {
z.putNextEntry(ZipEntry(name))
if (!scrubbed && LogRedactor.isText(f)) {
// Every text file, every time: the folder's own scrub may predate a rule.
if (LogRedactor.isText(f)) {
val w = z.bufferedWriter()
LogRedactor.scrubTo(f, w)
LogRedactor.scrubForShare(f, w)
w.flush()
} else {
f.inputStream().use { it.copyTo(z) }
@@ -119,6 +123,25 @@ object SessionLogShare {
/** The client's content and bootstrap logs grow large over months and say nothing about a session. */
private const val STEAM_LOG_MAX_BYTES = 8L * 1024 * 1024
/** The stores' engine log, one file a day, under files/logs. */
const val STORES_DIR = "stores"
private const val RECENT_DAYS = 7
private fun shareDir(context: Context): File = File(context.cacheDir, "share")
/** The files in [dir] changed in the last [RECENT_DAYS] days. */
private fun recent(dir: File): List<File> {
val since = System.currentTimeMillis() - RECENT_DAYS * 86_400_000L
return dir.listFiles { f -> f.isFile && f.lastModified() >= since }?.sortedBy { it.name }.orEmpty()
}
/** Deletes the last share's zip; at app start, so a zip never outlives the share it was made for. */
fun clear(context: Context) {
shareDir(context).deleteRecursively()
// Where builds before this one put it.
File(context.cacheDir, "shared-logs").deleteRecursively()
}
fun shareIntent(context: Context, zip: File): Intent {
val uri = FileProvider.getUriForFile(context, context.packageName + ".logs", zip)
val send = Intent(Intent.ACTION_SEND)
@@ -8,19 +8,20 @@ import java.util.Date
import java.util.Locale
/**
* One folder per session under `Download/DroidDeck/`, holding everything that session recorded,
* One folder per session under app-private `files/logs/`, holding everything that session recorded,
* named for the day, that day's session number and what was run, so the one a report is about can
* be picked out without opening any:
*
* ```
* Download/DroidDeck/2026-09-30-03-steam/
* files/logs/2026-09-30-03-steam/
* 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 KWin, Plasma and the programs on it (desktop mode)
* Download/DroidDeck/tools/ one-off commands: Flatpak installs, AppImage imports
* files/logs/tools/ one-off commands: Flatpak installs, AppImage imports
* files/logs/stores/ the stores' engine log, one file a day
* ```
*
* Folders from before this naming (`session-20260930-180642`) are still recognised, shared and
@@ -137,8 +138,7 @@ object SessionPaths {
/** Every session folder in the places logs are written, oldest first. */
fun sessionFolders(context: Context): List<File> =
listOf(com.droiddeck.launcher.runtime.LinuxRuntime.debugLogDir(), File(context.filesDir, "logs"))
.flatMap { parent -> parent.listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList() }
(com.droiddeck.launcher.runtime.LinuxRuntime.logDir(context).listFiles { f -> isSessionFolder(f) }?.toList() ?: emptyList())
.sortedWith(chronological)
internal fun slug(text: String): String =
@@ -493,7 +493,7 @@ object SessionPrefs {
}
/**
* Whether a session writes its folder under Download/DroidDeck. Off, the same logs are kept in
* Whether a session keeps its folder under files/logs. Off, the same logs are kept in
* the app's cache for the session's lifetime (the scripts need somewhere to write) and thrown
* away at the end, so nothing accumulates in Downloads.
*/
@@ -398,7 +398,7 @@ object WinComponents {
* Downloads the component's package (.msi) or installer (.exe) into the runtime and has
* droiddeck-msi-install lay it out in [staging]. Returns the installer's result (product,
* version, counts, notes), or null and why. Everything the installer prints also goes to
* Download/DroidDeck/tools.
* files/logs/tools.
*/
private fun installPackage(context: Context, c: Component, step: Step, staging: File, onProgress: (Progress) -> Unit): Pair<JSONObject?, String> {
val root = LinuxRuntime.rootDir(context)
@@ -125,40 +125,12 @@ object StoreLog {
}
}
private val URL_PATTERN = Regex("""[A-Za-z][A-Za-z0-9+.\-]*://[^\s"'<>]+""")
private val SAFE_SEGMENT = Regex("""[A-Za-z0-9._\-]{1,40}""")
private val SECRET = Regex("""(?i)(?<![A-Za-z0-9_])(__token__|f_token|hdnts|access_token|refresh_token|id_token|token)(["']?\s*[=:]\s*["']?)[^\s&;,"'}]+""")
private val CODE = Regex("""(?i)(?<![A-Za-z0-9_])(code|exchange_code|authorizationCode)(["']?\s*=\s*["']?)[^\s&;,"'}]+""")
private val AUTH_HEADER = Regex("""(?i)(authorization|cookie)(\s*[:=]\s*).*""")
/**
* What any store line may say, for the Downloads log and logcat alike: every URL cut to
* `scheme://host/first-segment/…` (the segment kept only when it is a plain name - a GOG secure
* link carries its token in the path), no query, fragment or userinfo; and outside URLs, the
* values of token-like keys and of Authorization / Cookie headers blanked.
* What any store line may say, for the stores' log and logcat alike: URLs cut to their host and a
* plain first path segment, token values and Authorization / Cookie headers blanked - the shared
* rules of [com.droiddeck.launcher.core.SecretScrub], which a shared log zip applies too.
*/
@JvmStatic
fun redactLine(text: String): String {
var out = URL_PATTERN.replace(text) { m -> shortUrl(m.value) }
out = SECRET.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
out = CODE.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
out = AUTH_HEADER.replace(out) { m -> m.groupValues[1] + m.groupValues[2] + "…" }
return out
}
private fun shortUrl(url: String): String {
val schemeEnd = url.indexOf("://")
val scheme = url.substring(0, schemeEnd)
var rest = url.substring(schemeEnd + 3)
rest = rest.substringBefore('#').substringBefore('?')
val slash = rest.indexOf('/')
val authority = (if (slash >= 0) rest.substring(0, slash) else rest).substringAfterLast('@')
if (slash < 0) return "$scheme://$authority"
val path = rest.substring(slash + 1)
val first = path.substringBefore('/')
val more = path.length > first.length
if (first.isEmpty()) return "$scheme://$authority/"
val shown = if (SAFE_SEGMENT.matches(first)) first else "…"
return "$scheme://$authority/$shown" + if (more && shown != "…") "/…" else ""
}
fun redactLine(text: String): String =
com.droiddeck.launcher.core.SecretScrub.scrub(text, com.droiddeck.launcher.core.SecretScrub.Urls.SHORT, "…")
}
+1 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="shared_logs" path="shared-logs/" />
<cache-path name="shared_logs" path="share/" />
</paths>
@@ -87,4 +87,65 @@ class LogRedactorTest {
assertEquals(once, LogRedactor.redact(once))
}
}
// The Steam UI's own login line (webhelper_js.txt), as the privacy scan found it.
@Test fun loginStateLineLosesAnEmailAccount() {
val line = "[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange [email protected] 2 1 0 0"
val out = LogRedactor.redact(line)
assertFalse(out.contains("someone.masked"))
assertFalse(out.contains("example.com"))
assertEquals("[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange <redacted:account> 2 1 0 0", out)
assertEquals(out, LogRedactor.redactForShare(line))
}
@Test fun loginStateLineLosesAPlainAccountName() {
val line = "[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange maskeduser42 2 1 0 0"
val out = LogRedactor.redact(line)
assertFalse(out.contains("maskeduser42"))
assertEquals("[2026-10-07 21:14:03] SteamUI: INFO: Login: OnLoginStateChange <redacted:account> 2 1 0 0", out)
assertEquals(out, LogRedactor.redactForShare(line))
}
@Test fun loginStateWithoutAnAccountIsKept() {
val line = "SteamUI: INFO: Login: OnLoginStateChange 0 1 0 0"
assertEquals(line, LogRedactor.redact(line))
}
@Test fun accountNameFieldsAreRedacted() {
val lines = listOf(
"\t\"AccountName\"\t\t\"maskeduser42\"",
"account_name=maskeduser42 remember=1",
"{\"login\":\"maskeduser42\",\"persist\":1}",
"auth: login=maskeduser42&remember=1",
"accountName: maskeduser42",
"{\"username\": \"masked user 42\"}",
)
for (line in lines) {
val out = LogRedactor.redactForShare(line)
assertFalse(out, out.contains("maskeduser42") || out.contains("masked user"))
assertTrue(out, out.contains("<redacted:account>"))
assertEquals(out, LogRedactor.redactForShare(out))
}
}
@Test fun loginAsAWordIsNotAField() {
for (line in listOf("SteamUI: INFO: Login: OnLoginStateChange 0 1 0 0", "login refresh pending", "Login: state 5")) {
assertEquals(line, LogRedactor.redact(line))
}
}
@Test fun privateAddressesLeaveTheZipPublicOnesStay() {
assertEquals("I/LinuxNetworkLink: resolver: <lan-address> <lan-address> 8.8.8.8",
LogRedactor.redactForShare("I/LinuxNetworkLink: resolver: 192.168.1.1 10.0.0.138 8.8.8.8"))
assertEquals("gateway <lan-address>:53 link <lan-address> lan <lan-address>",
LogRedactor.redactForShare("gateway 172.16.4.20:53 link 169.254.10.2 lan 172.31.255.254"))
for (line in listOf(
"connecting to CM 162.254.193.47:27017",
"public 172.32.0.1 and 11.0.0.1 and 193.168.1.1",
"Windows 10.0.19041.1 build 10.0.19045",
"Proton 10.0-3, driver 25.1.0.4",
)) assertEquals(line, LogRedactor.redactForShare(line))
val once = LogRedactor.redactForShare("resolver: 192.168.0.1")
assertEquals(once, LogRedactor.redactForShare(once))
}
}
@@ -0,0 +1,39 @@
package com.droiddeck.launcher.core
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
class SecretScrubTest {
private fun share(line: String) = SecretScrub.scrub(line, SecretScrub.Urls.KEEP_PATH, "<redacted:token>")
@Test fun windowsExceptionCodesAreKept() {
for (line in listOf(
"0024:trace:seh:dispatch_exception code=406d1388 flags=0 addr=00006FFFFFC0A6E8",
"0024:warn:seh:dispatch_exception code=c0000005 flags=0 addr=0000000140012345",
"0030:trace:seh:handle_syscall_fault code=80000003 flags=0",
"exception code=0xC0000005 at 0x140012345",
// An exception code stays one even on a line that also carries OAuth keys.
"client_id=abc code=c0000005",
)) assertEquals(line, share(line))
}
@Test fun anOAuthCodeIsBlanked() {
assertEquals("redirect client_id=34a02cf8 redirect_uri=x state=1 code=<redacted:token>",
share("redirect client_id=34a02cf8 redirect_uri=x state=1 code=AUTHCODE123"))
assertEquals("GET /on_login_success?code=<redacted:token>&scope=",
share("GET /on_login_success?code=AUTHCODE123&scope="))
assertEquals("exchange_code=<redacted:token>", share("exchange_code=0123456789abcdef"))
assertEquals("authorizationCode=<redacted:token>", share("authorizationCode=ABCD1234XYZ"))
}
@Test fun aCodeWithoutOAuthAroundItIsKept() {
for (line in listOf("exit code=1337", "status code=ERR_TIMEOUT", "code 403")) assertEquals(line, share(line))
}
@Test fun scrubbingTwiceChangesNothing() {
val once = share("client_id=1 code=AUTHCODE123 ?code=XYZW1234567")
assertFalse(once.contains("AUTHCODE123"))
assertEquals(once, share(once))
}
}
@@ -33,12 +33,13 @@ class SessionLogShareTest {
"rules $rules\n" + files.joinToString("") { val f = File(folder, it); "${f.length()}\t${f.lastModified()}\t$it\n" })
}
@Test fun aRecordedUnchangedFileGoesInAsItIs() {
@Test fun aRecordedUnchangedFileIsScrubbedAgainOnTheWayIntoTheZip() {
val folder = tmp.newFolder("2026-10-06-01-steam")
// Stands in for a file the ending scrubbed; the share must not pass it through again.
// A file the ending recorded as scrubbed still gets the share's final pass: the record
// may predate a rule, and a zip is the one way a log leaves the device.
File(folder, "session.log").writeText("$secret\n")
record(folder, "session.log")
assertTrue(zip(folder).getValue("session.log").contains(leaked))
assertFalse(zip(folder).getValue("session.log").contains(leaked))
}
@Test fun anythingTheRecordDoesNotVouchForIsScrubbed() {
@@ -0,0 +1,107 @@
package com.droiddeck.launcher.session
import com.droiddeck.launcher.runtime.LinuxRuntime
import java.io.File
import java.util.zip.ZipFile
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
/** A shared zip is scrubbed on the way in, whatever the files on disk still hold. */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [33])
class SessionLogShareZipTest {
private val app get() = RuntimeEnvironment.getApplication()
@Test fun everyTextFileInTheZipIsScrubbedAndTheStoresAndToolsLogsComeAlong() {
val logs = LinuxRuntime.logDir(app)
val session = File(logs, "2026-10-08-01-steam").apply { mkdirs() }
// Written raw, as a file from before a rule existed would be.
File(session, "session.log").writeText(
"fetch https://gog-cdn-fastly.gog.com/token=nva=1791500000~dirs=/x~token=0a1b2c3d4e5f/content-system/v2/store/1/ab/cd/f\n" +
"fetch https://epicgames-download1.akamaized.net/Builds/Org/a/ChunksV4/12/ABCD.chunk?__token__=exp=1~hmac=deadbeefcafe\n" +
"login refresh_token=abcdef123456 user [email protected] code=AUTHCODE123\n" +
"Authorization: Bearer abc.def.ghi\n" +
"kept: EResult 5, app 1086940, steamapps/common/Game\n"
)
File(logs, "stores").apply { mkdirs() }.resolve("stores-2026-10-08.log").writeText("epic: f_token=zzzzzzzz12 window 32\n")
File(logs, SessionPaths.TOOLS_DIR).apply { mkdirs() }.resolve("flatpak-install.log").writeText("== install\nCookie: steamLoginSecure=76561198000000000%7C%7Ceyabc\n")
val zip = SessionLogShare.zipFolder(app, session)!!
assertTrue(zip.path.startsWith(File(app.cacheDir, "share").path))
val text = ZipFile(zip).use { z -> z.entries().toList().associate { e -> e.name to z.getInputStream(e).bufferedReader().readText() } }
assertTrue(text.keys.containsAll(listOf("2026-10-08-01-steam/session.log", "stores/stores-2026-10-08.log", "tools/flatpak-install.log")))
val all = text.values.joinToString("\n")
for (secret in listOf("0a1b2c3d4e5f", "deadbeefcafe", "abcdef123456", "[email protected]", "AUTHCODE123", "abc.def.ghi", "zzzzzzzz12", "eyabc")) {
assertFalse("$secret survived", all.contains(secret))
}
// What makes a log worth reading stays.
assertTrue(all.contains("kept: EResult 5, app 1086940, steamapps/common/Game"))
assertTrue(all.contains("https://gog-cdn-fastly.gog.com/"))
SessionLogShare.clear(app)
assertFalse(zip.exists())
}
@Test fun theSharePassChangesNothingInACleanLine() {
val line = "dxvk: Device 0x5 (Adreno 750) https://github.com/doitsujin/dxvk/releases code 403"
assertEquals(line, com.droiddeck.launcher.core.LogRedactor.redactForShare(line))
}
// Folders an earlier build scrubbed and marked under older rules, their copied Steam UI log
// still naming the account: one app start (the move out of Download/, then the pass over
// older folders) leaves every one scrubbed on disk under the current rules.
private fun olderFolder(parent: File, name: String, marker: String = ".scrubbed-2"): File {
val dir = File(parent, name).apply { mkdirs() }
File(dir, marker).writeText("scrubbed earlier\n")
File(dir, SessionArtifacts.SCRUBBED_TREE_MARKER).writeText("rules 2\n")
File(dir, "steam").apply { mkdirs() }.resolve("webhelper_js.txt").writeText(
"[2026-10-01 20:00:00] SteamUI: INFO: Login: OnLoginStateChange [email protected] 2 1 0 0\n" +
"[2026-10-01 20:00:01] SteamUI: INFO: Login: OnLoginStateChange maskeduser42 2 1 0 0\n" +
"[2026-10-01 20:00:02] SteamUI: INFO: Login: OnLoginStateChange 0 1 0 0\n" +
"0024:trace:seh:dispatch_exception code=c0000005 flags=0\n"
)
return dir
}
private fun assertScrubbed(dir: File) {
val text = File(dir, "steam/webhelper_js.txt").readText()
for (secret in listOf("someone.masked", "example.com", "maskeduser42")) assertFalse("$secret survived in $dir", text.contains(secret))
assertTrue(text.contains("Login: OnLoginStateChange <redacted:account> 2 1 0 0"))
assertTrue(text.contains("Login: OnLoginStateChange 0 1 0 0"))
assertTrue(text.contains("code=c0000005"))
val markers = dir.list()!!.filter { it.startsWith(".scrubbed-") && it != SessionArtifacts.SCRUBBED_TREE_MARKER }
assertEquals("$dir", listOf(".scrubbed-r${com.droiddeck.launcher.core.LogRedactor.RULES_VERSION}"), markers)
assertTrue(File(dir, SessionArtifacts.SCRUBBED_TREE_MARKER).readText()
.startsWith("rules ${com.droiddeck.launcher.core.LogRedactor.RULES_VERSION}\n"))
}
@Test fun aFolderMarkedUnderOlderRulesIsScrubbedAgainAtStart() {
val dir = olderFolder(LinuxRuntime.logDir(app), "2026-10-01-01-steam")
SessionArtifacts.scrubOlder(app)
assertScrubbed(dir)
}
@Test fun everyMigratedFolderIsScrubbedUnderTheCurrentRulesInOneStart() {
val legacy = LinuxRuntime.legacyLogDir().apply { mkdirs() }
val names = (1..9).map { "2026-10-0${it}-01-steam" }
names.forEachIndexed { i, name ->
// Old markers of every kind an earlier build left, one even claiming the current rules.
olderFolder(legacy, name, marker = listOf(".scrubbed-2", ".scrubbed-1", ".scrubbed-r${com.droiddeck.launcher.core.LogRedactor.RULES_VERSION}")[i % 3])
}
File(legacy, "Saves").apply { mkdirs() }.resolve("keep.txt").writeText("save\n")
// What MainActivity runs at start.
LogMigration.run(app)
SessionArtifacts.scrubOlder(app)
val logs = LinuxRuntime.logDir(app)
for (name in names) assertScrubbed(File(logs, name))
assertEquals(listOf("Saves"), legacy.list()!!.toList())
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ The script then unpacks them without hooks and checks what the binaries link. Li
the Desktop package normally brings (PyGObject, json-glib, fuse3 and others) are fetched too,
but only when their files are missing. Flathub is added as a per-user remote from a copy of
its `.flatpakrepo` carried in the script, so that step needs no network. Every store command
writes its output to `Download/DroidDeck/flatpak-<verb>.log`, next to the session logs. The
writes its output to `files/logs/tools/flatpak-<verb>.log`, next to the session logs. The
setup script also puts Flatpak's own error text in the failure message the app shows.
## bubblewrap without namespaces
+54
View File
@@ -7,6 +7,60 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-08 - `feat/private-logs`: every log in app-private storage, shared as one scrubbed zip
- **Where logs live:** `files/logs/` (`LinuxRuntime.logDir`), nothing under `Download/DroidDeck`
any more. Session folders (`<day>-NN-<what>/`), `tools/` (GuestCommand, Flatpak, Windows
component installers) and `stores/` (the stores' engine log, one file a day) all sit there. The
files directory is bound into every session at the same path, so the session script, gamescope
and the guest tools write there as before. Thirty sessions kept; the Logs switch off still means
a cache folder deleted at session end. Game save backups stay public in `Download/DroidDeck/Saves`.
- **Moving the old ones:** on the first start of this build, session folders, `tools/` and loose
`*.log` files move from `Download/DroidDeck/` into `files/logs/` (rename, else copy and delete;
an item that fails stays). A marker records the pass; `Saves/` and anything else there is never
touched, and the app does not read `Download/DroidDeck/` again.
- **Share logs:** one zip in `cache/share/`, made on demand: the newest session folder (or the one
the session screen shares), the last seven days of `stores/` and `tools/`. Every text file goes
through `LogRedactor.redactForShare` on the way in - the existing rules (Steam tokens, JWTs,
SteamIDs, account names, emails, own addresses, MACs, serials) plus URLs without query, fragment
or userinfo and with token-bearing path segments blanked (GOG secure links), `__token__`,
`f_token`, `hdnts`, OAuth `code=`, and Authorization / Cookie headers - so a file written before a
rule existed is clean in the zip. The zip is deleted at the next share or app start.
`SessionLogShareTest` covers it. Nothing was relaxed: the per-session scrub, `scrubOlder` and
every existing rule stay as they were.
- **File manager:** the Session logs shortcut opens `files/logs` (the app reads its own storage).
- **On main after Stores (#476) and the KDE Plasma desktop (#473):** the stores' log is already
`files/logs/stores/stores-<date>.log` and the zip carries its last seven days under `stores/`. One
set of rules scrubs both the stores' log and the zip (`SecretScrub`): `StoreLog.redactLine` keeps
URLs to their host and first plain segment, the zip pass keeps paths with token segments blanked,
both blank token values and Authorization / Cookie headers; the two test suites pass on it. The
Plasma desktop writes nothing outside the session folder (its output is the session's
`desktop.log`), so there was nothing new to move.
- **Privacy scan fixes:** the Steam login account (an email, 374 times) was still in older
folders' copied `steam/webhelper_js.txt` (`SteamUI: INFO: Login: OnLoginStateChange <account>`):
those folders carried a scrub marker from before the account rules, and the marker never
expired. The marker is now named after the redactor's rules version (`.scrubbed-r3`), so every
folder scrubbed under older rules goes through again at app start, steam/ and every subfolder
included; folders moved out of `Download/DroidDeck` lose every old marker
(`SessionArtifacts.unmarkMoved`), so the same pass takes them whatever those markers said. New rules: `OnLoginStateChange <anything>`, and
`AccountName` / `account_name` / `username` / `login` fields (`login` only as `login=`, a quoted
key or a VDF pair, so the "Login:" label stays). The `code=` rule no longer touches Proton's
exception codes (`code=c0000005`, `406d1388`, `80000003`): a bare `code=` is blanked only right
after `?`/`&` or on a line with OAuth keys (`client_id`, `redirect_uri`, `state`, tokens), and
never when it is 8 hex digits; exchange / authorization codes are always blanked. Tests:
`LogRedactorTest` (the login line with an email and with a plain name), `SecretScrubTest`,
`SessionLogShareZipTest` (older and moved folders scrubbed on disk).
- **Device check (31 migrated folders):** only 4 carried `.scrubbed-r3` a minute in. Nothing was
skipped - the pass was still running: one folder holds ~30 MB of Steam logs, ~30 s through the
redactor, one folder after another (about 15 minutes for 31). The start pass now scrubs a few
folders at a time (half the cores, at most 4, background priority), old markers go whether or
not a folder's pass succeeds, a folder the process does not finish is taken up at the next start,
and it logs one line: `logs: scrubbed N folders under r3`. Test: nine migrated folders carrying
`.scrubbed-2`, `.scrubbed-1` or a stale `.scrubbed-r3` all end up scrubbed under r3 after one
migration + start pass.
- **LAN addresses in the zip:** private IPv4 addresses (10/8, 172.16/12, 192.168/16, 169.254/16;
e.g. the router in `LinuxNetworkLink: resolver: ...`) become `<lan-address>` in the zip pass;
public server addresses and dotted version numbers stay (`LogRedactorTest`).
## 2026-10-08 - `feat/stores`: GOG, Epic Games and Amazon Games in the launcher (in progress)
A new **Stores** section - the three storefronts' libraries and public catalogs, one download