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
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Fork synchronization

When asked to “sync fork”, update every remote fork branch against the latest
`upstream/main` while preserving each branch's features. Use
`python3 tools/sync-fork.py --push` from a clean or dirty checkout; it operates in
its own worktree, verifies changes and pushes all branch updates atomically.

If it reports a conflict, resolve it in the indicated sync worktree using the
actual upstream and branch behavior, run appropriate feature tests, stage the
resolution, and continue the reported session. The assistant handles conflicts;
do not ask the user to resolve them. Never use blanket ours/theirs resolutions,
force-push or discard local work. Git's recorded resolutions can be reused when
the same conflict recurs, but new conflicts require review.
35 changes: 32 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -636,10 +636,38 @@ $(APPINFO): helpers/appinfo.swift
swiftc -O -target $(ARCH)-apple-macos$(MIN_VER) -o $@ $<
@echo "==> Built $@"

helpers-install: $(ICONMAKER) $(APPINFO)
GAME_HOST := $(OUT_DIR)/game-host
GAME_CLIENT := $(OUT_DIR)/game-client.dylib
GAME_FLAGS := -arch arm64 -arch x86_64 -mmacosx-version-min=$(MIN_VER) $(AVAIL) \
-fobjc-arc -O2 -Wall -Wextra -Wno-unused-parameter -framework AppKit

.PHONY: game-session game-session-check
game-session: $(GAME_HOST) $(GAME_CLIENT)

$(GAME_HOST): helpers/game-host.m
@mkdir -p $(OUT_DIR)
$(CC) $(GAME_FLAGS) -framework CoreGraphics -o $@ $<
codesign --force --sign - $@

$(GAME_CLIENT): helpers/game-client.m
@mkdir -p $(OUT_DIR)
$(CC) $(GAME_FLAGS) -dynamiclib -install_name @rpath/game-client.dylib -o $@ $<
codesign --force --sign - $@

$(OUT_DIR)/game-session-fixture: dylib/tests/game-session-fixture.m
@mkdir -p $(OUT_DIR)
$(CC) -arch $(ARCH) -mmacosx-version-min=$(MIN_VER) -fobjc-arc -O2 -Wall -Wextra \
-framework AppKit -o $@ $<

game-session-check: game-session $(OUT_DIR)/game-session-fixture
python3 dylib/tests/game-session-check.py dylib/feats/compat_run.sh

helpers-install: $(ICONMAKER) $(APPINFO) $(GAME_HOST) $(GAME_CLIENT)
$(call install_atomically,$(ICONMAKER),$(SUPPORT_DIR)/iconmaker)
$(call install_atomically,$(APPINFO),$(SUPPORT_DIR)/appinfo)
@echo "==> Installed: $(SUPPORT_DIR)/{iconmaker,appinfo}"
$(call install_atomically,$(GAME_HOST),$(SUPPORT_DIR)/bridge/game-host)
$(call install_atomically,$(GAME_CLIENT),$(SUPPORT_DIR)/bridge/game-client.dylib)
@echo "==> Installed: $(SUPPORT_DIR)/{iconmaker,appinfo,bridge/game-host,bridge/game-client.dylib}"

dobby:
@mkdir -p $(DOBBY_DIR)
Expand Down Expand Up @@ -710,13 +738,14 @@ bridge:
fonts:
fonts/build.sh

