Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions dylib/feats/compat_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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, " ")
Expand Down Expand Up @@ -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
(
Expand Down Expand Up @@ -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" \
</dev/null >> "$log" 2>&1 8>&- 9>&- &
np_session_helper_pids="${np_session_helper_pids:+$np_session_helper_pids }$!"
done <<NP_SESSION_HELPERS
$np_feat_helpers
NP_SESSION_HELPERS
}

np_stop_session_helpers() {
# shellcheck disable=SC2086 # split the list of numeric PIDs
set -- $np_session_helper_pids
np_session_helper_pids=""
[ "$#" -gt 0 ] || return 0
kill -TERM "$@" 2>/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
Expand Down Expand Up @@ -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-}"
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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" ] \
Expand Down
1 change: 1 addition & 0 deletions dylib/feats/conf.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
23 changes: 23 additions & 0 deletions dylib/feats/session-helper.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 7 additions & 5 deletions dylib/feats/webpatch.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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];
Expand All @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -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))," \
Expand Down
2 changes: 2 additions & 0 deletions dylib/tests/conf-fixtures/EXPECTED
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions dylib/tests/conf-fixtures/plugins/36-tools.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@
label = Picky
tools = notproton-fex*
tools = notproton-26.3

[NOTPROTON_SESSION]
label = Session helper
session_helper = bin/session helper
12 changes: 12 additions & 0 deletions dylib/tests/featcheck.c
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions dylib/tests/featstagecheck.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"