wolf: drive app-container timezone from .env (WOLF_TZ) to fix Steam/EA time

App containers were defaulting to a non-local timezone (e.g. BST), showing the
wrong local time in Steam — and a skewed clock can stall EA App's installer.
The kernel clock is shared with the host, so this is a display/TZ issue: fix
it by setting each app's TZ env var, sourced from SITE_TZ at install and from
WOLF_TZ in .env for ./manage.sh apps.

Also generalize the array updater to rewrite both `mounts` and `env` (Wolf
reformats either as multi-line), so re-running apps updates the TZ on
already-installed entries without corrupting the TOML.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D8ckUJQtj1pH8jtAddBDZs
This commit is contained in:
Claude
2026-06-20 18:44:10 +00:00
parent dd7df8ce18
commit 2b6cf7700c
+53 -31
View File
@@ -924,6 +924,9 @@ GAME_STORAGE_DIR=${GAME_STORAGE_DIR}
# Wolf state folder (config.toml + every app session home) — lives on the game
# drive so Steam installs, games, and Proton prefixes stay off the OS drive.
WOLF_STATE_DIR=${WOLF_STATE_DIR}
# Timezone for app containers (Steam/Proton etc.) — set on each app's TZ env so
# Steam, EA App, and games show the correct local time. Edit + ./manage.sh apps.
WOLF_TZ=${SITE_TZ}
EOF
chmod 600 .env
chown "$ACTUAL_USER:$ACTUAL_USER" .env
@@ -951,6 +954,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# the drive because docker-compose.yml mounts it as HOST_APPS_STATE_FOLDER.
WOLF_STATE_DIR=$(grep '^WOLF_STATE_DIR=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
WOLF_CFG="${WOLF_STATE_DIR:-/etc/wolf}/cfg/config.toml"
WOLF_TZ=$(grep '^WOLF_TZ=' "$SCRIPT_DIR/.env" 2>/dev/null | cut -d= -f2-)
case "$1" in
start)
@@ -1043,12 +1047,13 @@ case "$1" in
echo " Applying: ${APP_KEYS:-none}"
[ -z "$APP_KEYS" ] && exit 0
sudo python3 - "$GAME_DIR" "$WOLF_CFG" $APP_KEYS << 'PYEOF'
sudo python3 - "$GAME_DIR" "$WOLF_CFG" "$WOLF_TZ" $APP_KEYS << 'PYEOF'
import sys, json
games = sys.argv[1].rstrip('/')
cfg = sys.argv[2]
selected = set(sys.argv[3:])
TZ = sys.argv[3].strip()
selected = set(sys.argv[4:])
STD_CAP = ['NET_RAW', 'MKNOD', 'NET_ADMIN']
STD_ENV = ['RUN_SWAY=1', 'GOW_REQUIRED_DEVICES=/dev/input/* /dev/dri/* /dev/nvidia*']
@@ -1132,6 +1137,14 @@ CATALOG = {
),
}
def ensure_tz(env_list):
# Set the container timezone so Steam / EA App / games show local time
# (a wrong/UTC display can confuse EA App's installer). Driven by WOLF_TZ.
e = [x for x in env_list if not x.startswith('TZ=')]
if TZ:
e.append(f'TZ={TZ}')
return e
def make_app_block(app):
host_cfg = {'IpcMode': app['ipc_mode'], 'CapAdd': app['cap_add'],
'Privileged': app['privileged'], 'DeviceCgroupRules': STD_RULES}
@@ -1139,7 +1152,7 @@ def make_app_block(app):
if app['ulimits']: host_cfg['Ulimits'] = app['ulimits']
create_json = json.dumps({'HostConfig': host_cfg}, indent=2)
mounts_str = str(app['mounts']).replace('"', "'")
env_str = str(app['env']).replace('"', "'")
env_str = str(ensure_tz(app['env'])).replace('"', "'")
return (
f"\n"
f" [[profiles.apps]]\n"
@@ -1159,23 +1172,20 @@ def make_app_block(app):
f" type = 'docker'\n"
)
def update_mounts(lines, wolf_name, new_mounts):
# Wolf rewrites config.toml and reformats `mounts` as a MULTI-LINE array,
# so we must replace the entire array (from `mounts =` to its closing `]`),
def update_field(lines, wolf_name, field, new_value):
# Wolf rewrites config.toml and reformats arrays (mounts/env) as MULTI-LINE,
# so we must replace the entire array (from `<field> =` to its closing `]`),
# not just the first line — otherwise the old elements are left orphaned and
# the file becomes invalid TOML. Always rewrite as a single line.
# the file becomes invalid TOML. Always rewrite as a single line, and heal
# any orphaned remnants left by a previously corrupted single-line rewrite.
for i, line in enumerate(lines):
if f"name = '{wolf_name}'" in line:
for j in range(max(0, i-20), min(len(lines), i+20)):
if lines[j].lstrip().startswith('mounts ='):
for j in range(max(0, i-25), min(len(lines), i+25)):
if lines[j].lstrip().startswith(field + ' ='):
indent = lines[j][:len(lines[j]) - len(lines[j].lstrip())]
# End of the array = first line containing ']'.
k = j
while k < len(lines) and ']' not in lines[k]:
k += 1
# Heal any orphaned array remnants left by a previously
# corrupted single-line rewrite (stray '…' elements / a lone
# ']' sitting below the mounts line).
m = k + 1
while m < len(lines):
s = lines[m].lstrip()
@@ -1183,7 +1193,7 @@ def update_mounts(lines, wolf_name, new_mounts):
m += 1
else:
break
lines[j:m] = [f"{indent}mounts = {new_mounts}\n"]
lines[j:m] = [f"{indent}{field} = {new_value}\n"]
return True
return False
@@ -1208,9 +1218,12 @@ for key in list(CATALOG.keys()):
app = CATALOG[key]
wolf_name = app['name']
new_mounts = str(app['mounts']).replace('"', "'")
new_env = str(ensure_tz(app['env'])).replace('"', "'")
already = any(f"name = '{wolf_name}'" in l for l in first_block)
if already:
if update_mounts(lines, wolf_name, new_mounts):
ok = update_field(lines, wolf_name, 'mounts', new_mounts)
ok = update_field(lines, wolf_name, 'env', new_env) or ok
if ok:
updated.append(app['title'])
else:
to_insert.append(make_app_block(app))
@@ -1229,7 +1242,7 @@ elif updated:
f.writelines(lines)
if added: print(f"Added: {', '.join(added)}")
if updated: print(f"Updated mounts: {', '.join(updated)}")
if updated: print(f"Updated mounts + timezone: {', '.join(updated)}")
if not added and not updated:
print('All selected apps already present and up to date')
PYEOF
@@ -1596,12 +1609,13 @@ MEOF
if [ -f "$WOLF_CFG" ]; then
log_info "Injecting selected apps into Wolf config..."
python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" $([[ -n "$_APP_KEYS" ]] && echo "$_APP_KEYS" || echo "steam esde") << 'PYEOF'
python3 - "$GAME_STORAGE_DIR" "$WOLF_CFG" "$SITE_TZ" $([[ -n "$_APP_KEYS" ]] && echo "$_APP_KEYS" || echo "steam esde") << 'PYEOF'
import sys, json
games = sys.argv[1].rstrip('/')
cfg = sys.argv[2]
selected = set(sys.argv[3:]) # app keys chosen by the user
TZ = sys.argv[3].strip()
selected = set(sys.argv[4:]) # app keys chosen by the user
# ── App catalog ───────────────────────────────────────────────────────────────
# Each entry: (wolf_name, title, icon_url, image, mounts, env, cap_add,
@@ -1689,6 +1703,14 @@ CATALOG = {
),
}
def ensure_tz(env_list):
# Set the container timezone so Steam / EA App / games show local time
# (a wrong/UTC display can confuse EA App's installer). Driven by SITE_TZ.
e = [x for x in env_list if not x.startswith('TZ=')]
if TZ:
e.append(f'TZ={TZ}')
return e
def make_app_block(app):
"""Render a [[profiles.apps]] TOML block from a catalog entry."""
host_cfg = {'IpcMode': app['ipc_mode'], 'CapAdd': app['cap_add'],
@@ -1697,7 +1719,7 @@ def make_app_block(app):
if app['ulimits']: host_cfg['Ulimits'] = app['ulimits']
create_json = json.dumps({'HostConfig': host_cfg}, indent=2)
mounts_str = str(app['mounts']).replace('"', "'")
env_str = str(app['env']).replace('"', "'")
env_str = str(ensure_tz(app['env'])).replace('"', "'")
return (
f"\n"
f" [[profiles.apps]]\n"
@@ -1717,23 +1739,20 @@ def make_app_block(app):
f" type = 'docker'\n"
)
def update_mounts(lines, wolf_name, new_mounts):
# Wolf rewrites config.toml and reformats `mounts` as a MULTI-LINE array,
# so we must replace the entire array (from `mounts =` to its closing `]`),
def update_field(lines, wolf_name, field, new_value):
# Wolf rewrites config.toml and reformats arrays (mounts/env) as MULTI-LINE,
# so we must replace the entire array (from `<field> =` to its closing `]`),
# not just the first line — otherwise the old elements are left orphaned and
# the file becomes invalid TOML. Always rewrite as a single line.
# the file becomes invalid TOML. Always rewrite as a single line, and heal
# any orphaned remnants left by a previously corrupted single-line rewrite.
for i, line in enumerate(lines):
if f"name = '{wolf_name}'" in line:
for j in range(max(0, i-20), min(len(lines), i+20)):
if lines[j].lstrip().startswith('mounts ='):
for j in range(max(0, i-25), min(len(lines), i+25)):
if lines[j].lstrip().startswith(field + ' ='):
indent = lines[j][:len(lines[j]) - len(lines[j].lstrip())]
# End of the array = first line containing ']'.
k = j
while k < len(lines) and ']' not in lines[k]:
k += 1
# Heal any orphaned array remnants left by a previously
# corrupted single-line rewrite (stray '…' elements / a lone
# ']' sitting below the mounts line).
m = k + 1
while m < len(lines):
s = lines[m].lstrip()
@@ -1741,7 +1760,7 @@ def update_mounts(lines, wolf_name, new_mounts):
m += 1
else:
break
lines[j:m] = [f"{indent}mounts = {new_mounts}\n"]
lines[j:m] = [f"{indent}{field} = {new_value}\n"]
return True
return False
@@ -1770,8 +1789,11 @@ for key in list(CATALOG.keys()): # preserve display order
wolf_name = app['name']
already = any(f"name = '{wolf_name}'" in l for l in first_block)
new_mounts = str(app['mounts']).replace('"', "'")
new_env = str(ensure_tz(app['env'])).replace('"', "'")
if already:
if update_mounts(lines, wolf_name, new_mounts):
ok = update_field(lines, wolf_name, 'mounts', new_mounts)
ok = update_field(lines, wolf_name, 'env', new_env) or ok
if ok:
updated.append(app['title'])
else:
to_insert.append(make_app_block(app))