app-payload: $(TARGET) $(OVERLAY_SHIM) $(ICONMAKER) $(APPINFO)
app-payload: $(TARGET) $(OVERLAY_SHIM) $(ICONMAKER) $(APPINFO) $(GAME_HOST) $(GAME_CLIENT)
@mkdir -p "$(APP_PAYLOAD)/signatures/macos.arm64"
@mkdir -p "$(APP_PAYLOAD)/bridge"
cp -f $(TARGET) "$(APP_PAYLOAD)/notproton.dylib"
cp -f $(OVERLAY_SHIM) "$(APP_PAYLOAD)/overlay-shim.dylib"
cp -f $(ICONMAKER) "$(APP_PAYLOAD)/iconmaker"
cp -f $(APPINFO) "$(APP_PAYLOAD)/appinfo"
cp -f $(GAME_HOST) $(GAME_CLIENT) "$(APP_PAYLOAD)/bridge/"
cp -f dylib/feats/compat_run.sh "$(APP_PAYLOAD)/run"
cp -f signatures/macos.arm64/*.json "$(APP_PAYLOAD)/signatures/macos.arm64/"
@set -e; for spec in $(BRIDGE_FILES); do \
Expand Down
2 changes: 2 additions & 0 deletions app/Sources/NotProtonApp/Model/BridgePayload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ enum BridgePayload {
}

static let entries: [Entry] = [
Entry(resource: "game-host", bridgePaths: ["game-host"]),
Entry(resource: "game-client.dylib", bridgePaths: ["game-client.dylib"]),
Entry(resource: "steam.exe", bridgePaths: [
"steam.exe",
]),
Expand Down
2 changes: 2 additions & 0 deletions app/Sources/NotProtonApp/Resources/payload.manifest
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
built game-host
built game-client.dylib
built lsteamclient.dll
built lsteamclient.so
built steam.exe
Expand Down
4 changes: 2 additions & 2 deletions app/Tests/NotProtonAppTests/BridgePayloadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct BridgePayloadTests {
return root
}

@Test("Locate finds all four resources when present")
@Test("Locate finds all resources when present")
func locateFindsAll() throws {
let work = try scratchDirectory("bridge")
defer { try? FileManager.default.removeItem(at: work) }
Expand All @@ -39,7 +39,7 @@ struct BridgePayloadTests {
#expect(throws: StepFailure.self) { try BridgePayload.locate(root: root) }
}

@Test("Stage writes all six bridge paths from four resources")
@Test("Stage writes all bridge paths")
func stageWritesAll() throws {
let work = try scratchDirectory("bridge")
defer { try? FileManager.default.removeItem(at: work) }
Expand Down
8 changes: 4 additions & 4 deletions app/Tests/NotProtonAppTests/PayloadManifestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ struct PayloadManifestTests {
func shippedManifestIsComplete() throws {
let manifest = try PayloadManifest.bundled()

#expect(manifest.entries.count == 20)
#expect(manifest.paths(origin: .built).count == 7)
#expect(manifest.entries.count == 22)
#expect(manifest.paths(origin: .built).count == 9)
#expect(manifest.paths(origin: .patched).count == 3)
#expect(manifest.paths(origin: .valve).count == 10)

Expand Down Expand Up @@ -91,10 +91,10 @@ struct PayloadInspectionTests {
let fex = try #require(SupportedRunners.all.first { $0.flavor == "fex" })
let state = PayloadInspector.inspect(bridge: bridge, builds: [fex])
#expect(state.manifestProblem == nil)
#expect(state.expected == 20)
#expect(state.expected == 22)
#expect(state.present == 10)
#expect(state.missing(origin: .valve).isEmpty)
#expect(state.missing(origin: .built).count == 7)
#expect(state.missing(origin: .built).count == 9)
#expect(state.missing(origin: .patched).count == 3)
#expect(!state.isComplete)
}
Expand Down
24 changes: 24 additions & 0 deletions docs/game-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Single game icon

Game bundles use a native host to keep one game icon in the Dock and application
switcher. Wine UI processes remain interactive accessory applications. Selecting
the game icon activates the first visible window belonging to that session.
Quitting the host stops its Wine prefix. If the host is force quit, the existing
`open -W` watcher in the compatibility script performs prefix cleanup.

`make game-session` builds universal arm64 and x86_64 helpers. `make app-payload`
packages them with the bridge, and the app installs them through `BridgePayload`.
`make helpers-install` also installs them for local development. Game bundles copy
the helpers into `Contents/MacOS`; the Wine child script preserves the overlay and
plugin library settings before adding the session client library.

The compatibility script falls back to the existing Wine launcher when either
helper is unavailable. Set `NOTPROTON_SINGLE_GAME_ICON=0` in a game's launch
options to use that launcher explicitly. `NOTPROTON_HIDE_LAUNCHER_TILE=1` still
hides the game host's icon.

`make game-session-check` covers fallback staging, universal binaries, argument
forwarding, accessory activation, and prefix-scoped shutdown using a native test
client. It does not require Wine or Steam. The host changes macOS application
ownership; automatic Game Mode behavior and third-party window switchers need
separate validation on the target system.
19 changes: 18 additions & 1 deletion dylib/feats/compat_run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2815,6 +2815,12 @@ np_game_names
loader_root="$HOME/Library/Application Support/notproton/launchers/$app_id"
mkdir -p "$loader_root"
loader_app="$loader_root/$bundle_name.app"
np_game_host="$np_support/bridge/game-host"
np_game_client="$np_support/bridge/game-client.dylib"
np_single_icon=0
if [ "${NOTPROTON_SINGLE_GAME_ICON:-1}" = 1 ] && [ -x "$np_game_host" ] && [ -f "$np_game_client" ]; then
np_single_icon=1
fi
find "$loader_root" -maxdepth 1 -name '.build.*' -mmin +60 -exec rm -rf {} + 2>/dev/null || true
loader_work=$(mktemp -d "$loader_root/.build.XXXXXX")
loader_stage="$loader_work/new/$bundle_name.app"
Expand All @@ -2834,7 +2840,8 @@ game_runner=""
display_name_xml=$game_name_xml
localized_arg=""
uielement_arg=""
if [ -z "$user_dllpath" ]; then
# The game host owns the Wine session when the single-icon feature is active.
if [ -z "$user_dllpath" ] && [ "$np_single_icon" != 1 ]; then
game_runner="$loader_root/runner"
game_app="$game_runner/lib/wine/${wine_unix##*/}/wine.app"
# Begone, unwanted Wine processes! Stay out of my dock!
Expand Down Expand Up @@ -2911,11 +2918,21 @@ fi
if [ -n "\$NOTPROTON_FEATURE_DYLIBS" ]; then
export DYLD_INSERT_LIBRARIES="\${DYLD_INSERT_LIBRARIES:+\$DYLD_INSERT_LIBRARIES:}\$NOTPROTON_FEATURE_DYLIBS"
fi
if [ -n "\$NOTPROTON_GAME_CHANNEL" ]; then
client="\$(dirname "\$WINELOADER")/game-client.dylib"
export DYLD_INSERT_LIBRARIES="\${DYLD_INSERT_LIBRARIES:+\$DYLD_INSERT_LIBRARIES:}\$client"
fi
[ -n "\$NOTPROTON_GAME_CWD" ] && cd "\$NOTPROTON_GAME_CWD"
exec "$WINELOADER" "\$@"
LAUNCHER
chmod +x "$loader_macos/launcher"
if [ "$np_single_icon" = 1 ]; then
mv "$loader_macos/launcher" "$loader_macos/wine-launch.sh"
cp "$np_game_host" "$loader_macos/launcher"
cp "$np_game_client" "$loader_macos/game-client.dylib"
chmod +x "$loader_macos/launcher"
fi
fi

