diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index ca2ab0ed..5f0c9b10 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -446,8 +446,8 @@ NP_CONF_NAMES BEGIN { nkv = 0; records = 0; id = ""; code = ""; detail = "" if (kind == "plugin") { - split("label description description_on section on off depends requires inject env overrides stage tools type choice min max step default suffix hide_crossover", f, " ") - split("1 1 16 1 1 1 1 4 1 16 1 1 8 1 16 1 1 1 1 1 1", m, " ") + split("label description description_on section on off depends requires inject env overrides stage session_helper tools type choice min max step default suffix hide_crossover", f, " ") + split("1 1 16 1 1 1 1 4 1 16 1 1 1 8 1 16 1 1 1 1 1 1", m, " ") split("label", req, " "); nreq = 1 } else { split("label variant src dst build remove", f, " ") @@ -2871,6 +2871,9 @@ else $uielement_arg" > "$loader_contents/Info.plist" fi +# Keep the original loader for helpers, before staging the game bundle. +np_session_wine_loader="$WINELOADER" + # Invokes macOS Game Mode if [ -z "$game_runner" ]; then ( @@ -3070,11 +3073,50 @@ kill_wine_prefix() { fi } +np_session_helper_pids="" + +np_start_session_helpers() { + while IFS= read -r np_session_helper; do + [ -n "$np_session_helper" ] || continue + WINELOADER="$np_session_wine_loader" "$np_session_helper" "$$" "$np_session_wine_loader" "$WINEPREFIX" \ + > "$log" 2>&1 8>&- 9>&- & + np_session_helper_pids="${np_session_helper_pids:+$np_session_helper_pids }$!" + done </dev/null || true + np_session_ticks=0 + while [ "$np_session_ticks" -lt 50 ]; do + np_session_running=0 + for np_session_pid do + if kill -0 "$np_session_pid" 2>/dev/null; then np_session_running=1; fi + done + [ "$np_session_running" -eq 1 ] || break + sleep 0.1 + np_session_ticks=$((np_session_ticks + 1)) + done + for np_session_pid do + kill -KILL "$np_session_pid" 2>/dev/null || true + if wait "$np_session_pid"; then :; else + np_session_status=$? + [ "$np_session_status" -eq 143 ] || np_feat_log "session helper $np_session_pid exited with status $np_session_status" + fi + done +} + # Steam's "Exit Game" and the client shutting the game down both send a # termination signal to this run script, this handles it # shellcheck disable=SC2329 # the trap below invokes this terminate() { np_terminated=1 + np_stop_session_helpers echo "=== termination signal received, killing wine prefix ===" >> "$log" 2>&1 || true kill_wine_prefix [ -n "$open_pid" ] && kill "$open_pid" 2>/dev/null || true @@ -3115,7 +3157,11 @@ np_feat_requires() { done } np_feat_installed() { - [ -z "$(np_feat_first "$1" requires)" ] || [ -n "$(np_feat_requires "$1")" ] + if [ -n "$(np_feat_first "$1" requires)" ] && [ -z "$(np_feat_requires "$1")" ]; then return 1; fi + np_feat_inst_helper=$(np_feat_first "$1" session_helper) + [ -z "$np_feat_inst_helper" ] && return 0 + np_feat_inst_helper=$(np_home_path "$np_feat_inst_helper") + [ -f "$np_feat_inst_helper" ] && [ -x "$np_feat_inst_helper" ] } np_feat_switched() { eval "np_feat_sv=\${$1-}" @@ -3159,6 +3205,8 @@ np_feat_list=$(np_conf_ids "$np_conf_feat_table") np_feat_dylibs="" np_feat_envs="" +np_feat_helpers="" + while IFS= read -r np_feat_key; do [ -n "$np_feat_key" ] || continue if ! np_feat_installed "$np_feat_key"; then @@ -3183,6 +3231,12 @@ while IFS= read -r np_feat_key; do fi fi + np_feat_helper=$(np_feat_first "$np_feat_key" session_helper) + if [ -n "$np_feat_helper" ]; then + np_feat_helpers="$np_feat_helpers +$(np_home_path "$np_feat_helper")" + fi + np_feat_stage=$(np_feat_first "$np_feat_key" stage) if [ -n "$np_feat_stage" ]; then np_feat_stage=$(np_home_path "$np_feat_stage") @@ -3294,6 +3348,10 @@ for launched_app in "$loader_app" ${game_runner:+"$game_app"}; do fi fi done + +trap np_stop_session_helpers EXIT +np_start_session_helpers + if [ -n "$game_runner" ]; then shim="$HOME/Library/Application Support/notproton/overlay-shim.dylib" inserts="$STEAM_DYLD_INSERT_LIBRARIES" @@ -3363,6 +3421,10 @@ open_pid=$! /usr/bin/caffeinate -d -i -w "$open_pid" >> "$log" 2>&1 & status=0 wait "$open_pid" || status=$? + +np_stop_session_helpers +trap - EXIT + echo "=== game exited status=$status, killing wine prefix ===" >> "$log" 2>&1 || true kill_wine_prefix [ -z "$STEAM_COMPAT_DATA_PATH" ] \ diff --git a/dylib/feats/conf.c b/dylib/feats/conf.c index 64ccee00..cdee436c 100644 --- a/dylib/feats/conf.c +++ b/dylib/feats/conf.c @@ -18,6 +18,7 @@ static const np_conf_spec_t kPlugin[] = { { "section", 1, 0 }, { "on", 1, 0 }, { "off", 1, 0 }, { "depends", 1, 0 }, { "requires", 4, 0 }, { "inject", 1, 0 }, { "env", 16, 0 }, { "overrides", 1, 0 }, { "stage", 1, 0 }, + { "session_helper", 1, 0 }, { "tools", 8, 0 }, { "type", 1, 0 }, { "choice", 16, 0 }, { "min", 1, 0 }, { "max", 1, 0 }, { "step", 1, 0 }, { "default", 1, 0 }, diff --git a/dylib/feats/session-helper.md b/dylib/feats/session-helper.md new file mode 100644 index 00000000..512cd75f --- /dev/null +++ b/dylib/feats/session-helper.md @@ -0,0 +1,23 @@ +# Plugin session helpers + +Set `session_helper` once in a plugin configuration to an executable path. +Relative paths are resolved against `$HOME`. The launcher starts the helper +asynchronously alongside the game when the plugin is active and the executable +exists. + +The helper inherits the runner environment, with `WINELOADER` set to the original +Wine loader. It receives these arguments in order: + +1. The launch script's PID. +2. The original Wine loader's absolute path. +3. The Wine prefix's absolute path. + +Standard output and standard error go to the launch log. During cleanup, the +launcher sends SIGTERM and gives the helper up to five seconds to exit before +sending SIGKILL and reaping it. + +The helper should stay in the foreground and manage any child processes it +starts. A child can keep the Wine session open after the game's foreground +windows close if not managed correctly. +For a Windows child process that should end with the Wine session, +launch it as a Wine background process. diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index a717dabc..8fc09f00 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -266,11 +266,11 @@ static int resolve_home_path(char *out, size_t size, const char *home, const cha return snprintf(out, size, "%s/%s", home, rel) < (int)size; } -static int feat_exists(const char *home, const char *rel) { +static int feat_exists(const char *home, const char *rel, int executable) { char path[2048]; struct stat st; if (!resolve_home_path(path, sizeof(path), home, rel)) return 0; - return stat(path, &st) == 0 && S_ISREG(st.st_mode); + return stat(path, &st) == 0 && S_ISREG(st.st_mode) && (!executable || access(path, X_OK) == 0); } typedef struct { np_conf_rec_t *list; int count; } feat_ctx_t; @@ -481,6 +481,7 @@ static void feat_build(void) { const char *type = np_conf_get(rec, "type"); const char *dflt = np_conf_get(rec, "default"); const char *inj = np_conf_get(rec, "inject"); + const char *helper = np_conf_get(rec, "session_helper"); const char *fn, *fv; int it = 0, nreq = 0, ok = 0; char item[NP_CONF_ID_MAX + 32]; @@ -491,12 +492,13 @@ static void feat_build(void) { while (np_conf_next(rec, &it, &fn, &fv)) { if (strcmp(fn, "requires") != 0) continue; nreq++; - if (ok || !feat_exists(home, fv)) continue; + if (ok || !feat_exists(home, fv, 0)) continue; // The first that exists is the one a page without our run script inserts. ok = 1; if (!resolve_home_path(resolved, sizeof(resolved), home, fv)) resolved[0] = 0; } if (!nreq) ok = 1; + if (helper && !feat_exists(home, helper, 1)) ok = 0; len = snprintf(item, sizeof(item), "%s\"%s\":%d", i ? "," : "", rec->id, ok); if (len < 0 || len >= (int)sizeof(item) || !out_put(&ready, item, (size_t)len)) goto done; @@ -515,7 +517,7 @@ static void feat_build(void) { if (*resolved && inj && strcmp(inj, "1") == 0) { if (!out_put(&rows, ",rq:", 4) || !out_jsstr(&rows, resolved)) goto done; } - if ((np_conf_get(rec, "stage") || np_conf_get(rec, "overrides")) + if ((helper || np_conf_get(rec, "stage") || np_conf_get(rec, "overrides")) && !out_put(&rows, ",rn:1", 5)) goto done; if (np_conf_get(rec, "hide_crossover") && !out_put(&rows, ",hc:1", 5)) goto done; if (type) { @@ -1374,7 +1376,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "||{data:String(pf(f(\"DXMT_CONFIG\")||\"\"))}).data:\"\"," \ "pickFps=f=>pfp(f(\"DXMT_CONFIG\")||\"\");" \ /* Without our run script behind it a row can only be what a launch option can carry, - so one asking for a staged DLL or an override is not offered, and neither is one + so one asking for a session helper, staged DLL or override is not offered, and neither is one with nothing to deliver. */ \ "const npRows=ours?MSCXFeat:MSCXFeat.filter(f=>!f.rn&&(f.ty" \ "?/^NOTPROTON_ENV_/.test(f.k):f.rq||(f.ev||[]).length))," \ diff --git a/dylib/tests/conf-fixtures/EXPECTED b/dylib/tests/conf-fixtures/EXPECTED index b79b3317..6a204274 100644 --- a/dylib/tests/conf-fixtures/EXPECTED +++ b/dylib/tests/conf-fixtures/EXPECTED @@ -57,6 +57,8 @@ NOTPROTON_SPACED env PATH_LIKE=a=b=c NOTPROTON_PICKY label Picky NOTPROTON_PICKY tools notproton-fex* NOTPROTON_PICKY tools notproton-26.3 +NOTPROTON_SESSION label Session helper +NOTPROTON_SESSION session_helper bin/session helper == plugins/37-hide-crossover.conf NOTPROTON_FEXMAC label FEXMac diff --git a/dylib/tests/conf-fixtures/plugins/36-tools.conf b/dylib/tests/conf-fixtures/plugins/36-tools.conf index 53812135..20ec40ef 100644 --- a/dylib/tests/conf-fixtures/plugins/36-tools.conf +++ b/dylib/tests/conf-fixtures/plugins/36-tools.conf @@ -5,3 +5,7 @@ label = Picky tools = notproton-fex* tools = notproton-26.3 + +[NOTPROTON_SESSION] +label = Session helper +session_helper = bin/session helper diff --git a/dylib/tests/featcheck.c b/dylib/tests/featcheck.c index d80801c1..5c824f36 100644 --- a/dylib/tests/featcheck.c +++ b/dylib/tests/featcheck.c @@ -125,6 +125,18 @@ int main(void) { make_asset("assets/a.dylib"); ok(strcmp(ready_json(), "{\"NOTPROTON_A\":1}") == 0, "is ready"); + puts("== a session helper needs an executable and the NotProton runner"); + clear_descs(); + write_desc("10-helper.conf", + "[NOTPROTON_HELPER]\nlabel = Helper\nsession_helper = assets/session helper\n"); + ok(strcmp(ready_json(), "{\"NOTPROTON_HELPER\":0}") == 0, "missing helper is not ready"); + make_asset("assets/session helper"); + ok(strcmp(ready_json(), "{\"NOTPROTON_HELPER\":0}") == 0, "non-executable helper is not ready"); + snprintf(cmd, sizeof(cmd), "chmod +x '%s/assets/session helper'", g_home); + ok(system(cmd) == 0, "helper made executable"); + ok(strcmp(ready_json(), "{\"NOTPROTON_HELPER\":1}") == 0, "executable helper is ready"); + ok(strstr(feats_json(), ",rn:1") != NULL, "helper is restricted to NotProton runners"); + puts("== repeated requires are any-of"); clear_descs(); write_desc("10-b.conf", diff --git a/dylib/tests/featstagecheck.sh b/dylib/tests/featstagecheck.sh index 333f79e2..11de1c27 100755 --- a/dylib/tests/featstagecheck.sh +++ b/dylib/tests/featstagecheck.sh @@ -329,5 +329,50 @@ dyld=$(env -i HOME="$work" NOTPROTON_FEATURE_DYLIBS=/f.dylib \ sh -c 'eval "$1"; printf "%s|%s" "$DYLD_INSERT_LIBRARIES" "${NOTPROTON_OVERLAY_SHIM:+shim}"' sh "$inject") ok "plugin dylibs load without the overlay shim" "$dyld" "/f.dylib|" +reset +mkdir -p "$work/helper files" +cat > "$work/helper files/bridge" <<'EOF' +#!/bin/sh +[ "$1" = "$PPID" ] && [ "$2" = "$WINELOADER" ] && [ "$3" = "$WINEPREFIX" ] || exit 1 +[ ! -e /dev/fd/8 ] && [ ! -e /dev/fd/9 ] || exit 2 +trap 'touch "$HOME/helper-stopped"; exit 0' TERM +touch "$HOME/helper-started" +while :; do sleep 0.1; done +EOF +chmod +x "$work/helper files/bridge" +conf 50-test.conf <<'EOF' +[NOTPROTON_HELPER] +label = Helper +session_helper = helper files/bridge +EOF +sed -n '/^np_session_helper_pids=/,/^# Steam.s "Exit Game"/p' "$SRC" > "$work/lifecycle.sh" +# shellcheck disable=SC2016 # expanded by the inner shell +env -i PATH="$PATH" HOME="$work" np_support="$work/Library/Application Support/notproton" \ + log="$work/log" WINEPREFIX="$work/pfx" sh -c ' + set -e + . "$HOME/block.sh" + . "$HOME/lifecycle.sh" + test -z "$np_feat_helpers" + NOTPROTON_HELPER=1 + . "$HOME/block.sh" + np_session_wine_loader="$HOME/original loader" + trap np_stop_session_helpers EXIT + exec 8>"$HOME/prefix-lock" 9>"$HOME/template-lock" + np_start_session_helpers + for attempt in 1 2 3 4 5 6 7 8 9 10; do + [ ! -f "$HOME/helper-started" ] || break + sleep 0.1 + done + test -f "$HOME/helper-started" + pid=$np_session_helper_pids + np_stop_session_helpers + test -f "$HOME/helper-stopped" + ! kill -0 "$pid" 2>/dev/null + chmod -x "$HOME/helper files/bridge" + . "$HOME/block.sh" + test -z "$np_feat_helpers" + ' +echo " ok session helpers honor the toggle, receive runner context and stop on cleanup" + [ "$fail" -eq 0 ] || exit 1 echo "==> featstagecheck: plugins stage into the prefix and extend the overrides"