android: build.sh installs and syncs the game files when a phone is attached
After building the APK, android/build.sh now picks the attached device (ANDROID_SERIAL for several), installs the APK (reinstalling on a signature mismatch), syncs the zh client into files/Client and, with --launch, wakes the phone and starts the app. Without a device it only builds. push-client.sh is now incremental (android/sync_client.py): the device keeps files/Client/.mt_sync (size, mtime, path of what the last sync wrote), so later syncs send only new and changed files as one tar streamed through `adb shell -T run-as` (no copy in /data/local/tmp) and delete the ones gone locally. Only game resources are synced; settings and logs the app writes there are kept. --locale zh rebuilds build/zh-locale/Client first when its inputs changed. android/adb_env.sh picks adb and falls back to server port 5038 when :5037 cannot bind. Verified against a fake adb device: first sync (1.4 GB, byte-identical), no-op resync, changed files, deletions, locale switch, --full, the install/launch path and the no-device path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f9cd63713f
commit
cd9e5b259d
@@ -0,0 +1,41 @@
|
||||
# Sourced by the android/ scripts: picks adb and the target device.
|
||||
# ADB adb binary (default: $ANDROID_HOME/platform-tools/adb, else adb on PATH)
|
||||
# ANDROID_SERIAL device to use when several are attached (adb honours it itself)
|
||||
# An adb server that cannot bind :5037 ("Address already in use" with nothing listening, seen on this
|
||||
# Mac after sleep) is retried on :5038 through ANDROID_ADB_SERVER_PORT.
|
||||
SDK="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}"
|
||||
PKG=org.metin2port.client
|
||||
if [ -z "${ADB:-}" ]; then
|
||||
if [ -x "$SDK/platform-tools/adb" ]; then ADB="$SDK/platform-tools/adb"; else ADB="$(command -v adb || true)"; fi
|
||||
fi
|
||||
[ -n "$ADB" ] || { echo "adb not found (install Android platform-tools or set ADB)" >&2; return 1 2>/dev/null || exit 1; }
|
||||
if ! "$ADB" start-server >/dev/null 2>&1; then
|
||||
export ANDROID_ADB_SERVER_PORT=5038
|
||||
"$ADB" start-server >/dev/null 2>&1 || { echo "adb server does not start on :5037 or :5038" >&2; return 1 2>/dev/null || exit 1; }
|
||||
echo "adb: :5037 unusable, using :5038"
|
||||
fi
|
||||
export ADB
|
||||
|
||||
# adb_device: sets DEVICE to the one ready device (or ANDROID_SERIAL) and returns 0; returns 1 with a
|
||||
# message when none is ready (unauthorized / offline devices are named so the user can fix them).
|
||||
adb_device() {
|
||||
local ready others
|
||||
ready=$("$ADB" devices | awk 'NR > 1 && $2 == "device" { print $1 }')
|
||||
others=$("$ADB" devices | awk 'NR > 1 && NF >= 2 && $2 != "device" { print $1 " (" $2 ")" }')
|
||||
if [ -n "${ANDROID_SERIAL:-}" ]; then
|
||||
if printf '%s\n' "$ready" | grep -qxF "$ANDROID_SERIAL"; then DEVICE="$ANDROID_SERIAL"; return 0; fi
|
||||
echo "device $ANDROID_SERIAL is not ready" >&2
|
||||
return 1
|
||||
fi
|
||||
case $(printf '%s' "$ready" | grep -c .) in
|
||||
1) DEVICE="$ready"; export ANDROID_SERIAL="$DEVICE"; return 0 ;;
|
||||
0)
|
||||
if [ -n "$others" ]; then
|
||||
echo "device attached but not ready: $others (unlock the phone and allow USB debugging)" >&2
|
||||
else
|
||||
echo "no Android device attached" >&2
|
||||
fi
|
||||
return 1 ;;
|
||||
*) echo "several devices attached, pick one with ANDROID_SERIAL:" $ready >&2; return 1 ;;
|
||||
esac
|
||||
}
|
||||
+59
-11
@@ -1,20 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the native (SDL3 + Vulkan) 40250 client as an Android arm64 APK.
|
||||
# ./android/build.sh [Debug|Release] [--install]
|
||||
# Build the native (SDL3 + Vulkan) 40250 client as an Android arm64 APK and, when a phone is attached,
|
||||
# install it and bring its game files up to date, so the app is ready to play.
|
||||
# ./android/build.sh [Debug|Release] [--no-deploy] [--no-push] [--locale XX] [--full-push] [--launch]
|
||||
# Output: android/app/build/outputs/apk/debug/app-debug.apk
|
||||
# The 1.4 GB Client/ directory is not in the APK; push it once with ./android/push-client.sh.
|
||||
# Deploy (skipped when no device is ready; ANDROID_SERIAL picks one of several):
|
||||
# adb install -r, then ./android/push-client.sh --locale XX (default zh), which sends only the game
|
||||
# files changed since the last push (the first push is the whole 1.4 GB client). --no-push installs
|
||||
# only, --full-push resends everything, --launch wakes the phone and starts the app afterwards.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
REPO="$PWD"
|
||||
|
||||
CONFIG=Release
|
||||
INSTALL=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
Debug|Release) CONFIG="$a" ;;
|
||||
--install) INSTALL=1 ;;
|
||||
*) echo "usage: $0 [Debug|Release] [--install]" >&2; exit 2 ;;
|
||||
DEPLOY=1
|
||||
PUSH=1
|
||||
LOCALE=zh
|
||||
FULL_PUSH=0
|
||||
LAUNCH=0
|
||||
usage="usage: $0 [Debug|Release] [--no-deploy] [--no-push] [--locale XX] [--full-push] [--launch]"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
Debug|Release) CONFIG="$1" ;;
|
||||
--install) ;; # the default now; kept for old command lines
|
||||
--no-deploy) DEPLOY=0 ;;
|
||||
--no-push) PUSH=0 ;;
|
||||
--locale) LOCALE="${2:?--locale needs a locale code}"; shift ;;
|
||||
--full-push) FULL_PUSH=1 ;;
|
||||
--launch) LAUNCH=1 ;;
|
||||
*) echo "$usage" >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
API=24
|
||||
OUT="$REPO/build/android"
|
||||
@@ -84,6 +99,39 @@ variant=Debug # debug-signed either way (sideload); CONFIG picks the native opt
|
||||
APK="$REPO/android/app/build/outputs/apk/debug/app-debug.apk"
|
||||
ls -la "$APK"
|
||||
|
||||
if [ "$INSTALL" = 1 ]; then
|
||||
adb install -r "$APK"
|
||||
[ "$DEPLOY" = 1 ] || exit 0
|
||||
. android/adb_env.sh
|
||||
if ! adb_device; then
|
||||
echo "APK built; install and push skipped. Attach the phone and rerun (or ./android/push-client.sh)."
|
||||
exit 0
|
||||
fi
|
||||
echo "device: $DEVICE ($("$ADB" shell getprop ro.product.model | tr -d '\r'))"
|
||||
if ! out=$("$ADB" install -r "$APK" 2>&1); then
|
||||
echo "$out" >&2
|
||||
case "$out" in
|
||||
*INSTALL_FAILED_UPDATE_INCOMPATIBLE*|*INSTALL_FAILED_VERSION_DOWNGRADE*)
|
||||
# Signed by another machine's debug key (or a newer build): only a reinstall helps. That drops the
|
||||
# app's data, so the push below starts over with the whole client.
|
||||
echo "reinstalling $PKG (the installed copy has another signature; its data is removed)"
|
||||
"$ADB" uninstall "$PKG" >/dev/null
|
||||
"$ADB" install "$APK" ;;
|
||||
*)
|
||||
echo "install failed; on some phones USB installs must be allowed in developer options and confirmed on screen" >&2
|
||||
exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
echo "installed $PKG"
|
||||
if [ "$PUSH" = 1 ]; then
|
||||
push_args=(--locale "$LOCALE")
|
||||
[ "$FULL_PUSH" = 1 ] && push_args+=(--full)
|
||||
python3 android/sync_client.py "${push_args[@]}"
|
||||
fi
|
||||
if [ "$LAUNCH" = 1 ]; then
|
||||
# The app exits with a Vulkan surface error when started behind the lock screen.
|
||||
"$ADB" shell input keyevent KEYCODE_WAKEUP
|
||||
if "$ADB" shell dumpsys window | grep -qE 'mDreamingLockscreen=true|isKeyguardShowing=true|mKeyguardShowing=true'; then
|
||||
echo "the phone is locked: unlock it, then start Metin2 from the launcher" >&2
|
||||
else
|
||||
"$ADB" shell am start -n "$PKG/.MainActivity" >/dev/null && echo "started $PKG"
|
||||
fi
|
||||
fi
|
||||
|
||||
+10
-35
@@ -1,39 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copy the 40250 Client/ directory into the Android app's private files/ (SDL_GetPrefPath), where
|
||||
# src/host looks for files/Client/pack/Index. Needs the debug APK installed (run-as).
|
||||
# ./android/push-client.sh [--locale XX] [CLIENT_DIR]
|
||||
# --locale XX writes the pushed locale.cfg from CLIENT_DIR/locale_XX.cfg. --locale zh defaults
|
||||
# CLIENT_DIR to build/zh-locale/Client (scripts/build_zh_locale.py), whose pack/ registers locale_zh.
|
||||
# Sync the 40250 game files into the Android app's private files/ (SDL_GetPrefPath), where src/host looks
|
||||
# for files/Client/pack/Index. Only files changed since the last sync are sent (android/sync_client.py).
|
||||
# Needs the debug APK installed (run-as).
|
||||
# ./android/push-client.sh [--locale XX] [--full] [--dry-run] [CLIENT_DIR]
|
||||
# --locale XX writes the pushed locale.cfg from CLIENT_DIR/locale_XX.cfg. --locale zh defaults CLIENT_DIR
|
||||
# to build/zh-locale/Client (scripts/build_zh_locale.py, rebuilt first when its inputs changed).
|
||||
# --full deletes the synced resources on the device and pushes everything again.
|
||||
# Symbolic links in CLIENT_DIR (the zh client links the original packs) are copied as their targets.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
PKG=org.metin2port.client
|
||||
LOCALE=""
|
||||
if [ "${1:-}" = "--locale" ]; then
|
||||
LOCALE="${2:?--locale needs a locale code}"
|
||||
shift 2
|
||||
fi
|
||||
if [ "$LOCALE" = zh ]; then
|
||||
CLIENT="${1:-build/zh-locale/Client}"
|
||||
else
|
||||
CLIENT="${1:-Client}"
|
||||
fi
|
||||
[ -f "$CLIENT/pack/Index" ] || { echo "$CLIENT/pack/Index not found" >&2; exit 1; }
|
||||
# The archive's top directory is basename(CLIENT DIR); the app reads files/Client.
|
||||
[ "$(basename "$CLIENT")" = Client ] || { echo "CLIENT_DIR must be named Client" >&2; exit 1; }
|
||||
if [ -n "$LOCALE" ]; then
|
||||
[ -f "$CLIENT/locale_$LOCALE.cfg" ] || { echo "$CLIENT/locale_$LOCALE.cfg not found" >&2; exit 1; }
|
||||
pack="$(cut -d' ' -f3 < "$CLIENT/locale_$LOCALE.cfg" | tr -d '\r\n')"
|
||||
tr -d '\r' < "$CLIENT/pack/Index" | grep -qxF "locale_$pack" ||
|
||||
{ echo "locale_$pack is not registered in $CLIENT/pack/Index" >&2; exit 1; }
|
||||
fi
|
||||
TMP=/data/local/tmp/mt-client.tar
|
||||
echo "packing $CLIENT ..."
|
||||
tar -h -C "$(dirname "$CLIENT")" -cf - "$(basename "$CLIENT")" | adb shell "cat > $TMP"
|
||||
echo "unpacking into $PKG files/ ..."
|
||||
adb shell "run-as $PKG sh -c 'mkdir -p files && rm -rf files/Client && cd files && tar -xf $TMP' && rm -f $TMP"
|
||||
if [ -n "$LOCALE" ]; then
|
||||
adb shell "run-as $PKG sh -c 'cp files/Client/locale_$LOCALE.cfg files/Client/locale.cfg'"
|
||||
echo "locale.cfg: $(adb shell "run-as $PKG cat files/Client/locale.cfg")"
|
||||
fi
|
||||
adb shell "run-as $PKG ls files/Client/pack/Index" >/dev/null && echo "done: files/Client"
|
||||
. android/adb_env.sh
|
||||
adb_device
|
||||
exec python3 android/sync_client.py "$@"
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bring the game files in the app's private files/Client (SDL_GetPrefPath, where src/host looks for
|
||||
files/Client/pack/Index) up to date with a local client directory, sending only what changed.
|
||||
|
||||
The device keeps files/Client/.mt_sync, the list (size, mtime, path) of what the last sync wrote. A sync
|
||||
compares it with the local files, streams the new and changed ones as one tar through `adb shell -T run-as`
|
||||
(no copy in /data/local/tmp), removes the ones gone locally, then writes the new list. Only the game
|
||||
resources are synced (pack/, sound/, BGM/, miles/, lib/, channel.inf, locale*.cfg); what the app writes
|
||||
there itself (settings such as ui_height.cfg, perf logs) is never touched. mouse.cfg is only seeded on
|
||||
the first sync.
|
||||
|
||||
Needs the debug APK installed (run-as). adb and the device come from android/adb_env.sh ($ADB,
|
||||
$ANDROID_SERIAL); android/push-client.sh is the shell entry point.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
PKG = "org.metin2port.client"
|
||||
MANIFEST = ".mt_sync"
|
||||
HEADER = "# mt-sync 1"
|
||||
SYNC_DIRS = ("pack", "sound", "BGM", "miles", "lib")
|
||||
SEED_FILES = ("mouse.cfg",)
|
||||
ZH_OUTPUT = REPO / "build/zh-locale/Client"
|
||||
ZH_STAMP = REPO / "build/zh-locale/.inputs.sha256"
|
||||
|
||||
|
||||
def adb(*args, **kwargs):
|
||||
return subprocess.run([os.environ.get("ADB", "adb"), *args], **kwargs)
|
||||
|
||||
|
||||
def run_as_command(script):
|
||||
# `adb shell -T` (shell protocol v2, Android 7+): no pty, so stdin/stdout stay binary-safe, and unlike
|
||||
# exec-in/exec-out it returns the remote exit status.
|
||||
return [os.environ.get("ADB", "adb"), "shell", "-T", f"run-as {PKG} sh -c '{script}'"]
|
||||
|
||||
|
||||
def run_as(script, **kwargs):
|
||||
"""Runs script with sh as the app user in its data directory."""
|
||||
return subprocess.run(run_as_command(script), **kwargs)
|
||||
|
||||
|
||||
def zh_inputs_digest(client_base):
|
||||
h = hashlib.sha256()
|
||||
inputs = [REPO / "scripts/build_zh_locale.py", client_base / "pack/Index"]
|
||||
inputs += sorted(p for p in (REPO / "data/zh_40250").rglob("*") if p.is_file())
|
||||
for path in inputs:
|
||||
h.update(str(path.relative_to(REPO)).encode())
|
||||
h.update(path.read_bytes() if path.exists() else b"")
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def ensure_zh_locale():
|
||||
"""Rebuilds build/zh-locale/Client when its inputs (translations, the builder, the base pack Index)
|
||||
changed since the last build."""
|
||||
digest = zh_inputs_digest(REPO / "Client")
|
||||
built = (ZH_OUTPUT / "pack/Index").is_file()
|
||||
if built and ZH_STAMP.is_file() and ZH_STAMP.read_text().strip() == digest:
|
||||
return
|
||||
print("zh locale: inputs changed, rebuilding build/zh-locale/Client ...")
|
||||
result = subprocess.run([sys.executable, str(REPO / "scripts/build_zh_locale.py")], cwd=REPO)
|
||||
if result.returncode == 0:
|
||||
ZH_STAMP.write_text(digest + "\n")
|
||||
elif built:
|
||||
print("zh locale: rebuild failed, syncing the previous build", file=sys.stderr)
|
||||
else:
|
||||
sys.exit("zh locale: build failed (scripts/build_zh_locale.py)")
|
||||
|
||||
|
||||
def local_files(client, locale):
|
||||
"""{device path: local path} of the synced resources; locale.cfg comes from locale_<locale>.cfg."""
|
||||
files = {}
|
||||
for top in SYNC_DIRS:
|
||||
root = client / top
|
||||
if not root.is_dir():
|
||||
continue
|
||||
for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||
for name in filenames:
|
||||
if not name.startswith("."):
|
||||
path = Path(dirpath) / name
|
||||
files[path.relative_to(client).as_posix()] = path
|
||||
for path in client.iterdir():
|
||||
if path.is_file() and (path.name == "channel.inf" or (path.name.startswith("locale") and path.suffix == ".cfg")):
|
||||
files[path.name] = path
|
||||
if locale:
|
||||
cfg = client / f"locale_{locale}.cfg"
|
||||
if not cfg.is_file():
|
||||
sys.exit(f"{cfg} not found")
|
||||
pack = cfg.read_text().split()[2]
|
||||
index = (client / "pack/Index").read_text(errors="replace").split()
|
||||
if f"locale_{pack}" not in index:
|
||||
sys.exit(f"locale_{pack} is not registered in {client}/pack/Index")
|
||||
files["locale.cfg"] = cfg
|
||||
return files
|
||||
|
||||
|
||||
def stat_key(path):
|
||||
st = path.stat()
|
||||
return f"{st.st_size}\t{st.st_mtime_ns}"
|
||||
|
||||
|
||||
def read_device_manifest():
|
||||
out = run_as(f"cat files/Client/{MANIFEST} 2>/dev/null", capture_output=True).stdout.decode(errors="replace")
|
||||
lines = out.splitlines()
|
||||
if not lines or lines[0] != HEADER:
|
||||
return None
|
||||
manifest = {}
|
||||
for line in lines[1:]:
|
||||
size, mtime, path = line.split("\t", 2)
|
||||
manifest[path] = f"{size}\t{mtime}"
|
||||
return manifest
|
||||
|
||||
|
||||
class Progress:
|
||||
def __init__(self, total):
|
||||
self.total, self.done, self.last, self.start = total, 0, 0.0, time.monotonic()
|
||||
|
||||
def add(self, n):
|
||||
self.done += n
|
||||
now = time.monotonic()
|
||||
if now - self.last >= 1 or self.done == self.total:
|
||||
self.last = now
|
||||
rate = self.done / max(now - self.start, 1e-3) / 2**20
|
||||
print(f"\r {self.done / 2**20:8.1f} / {self.total / 2**20:.1f} MB {rate:5.1f} MB/s", end="", flush=True)
|
||||
|
||||
|
||||
def push(files):
|
||||
"""Streams {device path: local path} into files/Client as one tar."""
|
||||
total = sum(p.stat().st_size for p in files.values())
|
||||
progress = Progress(total)
|
||||
proc = subprocess.Popen(run_as_command("mkdir -p files/Client && cd files/Client && tar -xf -"),
|
||||
stdin=subprocess.PIPE)
|
||||
with tarfile.open(fileobj=proc.stdin, mode="w|", format=tarfile.GNU_FORMAT, dereference=True) as tar:
|
||||
for name, path in sorted(files.items()):
|
||||
info = tar.gettarinfo(str(path), arcname=name) # dereference: the zh client links the original packs
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
with open(path, "rb") as f:
|
||||
tar.addfile(info, f)
|
||||
progress.add(info.size)
|
||||
proc.stdin.close()
|
||||
if progress.total:
|
||||
print()
|
||||
if proc.wait() != 0:
|
||||
sys.exit("push failed (tar on the device)")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("client", nargs="?", type=Path, help="client directory (default: Client, or the zh build for --locale zh)")
|
||||
parser.add_argument("--locale", help="write locale.cfg from locale_<LOCALE>.cfg; zh uses build/zh-locale/Client, rebuilt when stale")
|
||||
parser.add_argument("--full", action="store_true", help="delete the synced resources on the device and push everything")
|
||||
parser.add_argument("--dry-run", action="store_true", help="only print what would change")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.locale == "zh" and args.client is None:
|
||||
ensure_zh_locale()
|
||||
client = ZH_OUTPUT
|
||||
else:
|
||||
client = args.client or REPO / "Client"
|
||||
client = client.absolute()
|
||||
if not (client / "pack/Index").is_file():
|
||||
sys.exit(f"{client}/pack/Index not found")
|
||||
|
||||
if not adb("shell", "pm", "path", PKG, capture_output=True).stdout.strip():
|
||||
sys.exit(f"{PKG} is not installed (android/build.sh installs it)")
|
||||
if run_as("true", capture_output=True).returncode != 0:
|
||||
sys.exit(f"run-as {PKG} failed: the installed APK must be the debug build")
|
||||
|
||||
files = local_files(client, args.locale)
|
||||
wanted = {name: stat_key(path) for name, path in files.items()}
|
||||
device = None if args.full else read_device_manifest()
|
||||
first = device is None
|
||||
if first:
|
||||
changed = sorted(wanted)
|
||||
removed = []
|
||||
# First sync (or --full): also seed the default settings the app would otherwise lack.
|
||||
for name in SEED_FILES:
|
||||
if (client / name).is_file():
|
||||
files[name] = client / name
|
||||
changed.append(name)
|
||||
else:
|
||||
changed = sorted(n for n, key in wanted.items() if device.get(n) != key)
|
||||
removed = sorted(n for n in device if n not in wanted)
|
||||
size = sum(files[n].stat().st_size for n in changed)
|
||||
print(f"sync {client} -> {PKG} files/Client: {len(changed)} files to push ({size / 2**20:.1f} MB), "
|
||||
f"{len(removed)} to delete, {len(wanted) - len(changed) if not first else 0} unchanged"
|
||||
+ (" [full]" if args.full else " [first sync]" if first else ""))
|
||||
if not changed and not removed:
|
||||
return
|
||||
if args.dry_run:
|
||||
for n in changed[:50]:
|
||||
print(" +", n)
|
||||
for n in removed[:50]:
|
||||
print(" -", n)
|
||||
return
|
||||
|
||||
if args.full:
|
||||
dirs = " ".join(SYNC_DIRS)
|
||||
run_as(f"mkdir -p files/Client && cd files/Client && rm -rf {dirs} {MANIFEST}", check=True)
|
||||
# Drop the list before touching files: an interrupted sync then starts over as a first sync.
|
||||
run_as(f"rm -f files/Client/{MANIFEST}", check=True)
|
||||
if removed:
|
||||
run_as('cd files/Client && while IFS= read -r f; do rm -f "$f"; done',
|
||||
input="".join(n + "\n" for n in removed).encode(), check=True)
|
||||
if changed:
|
||||
push({n: files[n] for n in changed})
|
||||
manifest = HEADER + "\n" + "".join(f"{wanted[n]}\t{n}\n" for n in sorted(wanted))
|
||||
run_as(f"cat > files/Client/{MANIFEST}", input=manifest.encode(), check=True)
|
||||
locale = run_as("cat files/Client/locale.cfg", capture_output=True).stdout.decode(errors="replace").strip()
|
||||
print(f"done: files/Client updated (locale.cfg: {locale})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user