mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec2f6e1461 | ||
|
|
facb4ce74c | ||
|
|
79a40d93b3 | ||
|
|
8d94f0c948 | ||
|
|
56bc2cd3f7 |
No files matched your search
@@ -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,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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user