bundle_listing() {
(cd "$1" && find . | LC_ALL=C sort | while IFS= read -r p; do
Expand Down
145 changes: 145 additions & 0 deletions dylib/tests/game-session-check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Check bundle staging and native session shutdown without Wine or Steam."""
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import sys
import tempfile
import time
import unittest

ROOT = Path(__file__).resolve().parents[2]
SOURCE = Path(sys.argv.pop(1) if len(sys.argv) > 1 else ROOT / 'dylib/feats/compat_run.sh').read_text()
RECIPE = re.search(r'(cat > "\$loader_macos/launcher" <<LAUNCHER\n.*?)\nfi\n\nbundle_listing\(\)', SOURCE, re.S)[1]


class GameSession(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix='np-game-session-')
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.bundle = self.root / 'Example Game.app'
self.macos = self.bundle / 'Contents/MacOS'
self.macos.mkdir(parents=True)
self.env = os.environ.copy()
for key in ('DYLD_INSERT_LIBRARIES', 'NOTPROTON_GAME_CHANNEL', 'NOTPROTON_FEATURE_DYLIBS',
'STEAM_DYLD_INSERT_LIBRARIES', 'NOTPROTON_GAME_CWD'):
self.env.pop(key, None)
self.env.update(loader_macos=str(self.macos), loader_root=str(self.root),
WINELOADER=str(self.macos / 'wine'), np_game_host=str(ROOT / 'out/game-host'),
np_game_client=str(ROOT / 'out/game-client.dylib'))

def stage(self, enabled):
self.env['np_single_icon'] = str(int(enabled))
subprocess.run(['sh', '-ec', RECIPE], env=self.env, check=True)

def test_fallback_uses_existing_shell_entry(self):
self.stage(False)
self.assertTrue((self.macos / 'launcher').read_text().startswith('#!/bin/sh'))
self.assertFalse((self.macos / 'wine-launch.sh').exists())

def test_host_carries_both_architectures(self):
self.stage(True)
for name in ('launcher', 'game-client.dylib'):
arches = subprocess.check_output(['lipo', '-archs', self.macos / name], text=True).split()
self.assertEqual(set(arches), {'arm64', 'x86_64'})
self.assertTrue((self.macos / 'wine-launch.sh').is_file())

def test_quit_stops_only_the_requested_prefix_and_preserves_arguments(self):
self.check_session(quit_from_host=True)

def test_game_exit_removes_the_host_and_stops_the_prefix(self):
self.check_session(quit_from_host=False)

def test_game_exit_clears_a_lingering_bootstrap(self):
self.check_session(quit_from_host=False, lingering_wrapper=True)

def test_unrelated_window_exit_does_not_end_the_session(self):
self.check_session(quit_from_host=False, lingering_wrapper=True, unrelated_window=True)

def test_crash_before_appkit_clears_a_lingering_bootstrap(self):
self.check_session(quit_from_host=False, lingering_wrapper=True, early_crash=True)

def test_launcher_handoff_keeps_child_alive_until_it_exits(self):
self.check_session(quit_from_host=False, lingering_wrapper=True, handoff=True)

def check_session(self, quit_from_host, lingering_wrapper=False, unrelated_window=False,
early_crash=False, handoff=False):
import plistlib
self.stage(True)
with (self.bundle / 'Contents/Info.plist').open('wb') as stream:
plistlib.dump({'CFBundleExecutable': 'launcher', 'CFBundleName': 'Example Game',
'CFBundlePackageType': 'APPL', 'CFBundleIdentifier': 'com.notproton.test.game'}, stream)
fixture = ROOT / 'out/game-session-fixture'
(self.macos / 'wine').write_bytes(fixture.read_bytes())
(self.macos / 'wine').chmod(0o755)
server = self.root / 'wineserver'
server.write_text('#!/bin/sh\nprintf "%s\\n" "$WINEPREFIX" "$1" > "$NP_GAME_TEST_STOP"\n'
'kill "$(/usr/bin/plutil -extract pid raw -o - "$NP_GAME_TEST_STATE")"\n')
server.chmod(0o755)
state = self.root / 'state.json'
stopped = self.root / 'stopped'
self.env.update(WINESERVER=str(server), WINEPREFIX=str(self.root / 'prefix'),
NP_GAME_TEST_STATE=str(state), NP_GAME_TEST_STOP=str(stopped))
arguments = ['argument with spaces', '-switch', 'literal$argument']
if lingering_wrapper:
child = self.macos / 'Game.exe'
child.write_bytes(fixture.read_bytes())
child.chmod(0o755)
self.env.update(NP_GAME_TEST_CHILD=str(child), NP_GAME_TEST_WINDOW='1')
if early_crash:
self.env['NP_GAME_TEST_EARLY_EXIT'] = '1'
if handoff:
worker = self.macos / 'Worker.exe'
worker.write_bytes(fixture.read_bytes())
worker.chmod(0o755)
self.env['NP_GAME_TEST_HANDOFF'] = str(worker)
arguments = [r'C:\Program Files (x86)\Steam\steam.exe',
str(self.macos / 'Other.exe') if unrelated_window else str(child), *arguments]
host = subprocess.Popen([self.macos / 'launcher', *arguments], env=self.env)
client_pid = None
try:
deadline = time.monotonic() + 15
while not state.exists():
self.assertIsNone(host.poll(), 'host exited before launching the client')
if time.monotonic() >= deadline:
self.fail('client did not publish its state')
time.sleep(0.05)
data = json.loads(state.read_text())
client_pid = data['pid']
self.assertEqual(data['policy'], -1 if early_crash else 1)
self.assertEqual(data['arguments'][1:], arguments)
self.assertIn(str(self.macos / 'game-client.dylib'), data['libraries'])
if lingering_wrapper:
# Allow the real key-window notification to reach the host.
time.sleep(0.5)
self.assertIsNone(host.poll())
if handoff:
time.sleep(1.5)
self.assertIsNone(host.poll(), 'launcher exit must not stop its live child')
if quit_from_host:
subprocess.run([fixture, '--quit', str(host.pid)], check=True)
else:
os.kill(client_pid, signal.SIGTERM)
if unrelated_window:
time.sleep(1.5)
self.assertIsNone(host.poll(), 'an unrelated UI helper must not end the game')
subprocess.run([fixture, '--quit', str(host.pid)], check=True)
self.assertEqual(host.wait(timeout=15), 0)
self.assertEqual(stopped.read_text().splitlines(), [self.env['WINEPREFIX'], '-k'])
finally:
if host.poll() is None:
host.kill()
host.wait()
if client_pid:
try:
os.kill(client_pid, signal.SIGKILL)
except ProcessLookupError:
pass


if __name__ == '__main__':
unittest.main()
Loading
Loading