From 7bb07d5dba7650086a18bcfed2d488fdfc549175 Mon Sep 17 00:00:00 2001 From: maxjivi05 Date: Sun, 4 Oct 2026 22:33:09 -0400 Subject: [PATCH] Steam language: a guest helper that sets the client's language before it starts --- .../usr/local/bin/droiddeck-steam-language | 181 ++++++++++++++++++ tools/tests/test_steam_language.py | 138 +++++++++++++ 2 files changed, 319 insertions(+) create mode 100755 tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-language create mode 100644 tools/tests/test_steam_language.py diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-language b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-language new file mode 100755 index 000000000..bde57b06f --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-steam-language @@ -0,0 +1,181 @@ +#!/usr/bin/python3 +"""Sets the Steam client's interface language to the app's, before the client starts. + + droiddeck-steam-language + + is one of the client's language names (english, spanish, latam, japanese, koreana, +schinese, tchinese, ...). The client reads it from /.steam/registry.vdf, under +HKCU/Software/Valve/Steam and again under .../Steamsteamglobal: that second key is copied over the +first at every start, so setting only the first is undone by the old value. Its update window +reads the first key alone, and nothing reads LANG. exportedsettings.json is what the client writes +beside them when its own setting changes. The session also passes -language, which the interface +honours over the file; the file is still what the update window shows itself in. + +Run only while the client is stopped: it keeps the registry in memory and writes it back on exit. +""" +import json +import os +import re +import sys + +LANGUAGES = { + "english", "german", "french", "italian", "koreana", "spanish", "schinese", "tchinese", + "russian", "thai", "japanese", "portuguese", "polish", "danish", "dutch", "finnish", + "norwegian", "swedish", "hungarian", "czech", "romanian", "turkish", "brazilian", "bulgarian", + "greek", "arabic", "ukrainian", "latam", "vietnamese", "indonesian", "malay", +} +KEYS = (("Registry", "HKCU", "Software", "Valve", "Steam"), + ("Registry", "HKCU", "Software", "Valve", "Steamsteamglobal")) +TOKEN = re.compile(r'\s*(?:"((?:[^"\\]|\\.)*)"|([{}])|//[^\n]*)') + + +def tokenize(text): + tokens, pos = [], 0 + while pos < len(text): + m = TOKEN.match(text, pos) + if not m or m.end() == pos: + break + pos = m.end() + if m.group(2): + tokens.append(m.group(2)) + elif m.group(1) is not None: + tokens.append('"' + m.group(1) + '"') + return tokens + + +def serialize(tokens): + out, depth, i = [], 0, 0 + while i < len(tokens): + t = tokens[i] + if t == "}": + depth -= 1 + out.append("\t" * depth + "}") + i += 1 + elif i + 1 < len(tokens) and tokens[i + 1] == "{": + out.append("\t" * depth + t) + out.append("\t" * depth + "{") + depth += 1 + i += 2 + else: + out.append("\t" * depth + t + "\t\t" + (tokens[i + 1] if i + 1 < len(tokens) else '""')) + i += 2 + return "\n".join(out) + "\n" + + +def block_end(tokens, start): + """Index of the '}' closing the block whose '{' is at start.""" + depth = 0 + for i in range(start, len(tokens)): + if tokens[i] == "{": + depth += 1 + elif tokens[i] == "}": + depth -= 1 + if depth == 0: + return i + return len(tokens) + + +def child(tokens, start, end, name): + """Index of the '{' of the block called name directly inside tokens[start:end], or None. + Keys are matched without case, as the client does.""" + i = start + while i < end: + if tokens[i] in ("{", "}"): + i += 1 + continue + if i + 1 < end and tokens[i + 1] == "{": + if tokens[i].lower() == '"%s"' % name.lower(): + return i + 1 + i = block_end(tokens, i + 1) + 1 + else: + i += 2 + return None + + +def ensure_block(tokens, path): + """The '{' of the block at path, creating it and any missing parent at the end of its parent.""" + start, end = 0, len(tokens) + for depth, name in enumerate(path): + found = child(tokens, start, end, name) + if found is None: + at = end + tokens[at:at] = ['"%s"' % name, "{", "}"] + found = at + 1 + start, end = found + 1, block_end(tokens, found) + return start - 1 + + +def set_value(tokens, opening, key, value): + start, end = opening + 1, block_end(tokens, opening) + i = start + while i < end: + if i + 1 < end and tokens[i + 1] == "{": + i = block_end(tokens, i + 1) + 1 + continue + if tokens[i].lower() == '"%s"' % key.lower() and i + 1 < end: + tokens[i + 1] = '"%s"' % value + return + i += 2 + tokens[end:end] = ['"%s"' % key, '"%s"' % value] + + +def with_language(text, language): + tokens = tokenize(text) + if child(tokens, 0, len(tokens), "Registry") is None: + # Not a registry the client wrote (or none at all): start one it will read. + tokens = [] + for path in KEYS: + set_value(tokens, ensure_block(tokens, path), "language", language) + return serialize(tokens) + + +def write_changed(path, text): + try: + with open(path, encoding="utf-8", errors="replace") as f: + if f.read() == text: + return False + except OSError: + pass + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".droiddeck" + with open(tmp, "w", encoding="utf-8") as f: + f.write(text) + os.replace(tmp, path) + return True + + +def apply(home, language): + steam = os.path.join(home, ".steam") + registry = os.path.join(steam, "registry.vdf") + try: + with open(registry, encoding="utf-8", errors="replace") as f: + text = f.read() + except OSError: + text = "" + changed = write_changed(registry, with_language(text, language)) + exported = os.path.join(steam, "exportedsettings.json") + try: + with open(exported, encoding="utf-8") as f: + settings = json.load(f) + if not isinstance(settings, dict): + settings = {} + except (OSError, ValueError): + settings = {} + if settings.get("language") != language: + settings["language"] = language + write_changed(exported, json.dumps(settings)) + changed = True + return changed + + +def main(argv): + if len(argv) != 3 or argv[2] not in LANGUAGES: + print("usage: droiddeck-steam-language ", file=sys.stderr) + return 2 + changed = apply(argv[1], argv[2]) + print("== steam: language %s%s" % (argv[2], "" if changed else " (already set)")) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/tools/tests/test_steam_language.py b/tools/tests/test_steam_language.py new file mode 100644 index 000000000..43b1e1a95 --- /dev/null +++ b/tools/tests/test_steam_language.py @@ -0,0 +1,138 @@ +import json +from pathlib import Path +import runpy +import tempfile +import unittest + + +BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin' +MODULE = runpy.run_path(str(BIN / 'droiddeck-steam-language')) +apply = MODULE['apply'] +with_language = MODULE['with_language'] +tokenize = MODULE['tokenize'] +child = MODULE['child'] +block_end = MODULE['block_end'] +main = MODULE['main'] + + +def value(text, *path): + tokens = tokenize(text) + start, end = 0, len(tokens) + for name in path[:-1]: + opening = child(tokens, start, end, name) + if opening is None: + return None + start, end = opening + 1, block_end(tokens, opening) + i = start + while i < end: + if i + 1 < end and tokens[i + 1] == '{': + i = block_end(tokens, i + 1) + 1 + continue + if tokens[i].lower() == '"%s"' % path[-1].lower(): + return tokens[i + 1].strip('"') + i += 2 + return None + + +STEAM = ('Registry', 'HKCU', 'Software', 'Valve', 'Steam', 'language') +GLOBAL = ('Registry', 'HKCU', 'Software', 'Valve', 'Steamsteamglobal', 'language') + +# As the client leaves it on a device (trimmed): both keys, other values beside them, and HKLM. +CLIENT = '''"Registry" +{ + "HKLM" + { + "Software" + { + "Valve" + { + "Steam" + { + "InstallPath" "/root/.local/share/Steam" + } + } + } + } + "HKCU" + { + "Software" + { + "Valve" + { + "Steam" + { + "language" "english" + "AutoLoginUser" "someone" + "Apps" + { + "language" "not this one" + } + } + "Steamsteamglobal" + { + "language" "english" + } + } + } + } +} +''' + + +class SteamLanguageTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + (self.home / '.steam').mkdir() + self.registry = self.home / '.steam/registry.vdf' + + def test_both_keys_change_and_everything_else_stays(self): + text = with_language(CLIENT, 'japanese') + self.assertEqual('japanese', value(text, *STEAM)) + self.assertEqual('japanese', value(text, *GLOBAL)) + self.assertEqual('someone', value(text, 'Registry', 'HKCU', 'Software', 'Valve', 'Steam', 'AutoLoginUser')) + self.assertEqual('not this one', value(text, 'Registry', 'HKCU', 'Software', 'Valve', 'Steam', 'Apps', 'language')) + self.assertEqual('/root/.local/share/Steam', value(text, 'Registry', 'HKLM', 'Software', 'Valve', 'Steam', 'InstallPath')) + self.assertEqual(1, text.count('"HKCU"')) + + def test_a_first_run_gets_a_registry_the_client_reads(self): + self.assertTrue(apply(str(self.home), 'koreana')) + text = self.registry.read_text() + self.assertTrue(text.startswith('"Registry"')) + self.assertEqual('koreana', value(text, *STEAM)) + self.assertEqual('koreana', value(text, *GLOBAL)) + self.assertEqual({'language': 'koreana'}, json.loads((self.home / '.steam/exportedsettings.json').read_text())) + + def test_missing_keys_are_added_whatever_their_case(self): + text = with_language('"registry"\n{\n\t"hkcu"\n\t{\n\t\t"software"\n\t\t{\n\t\t}\n\t}\n}\n', 'schinese') + self.assertEqual('schinese', value(text, *STEAM)) + self.assertEqual('schinese', value(text, *GLOBAL)) + self.assertEqual(1, text.lower().count('"hkcu"')) + self.assertEqual(1, text.lower().count('"software"')) + + def test_a_value_is_replaced_not_duplicated_whatever_its_key_case(self): + text = with_language(CLIENT.replace('"language"\t\t"english"', '"Language"\t\t"english"'), 'latam') + self.assertEqual(2, text.lower().count('"language"\t\t"latam"')) + self.assertNotIn('english', text) + + def test_an_unchanged_language_writes_nothing(self): + self.registry.write_text(CLIENT) + apply(str(self.home), 'spanish') + before = self.registry.stat().st_mtime_ns + self.assertFalse(apply(str(self.home), 'spanish')) + self.assertEqual(before, self.registry.stat().st_mtime_ns) + + def test_other_exported_settings_are_kept(self): + (self.home / '.steam/exportedsettings.json').write_text('{"language": "english", "other": 1}') + apply(str(self.home), 'tchinese') + self.assertEqual({'language': 'tchinese', 'other': 1}, json.loads((self.home / '.steam/exportedsettings.json').read_text())) + + def test_only_the_clients_names_are_accepted(self): + self.assertEqual(2, main(['x', str(self.home), 'korean'])) + self.assertEqual(2, main(['x', str(self.home), 'Spanish'])) + self.assertFalse(self.registry.exists()) + + +if __name__ == '__main__': + unittest.main()