From afc5c6f66ad12a2ee6547aae30cd8811f3779ef9 Mon Sep 17 00:00:00 2001 From: ElfariaKensington <319984589+ElfariaKensington@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:06:50 +0300 Subject: [PATCH 1/7] Keep game sessions under a single Dock icon Build and deploy a native game host with a universal Wine client helper. Forward activation to session windows and stop the Wine prefix when the host exits. Preserve the existing launcher when helpers are unavailable or the feature is disabled. Validation: native session checks, payload and bridge tests, launcher checks, plugin staging checks, route checks, and environment forwarding checks. --- Makefile | 35 ++++- .../NotProtonApp/Model/BridgePayload.swift | 2 + .../NotProtonApp/Resources/payload.manifest | 2 + .../BridgePayloadTests.swift | 4 +- .../PayloadManifestTests.swift | 8 +- docs/game-session.md | 24 ++++ dylib/feats/compat_run.sh | 16 +++ dylib/tests/game-session-check.py | 98 ++++++++++++++ dylib/tests/game-session-fixture.m | 26 ++++ helpers/game-client.m | 40 ++++++ helpers/game-host.m | 122 ++++++++++++++++++ 11 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 docs/game-session.md create mode 100644 dylib/tests/game-session-check.py create mode 100644 dylib/tests/game-session-fixture.m create mode 100644 helpers/game-client.m create mode 100644 helpers/game-host.m diff --git a/Makefile b/Makefile index 976d3bd5..e1d09686 100644 --- a/Makefile +++ b/Makefile @@ -561,10 +561,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) @@ -635,13 +663,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 \ diff --git a/app/Sources/NotProtonApp/Model/BridgePayload.swift b/app/Sources/NotProtonApp/Model/BridgePayload.swift index 41ceb9dd..080fc2d4 100644 --- a/app/Sources/NotProtonApp/Model/BridgePayload.swift +++ b/app/Sources/NotProtonApp/Model/BridgePayload.swift @@ -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", ]), diff --git a/app/Sources/NotProtonApp/Resources/payload.manifest b/app/Sources/NotProtonApp/Resources/payload.manifest index 1c0f8067..51c6a2ed 100644 --- a/app/Sources/NotProtonApp/Resources/payload.manifest +++ b/app/Sources/NotProtonApp/Resources/payload.manifest @@ -1,3 +1,5 @@ +built game-host +built game-client.dylib built lsteamclient.dll built lsteamclient.so built steam.exe diff --git a/app/Tests/NotProtonAppTests/BridgePayloadTests.swift b/app/Tests/NotProtonAppTests/BridgePayloadTests.swift index 220a6312..8ab136fd 100644 --- a/app/Tests/NotProtonAppTests/BridgePayloadTests.swift +++ b/app/Tests/NotProtonAppTests/BridgePayloadTests.swift @@ -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) } @@ -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) } diff --git a/app/Tests/NotProtonAppTests/PayloadManifestTests.swift b/app/Tests/NotProtonAppTests/PayloadManifestTests.swift index 5289383a..ee013fb5 100644 --- a/app/Tests/NotProtonAppTests/PayloadManifestTests.swift +++ b/app/Tests/NotProtonAppTests/PayloadManifestTests.swift @@ -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) @@ -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) } diff --git a/docs/game-session.md b/docs/game-session.md new file mode 100644 index 00000000..fd32877f --- /dev/null +++ b/docs/game-session.md @@ -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. diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index a1b86b48..998e3ec8 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -1699,6 +1699,12 @@ game_name_xml=$(printf '%s' "$game_name" | sed -e 's/&/\&/g' -e 's//dev/null || true loader_work=$(mktemp -d "$loader_root/.build.XXXXXX") loader_stage="$loader_work/new/$bundle_name.app" @@ -1776,11 +1782,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" "$WINELOADER" "\$@" exit \$? 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 bundle_listing() { (cd "$1" && find . | LC_ALL=C sort | while IFS= read -r p; do diff --git a/dylib/tests/game-session-check.py b/dylib/tests/game-session-check.py new file mode 100644 index 00000000..0bd61e8a --- /dev/null +++ b/dylib/tests/game-session-check.py @@ -0,0 +1,98 @@ +#!/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" < "$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'] + 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) + self.assertEqual(data['arguments'][1:], arguments) + self.assertIn(str(self.macos / 'game-client.dylib'), data['libraries']) + 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() diff --git a/dylib/tests/game-session-fixture.m b/dylib/tests/game-session-fixture.m new file mode 100644 index 00000000..a6d9caf4 --- /dev/null +++ b/dylib/tests/game-session-fixture.m @@ -0,0 +1,26 @@ +#import + +int main(int argc, const char **argv) { + @autoreleasepool { + if (argc == 3 && strcmp(argv[1], "--quit") == 0) { + NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:atoi(argv[2])]; + return app && [app terminate] ? 0 : 1; + } + NSApplication *app = NSApplication.sharedApplication; + [app setActivationPolicy:NSApplicationActivationPolicyRegular]; + dispatch_async(dispatch_get_main_queue(), ^{ + // Repeated promotion must still leave the Wine client off the Dock. + [app setActivationPolicy:NSApplicationActivationPolicyRegular]; + NSDictionary *state = @{ + @"pid": @(getpid()), + @"policy": @(app.activationPolicy), + @"arguments": NSProcessInfo.processInfo.arguments, + @"libraries": NSProcessInfo.processInfo.environment[@"DYLD_INSERT_LIBRARIES"] ?: @"" + }; + NSData *data = [NSJSONSerialization dataWithJSONObject:state options:0 error:nil]; + [data writeToFile:NSProcessInfo.processInfo.environment[@"NP_GAME_TEST_STATE"] atomically:YES]; + }); + [app run]; + } + return 0; +} diff --git a/helpers/game-client.m b/helpers/game-client.m new file mode 100644 index 00000000..11aa8987 --- /dev/null +++ b/helpers/game-client.m @@ -0,0 +1,40 @@ +#import +#import + +static BOOL (*originalPolicy)(id, SEL, NSApplicationActivationPolicy); + +static BOOL clientPolicy(id application, SEL selector, NSApplicationActivationPolicy policy) { + if (policy == NSApplicationActivationPolicyRegular) policy = NSApplicationActivationPolicyAccessory; + return originalPolicy(application, selector, policy); +} + +static void announceClient(void) { + NSString *channel = NSProcessInfo.processInfo.environment[@"NOTPROTON_GAME_CHANNEL"]; + if (!channel.length) return; + [[NSDistributedNotificationCenter defaultCenter] postNotificationName:channel object:nil + userInfo:@{@"pid": @(getpid())} deliverImmediately:YES]; +} + +__attribute__((constructor)) static void setupClient(void) { + @autoreleasepool { + if (!getenv("NOTPROTON_GAME_CHANNEL")) return; + // Wine promotes its UI processes to regular apps as windows appear. + Method method = class_getInstanceMethod(NSApplication.class, @selector(setActivationPolicy:)); + originalPolicy = (void *)method_getImplementation(method); + method_setImplementation(method, (IMP)clientPolicy); + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + announceClient(); + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + [center addObserverForName:NSApplicationDidFinishLaunchingNotification object:nil + queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *notification) { + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + announceClient(); + }]; + [center addObserverForName:NSWindowDidBecomeKeyNotification object:nil + queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *notification) { + announceClient(); + }]; + }); + } +} diff --git a/helpers/game-host.m b/helpers/game-host.m new file mode 100644 index 00000000..d2599430 --- /dev/null +++ b/helpers/game-host.m @@ -0,0 +1,122 @@ +#import +#import + +@interface NPGameHost : NSObject +@property(nonatomic, strong) NSTask *task; +@property(nonatomic, strong) NSMutableDictionary *clients; +@property(nonatomic, copy) NSString *channel; +@property(nonatomic) BOOL stopping; +@end + +@implementation NPGameHost + +- (void)focusGame { + if (self.stopping) return; + NSArray *windows = CFBridgingRelease(CGWindowListCopyWindowInfo( + kCGWindowListOptionOnScreenOnly, kCGNullWindowID)); + for (NSDictionary *window in windows) { + NSNumber *pid = window[(id)kCGWindowOwnerPID]; + NSRunningApplication *client = self.clients[pid]; + if (client && !client.terminated && [window[(id)kCGWindowLayer] intValue] == 0) { + [client activateWithOptions:NSApplicationActivateAllWindows]; + return; + } + } +} + +- (void)registerClient:(NSNotification *)notification { + NSNumber *pid = notification.userInfo[@"pid"]; + if (![pid isKindOfClass:NSNumber.class] || pid.intValue <= 0) return; + NSRunningApplication *client = [NSRunningApplication runningApplicationWithProcessIdentifier:pid.intValue]; + if (client && !client.terminated) self.clients[pid] = client; +} + +- (void)applicationDidBecomeActive:(NSNotification *)notification { + [self focusGame]; +} + +- (BOOL)applicationShouldHandleReopen:(NSApplication *)application hasVisibleWindows:(BOOL)visible { + [self focusGame]; + return NO; +} + +- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)application { + if (self.stopping) return NSTerminateLater; + self.stopping = YES; + // The outer run script also kills this prefix if the host is force quit. + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + NSDictionary *environment = NSProcessInfo.processInfo.environment; + NSString *server = environment[@"WINESERVER"]; + if (server.length && [environment[@"WINEPREFIX"] length]) { + NSTask *stop = [NSTask new]; + stop.executableURL = [NSURL fileURLWithPath:server]; + stop.arguments = @[@"-k"]; + NSError *error = nil; + if ([stop launchAndReturnError:&error]) [stop waitUntilExit]; + else NSLog(@"NotProton could not stop the Wine session: %@", error); + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.task.running) [self.task terminate]; + [application replyToApplicationShouldTerminate:YES]; + }); + }); + return NSTerminateLater; +} + +- (void)applicationDidFinishLaunching:(NSNotification *)notification { + self.clients = [NSMutableDictionary new]; + self.channel = [@"com.notproton.game." stringByAppendingString:NSUUID.UUID.UUIDString]; + [[NSDistributedNotificationCenter defaultCenter] + addObserver:self selector:@selector(registerClient:) name:self.channel object:nil + suspensionBehavior:NSNotificationSuspensionBehaviorDeliverImmediately]; + + NSMenu *menu = [NSMenu new]; + NSMenuItem *item = [NSMenuItem new]; + [menu addItem:item]; + NSMenu *appMenu = [NSMenu new]; + NSString *name = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleDisplayName"] ?: @"Game"; + [appMenu addItemWithTitle:[@"Quit " stringByAppendingString:name] + action:@selector(terminate:) keyEquivalent:@"q"]; + item.submenu = appMenu; + NSApp.mainMenu = menu; + + NSString *script = [NSBundle.mainBundle.bundlePath + stringByAppendingPathComponent:@"Contents/MacOS/wine-launch.sh"]; + self.task = [NSTask new]; + self.task.executableURL = [NSURL fileURLWithPath:@"/bin/sh"]; + NSMutableArray *arguments = [NSMutableArray arrayWithObject:script]; + NSArray *incoming = NSProcessInfo.processInfo.arguments; + if (incoming.count > 1) { + [arguments addObjectsFromArray:[incoming subarrayWithRange:NSMakeRange(1, incoming.count - 1)]]; + } + self.task.arguments = arguments; + NSMutableDictionary *environment = [NSProcessInfo.processInfo.environment mutableCopy]; + environment[@"NOTPROTON_GAME_CHANNEL"] = self.channel; + self.task.environment = environment; + __weak NPGameHost *weakSelf = self; + self.task.terminationHandler = ^(NSTask *task) { + dispatch_async(dispatch_get_main_queue(), ^{ + NPGameHost *host = weakSelf; + if (host && !host.stopping) [NSApp terminate:nil]; + }); + }; + NSError *error = nil; + if (![self.task launchAndReturnError:&error]) { + NSLog(@"NotProton could not launch the Wine session: %@", error); + [NSApp terminate:nil]; + } +} +@end + +int main(void) { + @autoreleasepool { + NSApplication *application = NSApplication.sharedApplication; + NPGameHost *host __attribute__((objc_precise_lifetime)) = [NPGameHost new]; + application.delegate = host; + BOOL hidden = [[NSBundle.mainBundle objectForInfoDictionaryKey:@"LSUIElement"] boolValue]; + [application setActivationPolicy:hidden ? NSApplicationActivationPolicyAccessory + : NSApplicationActivationPolicyRegular]; + [application run]; + } + return 0; +} From 7f8338e19766b8a092b3719f6ecff336e888a7e9 Mon Sep 17 00:00:00 2001 From: ElfariaKensington <319984589+ElfariaKensington@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:55:55 +0300 Subject: [PATCH 2/7] Allow game hosts to finish automatic shutdown Schedule termination and its delegate reply through the main run loop. NSApplication termination can enter a nested event loop, so waiting from a main-queue block could strand the reply and leave a running Dock icon after Wine exited. Add a regression case that exits the game client and waits for the host to disappear. --- dylib/tests/game-session-check.py | 11 ++++++++++- helpers/game-host.m | 24 ++++++++++++++++-------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/dylib/tests/game-session-check.py b/dylib/tests/game-session-check.py index 0bd61e8a..4409be9b 100644 --- a/dylib/tests/game-session-check.py +++ b/dylib/tests/game-session-check.py @@ -49,6 +49,12 @@ def test_host_carries_both_architectures(self): 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 check_session(self, quit_from_host): import plistlib self.stage(True) with (self.bundle / 'Contents/Info.plist').open('wb') as stream: @@ -80,7 +86,10 @@ def test_quit_stops_only_the_requested_prefix_and_preserves_arguments(self): self.assertEqual(data['policy'], 1) self.assertEqual(data['arguments'][1:], arguments) self.assertIn(str(self.macos / 'game-client.dylib'), data['libraries']) - subprocess.run([fixture, '--quit', str(host.pid)], check=True) + if quit_from_host: + subprocess.run([fixture, '--quit', str(host.pid)], check=True) + else: + os.kill(client_pid, signal.SIGTERM) self.assertEqual(host.wait(timeout=15), 0) self.assertEqual(stopped.read_text().splitlines(), [self.env['WINEPREFIX'], '-k']) finally: diff --git a/helpers/game-host.m b/helpers/game-host.m index d2599430..c99e2649 100644 --- a/helpers/game-host.m +++ b/helpers/game-host.m @@ -40,6 +40,15 @@ - (BOOL)applicationShouldHandleReopen:(NSApplication *)application hasVisibleWin return NO; } +- (void)gameTaskDidExit { + if (!self.stopping) [NSApp terminate:nil]; +} + +- (void)finishShutdown { + if (self.task.running) [self.task terminate]; + [NSApp replyToApplicationShouldTerminate:YES]; +} + - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)application { if (self.stopping) return NSTerminateLater; self.stopping = YES; @@ -55,10 +64,8 @@ - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)appli if ([stop launchAndReturnError:&error]) [stop waitUntilExit]; else NSLog(@"NotProton could not stop the Wine session: %@", error); } - dispatch_async(dispatch_get_main_queue(), ^{ - if (self.task.running) [self.task terminate]; - [application replyToApplicationShouldTerminate:YES]; - }); + [self performSelectorOnMainThread:@selector(finishShutdown) withObject:nil + waitUntilDone:NO modes:@[NSRunLoopCommonModes]]; }); return NSTerminateLater; } @@ -95,10 +102,11 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { self.task.environment = environment; __weak NPGameHost *weakSelf = self; self.task.terminationHandler = ^(NSTask *task) { - dispatch_async(dispatch_get_main_queue(), ^{ - NPGameHost *host = weakSelf; - if (host && !host.stopping) [NSApp terminate:nil]; - }); + // terminate: may run a nested event loop while awaiting the delegate's reply. + // Keep it outside a main-queue block so shutdown callbacks can run. + NPGameHost *host = weakSelf; + [host performSelectorOnMainThread:@selector(gameTaskDidExit) withObject:nil + waitUntilDone:NO modes:@[NSRunLoopCommonModes]]; }; NSError *error = nil; if (![self.task launchAndReturnError:&error]) { From c5c0cebb682878436284b50d2fd41493104c4406 Mon Sep 17 00:00:00 2001 From: ElfariaKensington <319984589+ElfariaKensington@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:05:00 +0300 Subject: [PATCH 3/7] End game sessions when the primary windowed process exits --- dylib/tests/game-session-check.py | 23 ++++++++++++++- dylib/tests/game-session-fixture.m | 21 ++++++++++++++ helpers/game-client.m | 10 +++---- helpers/game-host.m | 46 +++++++++++++++++++++++++++++- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/dylib/tests/game-session-check.py b/dylib/tests/game-session-check.py index 4409be9b..1cc03371 100644 --- a/dylib/tests/game-session-check.py +++ b/dylib/tests/game-session-check.py @@ -54,7 +54,13 @@ def test_quit_stops_only_the_requested_prefix_and_preserves_arguments(self): def test_game_exit_removes_the_host_and_stops_the_prefix(self): self.check_session(quit_from_host=False) - def check_session(self, quit_from_host): + 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 check_session(self, quit_from_host, lingering_wrapper=False, unrelated_window=False): import plistlib self.stage(True) with (self.bundle / 'Contents/Info.plist').open('wb') as stream: @@ -72,6 +78,13 @@ def check_session(self, quit_from_host): 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') + 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: @@ -86,10 +99,18 @@ def check_session(self, quit_from_host): self.assertEqual(data['policy'], 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 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: diff --git a/dylib/tests/game-session-fixture.m b/dylib/tests/game-session-fixture.m index a6d9caf4..4a84b2f0 100644 --- a/dylib/tests/game-session-fixture.m +++ b/dylib/tests/game-session-fixture.m @@ -8,9 +8,30 @@ int main(int argc, const char **argv) { } NSApplication *app = NSApplication.sharedApplication; [app setActivationPolicy:NSApplicationActivationPolicyRegular]; + NSString *childPath = NSProcessInfo.processInfo.environment[@"NP_GAME_TEST_CHILD"]; + if (childPath.length) { + // Mimic Wine's bootstrap: the real GUI child can exit while this + // process remains alive waiting on background helper processes. + NSTask *child = [NSTask new]; + child.executableURL = [NSURL fileURLWithPath:childPath]; + child.arguments = [NSProcessInfo.processInfo.arguments subarrayWithRange:NSMakeRange(1, argc - 1)]; + NSMutableDictionary *environment = [NSProcessInfo.processInfo.environment mutableCopy]; + [environment removeObjectForKey:@"NP_GAME_TEST_CHILD"]; + child.environment = environment; + if (![child launchAndReturnError:nil]) return 1; + [app run]; + return 0; + } dispatch_async(dispatch_get_main_queue(), ^{ // Repeated promotion must still leave the Wine client off the Dock. [app setActivationPolicy:NSApplicationActivationPolicyRegular]; + if (getenv("NP_GAME_TEST_WINDOW")) { + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(0, 0, 240, 100) + styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO]; + window.releasedWhenClosed = NO; + [window makeKeyAndOrderFront:nil]; + [app activateIgnoringOtherApps:YES]; + } NSDictionary *state = @{ @"pid": @(getpid()), @"policy": @(app.activationPolicy), diff --git a/helpers/game-client.m b/helpers/game-client.m index 11aa8987..e99cfcff 100644 --- a/helpers/game-client.m +++ b/helpers/game-client.m @@ -8,11 +8,11 @@ static BOOL clientPolicy(id application, SEL selector, NSApplicationActivationPo return originalPolicy(application, selector, policy); } -static void announceClient(void) { +static void announceClient(BOOL windowed) { NSString *channel = NSProcessInfo.processInfo.environment[@"NOTPROTON_GAME_CHANNEL"]; if (!channel.length) return; [[NSDistributedNotificationCenter defaultCenter] postNotificationName:channel object:nil - userInfo:@{@"pid": @(getpid())} deliverImmediately:YES]; + userInfo:@{@"pid": @(getpid()), @"executable": NSProcessInfo.processInfo.processName, @"windowed": @(windowed)} deliverImmediately:YES]; } __attribute__((constructor)) static void setupClient(void) { @@ -24,16 +24,16 @@ static void announceClient(void) { method_setImplementation(method, (IMP)clientPolicy); dispatch_async(dispatch_get_main_queue(), ^{ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; - announceClient(); + announceClient(NO); NSNotificationCenter *center = NSNotificationCenter.defaultCenter; [center addObserverForName:NSApplicationDidFinishLaunchingNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *notification) { [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; - announceClient(); + announceClient(NO); }]; [center addObserverForName:NSWindowDidBecomeKeyNotification object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *notification) { - announceClient(); + announceClient(YES); }]; }); } diff --git a/helpers/game-host.m b/helpers/game-host.m index c99e2649..cf01d608 100644 --- a/helpers/game-host.m +++ b/helpers/game-host.m @@ -6,6 +6,10 @@ @interface NPGameHost : NSObject @property(nonatomic, strong) NSMutableDictionary *clients; @property(nonatomic, copy) NSString *channel; @property(nonatomic) BOOL stopping; +@property(nonatomic, copy) NSString *gameExecutable; +@property(nonatomic, strong) NSMutableDictionary *windowWatches; +@property(nonatomic, strong) NSNumber *primaryWindowPID; +@property(nonatomic) BOOL primaryExited; @end @implementation NPGameHost @@ -28,7 +32,32 @@ - (void)registerClient:(NSNotification *)notification { NSNumber *pid = notification.userInfo[@"pid"]; if (![pid isKindOfClass:NSNumber.class] || pid.intValue <= 0) return; NSRunningApplication *client = [NSRunningApplication runningApplicationWithProcessIdentifier:pid.intValue]; - if (client && !client.terminated) self.clients[pid] = client; + if (!client || client.terminated) return; + self.clients[pid] = client; + if (![notification.userInfo[@"windowed"] boolValue] || self.windowWatches[pid]) return; + NSString *executable = notification.userInfo[@"executable"]; + if ([executable isKindOfClass:NSString.class] && + [executable caseInsensitiveCompare:self.gameExecutable] == NSOrderedSame) + self.primaryWindowPID = pid; + dispatch_source_t watch = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC, + (uintptr_t)pid.intValue, DISPATCH_PROC_EXIT, dispatch_get_main_queue()); + if (!watch) return; + self.windowWatches[pid] = watch; + __weak NPGameHost *weakSelf = self; + dispatch_source_set_event_handler(watch, ^{ + NPGameHost *host = weakSelf; + if (!host) return; + dispatch_source_t ended = host.windowWatches[pid]; + if (ended) dispatch_source_cancel(ended); + [host.windowWatches removeObjectForKey:pid]; + if ([pid isEqual:host.primaryWindowPID]) host.primaryExited = YES; + // Other windowed clients may be a launcher handing off to the game. + // Never treat a closed/minimized window as an exited process. + [NSObject cancelPreviousPerformRequestsWithTarget:host selector:@selector(checkGameExit) object:nil]; + [host performSelector:@selector(checkGameExit) withObject:nil afterDelay:1 + inModes:@[NSRunLoopCommonModes]]; + }); + dispatch_resume(watch); } - (void)applicationDidBecomeActive:(NSNotification *)notification { @@ -40,6 +69,11 @@ - (BOOL)applicationShouldHandleReopen:(NSApplication *)application hasVisibleWin return NO; } +- (void)checkGameExit { + if (!self.stopping && self.primaryExited && self.windowWatches.count == 0) + [NSApp terminate:nil]; +} + - (void)gameTaskDidExit { if (!self.stopping) [NSApp terminate:nil]; } @@ -72,6 +106,16 @@ - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)appli - (void)applicationDidFinishLaunching:(NSNotification *)notification { self.clients = [NSMutableDictionary new]; + self.windowWatches = [NSMutableDictionary new]; + self.gameExecutable = @""; + for (NSString *argument in [NSProcessInfo.processInfo.arguments subarrayWithRange: + NSMakeRange(1, NSProcessInfo.processInfo.arguments.count - 1)]) { + NSString *name = [[argument stringByReplacingOccurrencesOfString:@"\\" withString:@"/"] lastPathComponent]; + if ([name.pathExtension caseInsensitiveCompare:@"exe"] == NSOrderedSame && + [name caseInsensitiveCompare:@"steam.exe"] != NSOrderedSame) { + self.gameExecutable = name; break; + } + } self.channel = [@"com.notproton.game." stringByAppendingString:NSUUID.UUID.UUIDString]; [[NSDistributedNotificationCenter defaultCenter] addObserver:self selector:@selector(registerClient:) name:self.channel object:nil From 73c8bf916e2d952c1b743838e28f13dfc4ca4fcc Mon Sep 17 00:00:00 2001 From: ElfariaKensington <319984589+ElfariaKensington@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:34:49 +0300 Subject: [PATCH 4/7] Track game processes before UI startup and retain launcher handoffs --- dylib/tests/game-session-check.py | 21 +++++++++++++-- dylib/tests/game-session-fixture.m | 19 +++++++++++++ helpers/game-client.m | 20 +++++++++++++- helpers/game-host.m | 43 +++++++++++++++++++----------- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/dylib/tests/game-session-check.py b/dylib/tests/game-session-check.py index 1cc03371..85b78ef9 100644 --- a/dylib/tests/game-session-check.py +++ b/dylib/tests/game-session-check.py @@ -60,7 +60,14 @@ def test_game_exit_clears_a_lingering_bootstrap(self): 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 check_session(self, quit_from_host, lingering_wrapper=False, unrelated_window=False): + 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: @@ -83,6 +90,13 @@ def check_session(self, quit_from_host, lingering_wrapper=False, unrelated_windo 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) @@ -96,13 +110,16 @@ def check_session(self, quit_from_host, lingering_wrapper=False, unrelated_windo time.sleep(0.05) data = json.loads(state.read_text()) client_pid = data['pid'] - self.assertEqual(data['policy'], 1) + 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: diff --git a/dylib/tests/game-session-fixture.m b/dylib/tests/game-session-fixture.m index 4a84b2f0..127c66eb 100644 --- a/dylib/tests/game-session-fixture.m +++ b/dylib/tests/game-session-fixture.m @@ -6,6 +6,25 @@ int main(int argc, const char **argv) { NSRunningApplication *app = [NSRunningApplication runningApplicationWithProcessIdentifier:atoi(argv[2])]; return app && [app terminate] ? 0 : 1; } + NSString *handoff = NSProcessInfo.processInfo.environment[@"NP_GAME_TEST_HANDOFF"]; + if (handoff.length && !NSProcessInfo.processInfo.environment[@"NP_GAME_TEST_CHILD"]) { + NSTask *child = [NSTask new]; + child.executableURL = [NSURL fileURLWithPath:handoff]; + child.arguments = [NSProcessInfo.processInfo.arguments subarrayWithRange:NSMakeRange(1, argc - 1)]; + NSMutableDictionary *environment = [NSProcessInfo.processInfo.environment mutableCopy]; + [environment removeObjectForKey:@"NP_GAME_TEST_HANDOFF"]; + child.environment = environment; + return [child launchAndReturnError:nil] ? 0 : 1; + } + if (getenv("NP_GAME_TEST_EARLY_EXIT") && !getenv("NP_GAME_TEST_CHILD")) { + // No NSApplication, LaunchServices registration, or window at all. + NSDictionary *state = @{@"pid": @(getpid()), @"policy": @(-1), + @"arguments": NSProcessInfo.processInfo.arguments, + @"libraries": NSProcessInfo.processInfo.environment[@"DYLD_INSERT_LIBRARIES"] ?: @""}; + [[NSJSONSerialization dataWithJSONObject:state options:0 error:nil] + writeToFile:NSProcessInfo.processInfo.environment[@"NP_GAME_TEST_STATE"] atomically:YES]; + for (;;) pause(); + } NSApplication *app = NSApplication.sharedApplication; [app setActivationPolicy:NSApplicationActivationPolicyRegular]; NSString *childPath = NSProcessInfo.processInfo.environment[@"NP_GAME_TEST_CHILD"]; diff --git a/helpers/game-client.m b/helpers/game-client.m index e99cfcff..6446fbf8 100644 --- a/helpers/game-client.m +++ b/helpers/game-client.m @@ -1,5 +1,6 @@ #import #import +#include static BOOL (*originalPolicy)(id, SEL, NSApplicationActivationPolicy); @@ -8,16 +9,33 @@ static BOOL clientPolicy(id application, SEL selector, NSApplicationActivationPo return originalPolicy(application, selector, policy); } +static NSString *processImage(void) { + // Wine can rewrite its process name after dylib constructors run. Read the + // launch arguments directly instead of caching an early "wine" identity. + int argc = *_NSGetArgc(); + char **argv = *_NSGetArgv(); + for (int i = 0; i < argc; ++i) { + NSString *argument = [NSString stringWithUTF8String:argv[i]]; + if (argument && [argument.pathExtension caseInsensitiveCompare:@"exe"] == NSOrderedSame) + return argument; + } + return NSProcessInfo.processInfo.processName; +} + static void announceClient(BOOL windowed) { NSString *channel = NSProcessInfo.processInfo.environment[@"NOTPROTON_GAME_CHANNEL"]; if (!channel.length) return; + NSString *image = processImage(); + NSString *executable = [[image stringByReplacingOccurrencesOfString:@"\\" withString:@"/"] lastPathComponent]; [[NSDistributedNotificationCenter defaultCenter] postNotificationName:channel object:nil - userInfo:@{@"pid": @(getpid()), @"executable": NSProcessInfo.processInfo.processName, @"windowed": @(windowed)} deliverImmediately:YES]; + userInfo:@{@"pid": @(getpid()), @"executable": executable, @"windowed": @(windowed), @"image": image} deliverImmediately:YES]; } __attribute__((constructor)) static void setupClient(void) { @autoreleasepool { if (!getenv("NOTPROTON_GAME_CHANNEL")) return; + // Register before AppKit starts: startup failures may never create a window. + announceClient(NO); // Wine promotes its UI processes to regular apps as windows appear. Method method = class_getInstanceMethod(NSApplication.class, @selector(setActivationPolicy:)); originalPolicy = (void *)method_getImplementation(method); diff --git a/helpers/game-host.m b/helpers/game-host.m index cf01d608..44ac857e 100644 --- a/helpers/game-host.m +++ b/helpers/game-host.m @@ -7,8 +7,8 @@ @interface NPGameHost : NSObject @property(nonatomic, copy) NSString *channel; @property(nonatomic) BOOL stopping; @property(nonatomic, copy) NSString *gameExecutable; -@property(nonatomic, strong) NSMutableDictionary *windowWatches; -@property(nonatomic, strong) NSNumber *primaryWindowPID; +@property(nonatomic, strong) NSMutableDictionary *processWatches; +@property(nonatomic, strong) NSNumber *primaryPID; @property(nonatomic) BOOL primaryExited; @end @@ -32,26 +32,39 @@ - (void)registerClient:(NSNotification *)notification { NSNumber *pid = notification.userInfo[@"pid"]; if (![pid isKindOfClass:NSNumber.class] || pid.intValue <= 0) return; NSRunningApplication *client = [NSRunningApplication runningApplicationWithProcessIdentifier:pid.intValue]; - if (!client || client.terminated) return; - self.clients[pid] = client; - if (![notification.userInfo[@"windowed"] boolValue] || self.windowWatches[pid]) return; + if (client && !client.terminated) self.clients[pid] = client; + // A process can fail before registering with LaunchServices or making a + // window. Its lifetime still belongs to the session. NSString *executable = notification.userInfo[@"executable"]; - if ([executable isKindOfClass:NSString.class] && - [executable caseInsensitiveCompare:self.gameExecutable] == NSOrderedSame) - self.primaryWindowPID = pid; + if (![executable isKindOfClass:NSString.class] || + [executable.pathExtension caseInsensitiveCompare:@"exe"] != NSOrderedSame) return; + BOOL primary = [executable caseInsensitiveCompare:self.gameExecutable] == NSOrderedSame; + NSString *image = notification.userInfo[@"image"]; + NSString *path = [image isKindOfClass:NSString.class] ? + [[image stringByReplacingOccurrencesOfString:@"\\" withString:@"/"] lowercaseString] : @""; + NSString *name = executable.lowercaseString; + BOOL backgroundHelper = [path containsString:@"/windows/"] || + [name hasPrefix:@"unitycrashhandler"] || [name hasPrefix:@"crashreportclient"] || + [name isEqualToString:@"winedbg.exe"] || [name isEqualToString:@"werfault.exe"]; + if (!primary && ([name isEqualToString:@"steam.exe"] || + (backgroundHelper && ![notification.userInfo[@"windowed"] boolValue]))) return; + if (primary) self.primaryPID = pid; + if (self.processWatches[pid]) return; dispatch_source_t watch = dispatch_source_create(DISPATCH_SOURCE_TYPE_PROC, (uintptr_t)pid.intValue, DISPATCH_PROC_EXIT, dispatch_get_main_queue()); if (!watch) return; - self.windowWatches[pid] = watch; + self.processWatches[pid] = watch; + NSLog(@"NotProton session: tracking %@ pid=%@ primary=%d", executable, pid, primary); __weak NPGameHost *weakSelf = self; dispatch_source_set_event_handler(watch, ^{ NPGameHost *host = weakSelf; if (!host) return; - dispatch_source_t ended = host.windowWatches[pid]; + dispatch_source_t ended = host.processWatches[pid]; if (ended) dispatch_source_cancel(ended); - [host.windowWatches removeObjectForKey:pid]; - if ([pid isEqual:host.primaryWindowPID]) host.primaryExited = YES; - // Other windowed clients may be a launcher handing off to the game. + [host.processWatches removeObjectForKey:pid]; + NSLog(@"NotProton session: process exited pid=%@", pid); + if ([pid isEqual:host.primaryPID]) host.primaryExited = YES; + // Keep child executables alive across launcher-to-game handoffs. // Never treat a closed/minimized window as an exited process. [NSObject cancelPreviousPerformRequestsWithTarget:host selector:@selector(checkGameExit) object:nil]; [host performSelector:@selector(checkGameExit) withObject:nil afterDelay:1 @@ -70,7 +83,7 @@ - (BOOL)applicationShouldHandleReopen:(NSApplication *)application hasVisibleWin } - (void)checkGameExit { - if (!self.stopping && self.primaryExited && self.windowWatches.count == 0) + if (!self.stopping && self.primaryExited && self.processWatches.count == 0) [NSApp terminate:nil]; } @@ -106,7 +119,7 @@ - (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)appli - (void)applicationDidFinishLaunching:(NSNotification *)notification { self.clients = [NSMutableDictionary new]; - self.windowWatches = [NSMutableDictionary new]; + self.processWatches = [NSMutableDictionary new]; self.gameExecutable = @""; for (NSString *argument in [NSProcessInfo.processInfo.arguments subarrayWithRange: NSMakeRange(1, NSProcessInfo.processInfo.arguments.count - 1)]) { From 78354a465e21fc907a3291bdf3a1945a3ac7628d Mon Sep 17 00:00:00 2001 From: ElfariaKensington <319984589+ElfariaKensington@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:33:59 +0300 Subject: [PATCH 5/7] Add repeatable, isolated fork branch synchronization --- AGENTS.md | 13 +++ tools/fork-sync.md | 24 ++++++ tools/sync-fork.py | 145 ++++++++++++++++++++++++++++++++++ tools/tests/sync-fork-test.py | 49 ++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 AGENTS.md create mode 100644 tools/fork-sync.md create mode 100755 tools/sync-fork.py create mode 100644 tools/tests/sync-fork-test.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..de070bab --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/tools/fork-sync.md b/tools/fork-sync.md new file mode 100644 index 00000000..b89551d0 --- /dev/null +++ b/tools/fork-sync.md @@ -0,0 +1,24 @@ +# Fork synchronization + +Run `python3 tools/sync-fork.py --push` to fetch `origin` and `upstream`, merge +`upstream/main` into every remote fork branch in an isolated worktree, run the +available compatibility, launcher and feature checks, and push the verified +results together with an atomic non-force push. Local branch checkouts and their +uncommitted changes are not modified. The remote's branch list and tips are +checked again immediately before pushing; if they changed, restart the sync. + +Without `--push`, the command prepares and verifies the results only. It prints +its session path; publish them later using `--continue SESSION --push`. + +Git's rerere cache reuses previously recorded conflict resolutions. If a new +conflict appears, the script stops before pushing anything and prints the +isolated checkout and session path. Resolve and stage the conflict in that +checkout, verify the feature, and resume with `--continue SESSION --push`. +For a failed test, commit the correction in that checkout before continuing. +The session and results survive interruption; successfully pushed sessions +remove the temporary worktree. No merge strategy can safely resolve every future +semantic conflict without review. + +`python3 tools/tests/sync-fork-test.py` tests the workflow against temporary +local remotes, including a conflict, continuation, atomic publishing, feature +history preservation, and isolation of a dirty working checkout. diff --git a/tools/sync-fork.py b/tools/sync-fork.py new file mode 100755 index 00000000..807f2cef --- /dev/null +++ b/tools/sync-fork.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Merge upstream's default branch into every fork branch without touching checkouts.""" +import argparse +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time +import uuid + + +def run(*args, cwd=None, check=True, capture=False): + result = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None) + if check and result.returncode: + raise RuntimeError(f"Command failed ({result.returncode}): {' '.join(args)}\n{result.stderr or ''}") + return result.stdout.strip() if capture else result.returncode + + +def git(*args, cwd=None, **kw): + return run('git', *args, cwd=cwd, **kw) + + +def save(path, state): + temp = path.with_suffix('.new') + temp.write_text(json.dumps(state, indent=2) + '\n') + os.replace(temp, path) + + +def checks(wd): + git('diff', '--check', 'HEAD^1', 'HEAD', cwd=wd) + launcher = wd / 'dylib/feats/compat_run.sh' + if launcher.exists(): run('sh', '-n', str(launcher)) + makefile = wd / 'Makefile' + if not makefile.exists(): return + text = makefile.read_text() + wanted = ['compatsvc-check', 'panel-behavior', 'scriptcheck', 'launchercheck', + 'bundlecheck', 'gameenv-check', 'display-check', 'cursor-check', + 'launchperfcheck', 'runnervariantcheck', 'confcheck'] + targets = [name for name in wanted if re.search(r'^' + re.escape(name) + r'\s*:', text, re.M)] + if targets: run('make', *targets, cwd=wd) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--push', action='store_true', help='Push all verified branch updates atomically') + parser.add_argument('--continue', dest='resume', type=Path, metavar='SESSION', help='Resume a resolved conflict') + args = parser.parse_args() + repo = Path(git('rev-parse', '--show-toplevel', capture=True)) + common = Path(git('rev-parse', '--path-format=absolute', '--git-common-dir', capture=True)) + if args.resume: + session = args.resume.resolve() + if session.parent != (common / 'notproton-sync').resolve(): + raise RuntimeError('Session must be inside this repository’s notproton-sync directory') + statefile = session / 'state.json' + state = json.loads(statefile.read_text()) + wd = session / 'checkout' + else: + remotes = git('remote', cwd=repo, capture=True).splitlines() + if 'origin' not in remotes: raise RuntimeError('Missing fork remote origin') + if 'upstream' not in remotes: + git('remote', 'add', 'upstream', 'https://github.com/NotProtonNot/NotProton.git', cwd=repo) + git('fetch', '--prune', 'origin', cwd=repo) + git('fetch', 'upstream', cwd=repo) + target = git('rev-parse', 'upstream/main^{commit}', cwd=repo, capture=True) + refs = git('for-each-ref', '--format=%(refname) %(objectname) %(symref)', 'refs/remotes/origin/', + cwd=repo, capture=True).splitlines() + branches = [] + for line in refs: + fields = line.split() + if len(fields) != 2: continue # Exclude the symbolic origin/HEAD. + name = fields[0].removeprefix('refs/remotes/origin/') + branches.append({'name': name, 'old': fields[1]}) + if not branches: raise RuntimeError('No origin branches were found') + session = common / 'notproton-sync' / (time.strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:8]) + session.mkdir(parents=True) + wd = session / 'checkout' + statefile = session / 'state.json' + state = {'target': target, 'branches': branches, 'index': 0, 'results': {}, 'in_merge': False} + save(statefile, state) + git('worktree', 'add', '--detach', str(wd), branches[0]['old'], cwd=repo) + print(f"Sync session: {session}\nUpstream commit: {state['target']}", flush=True) + while state['index'] < len(state['branches']): + branch = state['branches'][state['index']] + name, old = branch['name'], branch['old'] + print(f"Checking {name}", flush=True) + if state.get('pending_branch') == name: + if git('status', '--porcelain', cwd=wd, capture=True): + raise RuntimeError(f'Commit verification fixes in {wd}, then rerun --continue {session}') + elif state['in_merge']: + if git('diff', '--name-only', '--diff-filter=U', cwd=wd, capture=True): + raise RuntimeError(f'Unresolved merge in {wd}; resolve and stage it, then rerun with --continue {session}') + git('-c', 'rerere.enabled=true', '-c', 'core.editor=true', 'merge', '--continue', cwd=wd) + state['in_merge'] = False + else: + if git('status', '--porcelain', cwd=wd, capture=True): + raise RuntimeError(f'The sync checkout has unexpected changes: {wd}') + git('switch', '--detach', old, cwd=wd) + if git('merge-base', '--is-ancestor', state['target'], old, cwd=wd, check=False) != 0: + state['in_merge'] = True + save(statefile, state) + rc = git('-c', 'rerere.enabled=true', '-c', 'rerere.autoupdate=true', + '-c', 'core.editor=true', 'merge', '--no-edit', '--no-ff', state['target'], cwd=wd, check=False) + if rc: + raise RuntimeError(f'Merge needs attention in {wd}; resolve and stage it, then rerun with --continue {session}') + state['in_merge'] = False + state['pending_branch'] = name + save(statefile, state) + new = git('rev-parse', 'HEAD', cwd=wd, capture=True) + if git('merge-base', '--is-ancestor', old, new, cwd=wd, check=False): + raise RuntimeError(f'{name} would discard existing history') + if new != old: checks(wd) + state['results'][name] = new + state.pop('pending_branch', None) + state['index'] += 1 + save(statefile, state) + if args.push: + git('fetch', '--prune', 'origin', cwd=repo) + current = git('for-each-ref', '--format=%(refname) %(objectname) %(symref)', 'refs/remotes/origin/', cwd=repo, capture=True).splitlines() + observed = {parts[0].removeprefix('refs/remotes/origin/'): parts[1] for line in current if len(parts := line.split()) == 2} + expected = {branch['name']: branch['old'] for branch in state['branches']} + if observed != expected: raise RuntimeError('Fork branches changed during sync; start again from their new tips') + refspecs = [f"{state['results'][b['name']]}:refs/heads/{b['name']}" for b in state['branches']] + # The explicit credential helper avoids macOS keychain picking an unrelated GitHub account. + if shutil.which('gh') and subprocess.run(['gh', 'auth', 'status'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0: + git('-c', 'credential.helper=', '-c', 'credential.helper=!gh auth git-credential', + 'push', '--atomic', 'origin', *refspecs, cwd=repo) + else: git('push', '--atomic', 'origin', *refspecs, cwd=repo) + print('All fork branches pushed atomically.', flush=True) + else: + print('Verified locally; rerun with --continue ' + str(session) + ' --push to publish.', flush=True) + return + git('worktree', 'remove', '--force', str(wd), cwd=repo) + state['pushed'] = True + save(statefile, state) + + +if __name__ == '__main__': + try: main() + except (RuntimeError, OSError, subprocess.SubprocessError) as error: + print(f'sync-fork: {error}', file=sys.stderr) + sys.exit(1) diff --git a/tools/tests/sync-fork-test.py b/tools/tests/sync-fork-test.py new file mode 100644 index 00000000..48a2d0e3 --- /dev/null +++ b/tools/tests/sync-fork-test.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Exercise atomic fork sync and conflict resumption using disposable Git repositories.""" +from pathlib import Path +import subprocess +import tempfile + +SCRIPT = Path(__file__).resolve().parents[1] / 'sync-fork.py' +def cmd(*args, cwd=None, check=True): + return subprocess.run(args, cwd=cwd, text=True, capture_output=True, check=check) +def git(repo, *args, check=True): return cmd('git', *args, cwd=repo, check=check) +def write(repo, name, text): + path = repo / name; path.write_text(text) +def commit(repo, message): + git(repo, 'add', '.'); git(repo, 'commit', '-m', message) +with tempfile.TemporaryDirectory(prefix='np-sync-test-') as tmp: + root=Path(tmp); origin=root/'origin.git'; upstream=root/'upstream.git'; seed=root/'seed'; local=root/'local' + cmd('git','init','--bare',str(origin)); cmd('git','init','--bare',str(upstream)); cmd('git','init','-b','main',str(seed)) + for key,value in [('user.name','Sync test'),('user.email','sync@example.invalid')]: git(seed,'config',key,value) + git(seed,'remote','add','origin',str(origin));git(seed,'remote','add','upstream',str(upstream)) + write(seed,'shared.txt','base\n');commit(seed,'base');git(seed,'push','origin','main');git(seed,'push','upstream','main') + git(seed,'switch','-c','feature/example');write(seed,'feature.txt','keep feature\n');commit(seed,'feature');feature=git(seed,'rev-parse','HEAD').stdout.strip();git(seed,'push','origin','HEAD') + git(seed,'switch','main');git(seed,'switch','-c','dev');write(seed,'dev.txt','keep dev\n');commit(seed,'dev');git(seed,'push','origin','HEAD') + git(seed,'switch','main');write(seed,'upstream.txt','new upstream\n');commit(seed,'upstream');target=git(seed,'rev-parse','HEAD').stdout.strip();git(seed,'push','upstream','main') + cmd('git','clone','-b','main',str(origin),str(local));git(local,'remote','add','upstream',str(upstream)) + for key,value in [('user.name','Sync test'),('user.email','sync@example.invalid')]: git(local,'config',key,value) + write(local,'untracked-user.txt','do not touch\n');before=git(local,'status','--porcelain').stdout;head=git(local,'rev-parse','HEAD').stdout + result=cmd('python3',str(SCRIPT),'--push',cwd=local,check=False) + assert result.returncode==0,(result.stdout,result.stderr) + assert git(local,'status','--porcelain').stdout==before and git(local,'rev-parse','HEAD').stdout==head + git(local,'fetch','origin') + for branch in ['main','dev','feature/example']: + assert git(local,'merge-base','--is-ancestor',target,'origin/'+branch,check=False).returncode==0,branch + assert git(local,'merge-base','--is-ancestor',feature,'origin/feature/example',check=False).returncode==0 + # New conflicting edits must leave every remote branch unchanged until resolved. + git(seed,'fetch','origin');git(seed,'switch','-C','feature/example','origin/feature/example');write(seed,'shared.txt','feature change\n');commit(seed,'feature conflict');git(seed,'push','origin','HEAD') + git(seed,'switch','main');write(seed,'shared.txt','upstream change\n');commit(seed,'upstream conflict');target2=git(seed,'rev-parse','HEAD').stdout.strip();git(seed,'push','upstream','main') + refs_before=cmd('git','--git-dir',str(origin),'for-each-ref','--format=%(refname) %(objectname)','refs/heads').stdout + failed=cmd('python3',str(SCRIPT),'--push',cwd=local,check=False) + assert failed.returncode!=0 and 'Merge needs attention' in failed.stderr,(failed.stdout,failed.stderr) + assert cmd('git','--git-dir',str(origin),'for-each-ref','--format=%(refname) %(objectname)','refs/heads').stdout==refs_before + session=Path(next(line.removeprefix('Sync session: ') for line in failed.stdout.splitlines() if line.startswith('Sync session: '))) + checkout=session/'checkout';write(checkout,'shared.txt','feature change\nupstream change\n');git(checkout,'add','shared.txt') + continued=cmd('python3',str(SCRIPT),'--continue',str(session),'--push',cwd=local,check=False) + assert continued.returncode==0,(continued.stdout,continued.stderr) + git(local,'fetch','origin') + assert git(local,'merge-base','--is-ancestor',target2,'origin/feature/example',check=False).returncode==0 + assert git(local,'show','origin/feature/example:shared.txt').stdout=='feature change\nupstream change\n' + assert git(local,'status','--porcelain').stdout==before and git(local,'rev-parse','HEAD').stdout==head +print('sync-fork: feature histories preserved; atomic push, conflict resume and dirty checkout isolation pass') From 70e9ef0c7a6a4632897b4140591ca86c689f5cfb Mon Sep 17 00:00:00 2001 From: ElfariaKensington <319984589+ElfariaKensington@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:49:25 +0300 Subject: [PATCH 6/7] Include game session and prefix checks in fork sync --- tools/sync-fork.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/sync-fork.py b/tools/sync-fork.py index 807f2cef..4cbdb3d8 100755 --- a/tools/sync-fork.py +++ b/tools/sync-fork.py @@ -38,7 +38,8 @@ def checks(wd): if not makefile.exists(): return text = makefile.read_text() wanted = ['compatsvc-check', 'panel-behavior', 'scriptcheck', 'launchercheck', - 'bundlecheck', 'gameenv-check', 'display-check', 'cursor-check', + 'bundlecheck', 'gameenv-check', 'game-session-check', 'display-check', 'cursor-check', + 'settingscheck', 'envpasscheck', 'bridgecheck', 'launchperfcheck', 'runnervariantcheck', 'confcheck'] targets = [name for name in wanted if re.search(r'^' + re.escape(name) + r'\s*:', text, re.M)] if targets: run('make', *targets, cwd=wd) From 5d9ad01b2b0db7c71ab1db84737243e478253cd5 Mon Sep 17 00:00:00 2001 From: ElfariaKensington <319984589+ElfariaKensington@users.noreply.github.com> Date: Tue, 3 Nov 2026 07:55:02 +0300 Subject: [PATCH 7/7] Carry fork main into all feature branches during sync --- tools/fork-sync.md | 3 ++- tools/sync-fork.py | 7 +++++-- tools/tests/sync-fork-test.py | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tools/fork-sync.md b/tools/fork-sync.md index b89551d0..5cfb6141 100644 --- a/tools/fork-sync.md +++ b/tools/fork-sync.md @@ -1,7 +1,8 @@ # Fork synchronization Run `python3 tools/sync-fork.py --push` to fetch `origin` and `upstream`, merge -`upstream/main` into every remote fork branch in an isolated worktree, run the +`upstream/main` into fork `main`, then merge that result into every remote fork +branch in an isolated worktree, run the available compatibility, launcher and feature checks, and push the verified results together with an atomic non-force push. Local branch checkouts and their uncommitted changes are not modified. The remote's branch list and tips are diff --git a/tools/sync-fork.py b/tools/sync-fork.py index 4cbdb3d8..6aecd6f8 100755 --- a/tools/sync-fork.py +++ b/tools/sync-fork.py @@ -76,6 +76,8 @@ def main(): name = fields[0].removeprefix('refs/remotes/origin/') branches.append({'name': name, 'old': fields[1]}) if not branches: raise RuntimeError('No origin branches were found') + # Build fork main first so each feature branch also includes its latest fork changes. + branches.sort(key=lambda branch: (branch['name'] != 'main', branch['name'])) session = common / 'notproton-sync' / (time.strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:8]) session.mkdir(parents=True) wd = session / 'checkout' @@ -87,6 +89,7 @@ def main(): while state['index'] < len(state['branches']): branch = state['branches'][state['index']] name, old = branch['name'], branch['old'] + target = state['target'] if name == 'main' else state['results'].get('main', state['target']) print(f"Checking {name}", flush=True) if state.get('pending_branch') == name: if git('status', '--porcelain', cwd=wd, capture=True): @@ -100,11 +103,11 @@ def main(): if git('status', '--porcelain', cwd=wd, capture=True): raise RuntimeError(f'The sync checkout has unexpected changes: {wd}') git('switch', '--detach', old, cwd=wd) - if git('merge-base', '--is-ancestor', state['target'], old, cwd=wd, check=False) != 0: + if git('merge-base', '--is-ancestor', target, old, cwd=wd, check=False) != 0: state['in_merge'] = True save(statefile, state) rc = git('-c', 'rerere.enabled=true', '-c', 'rerere.autoupdate=true', - '-c', 'core.editor=true', 'merge', '--no-edit', '--no-ff', state['target'], cwd=wd, check=False) + '-c', 'core.editor=true', 'merge', '--no-edit', '--no-ff', target, cwd=wd, check=False) if rc: raise RuntimeError(f'Merge needs attention in {wd}; resolve and stage it, then rerun with --continue {session}') state['in_merge'] = False diff --git a/tools/tests/sync-fork-test.py b/tools/tests/sync-fork-test.py index 48a2d0e3..a94e92a1 100644 --- a/tools/tests/sync-fork-test.py +++ b/tools/tests/sync-fork-test.py @@ -30,6 +30,7 @@ def commit(repo, message): git(local,'fetch','origin') for branch in ['main','dev','feature/example']: assert git(local,'merge-base','--is-ancestor',target,'origin/'+branch,check=False).returncode==0,branch + assert git(local,'merge-base','--is-ancestor','origin/main','origin/'+branch,check=False).returncode==0,branch assert git(local,'merge-base','--is-ancestor',feature,'origin/feature/example',check=False).returncode==0 # New conflicting edits must leave every remote branch unchanged until resolved. git(seed,'fetch','origin');git(seed,'switch','-C','feature/example','origin/feature/example');write(seed,'shared.txt','feature change\n');commit(seed,'feature conflict');git(seed,'push','origin','HEAD')