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/Makefile b/Makefile index 14db835f..070a39bc 100644 --- a/Makefile +++ b/Makefile @@ -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) @@ -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 \ 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 ca2ab0ed..c0fb8fc8 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -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" @@ -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! @@ -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 diff --git a/dylib/tests/game-session-check.py b/dylib/tests/game-session-check.py new file mode 100644 index 00000000..38ee9e17 --- /dev/null +++ b/dylib/tests/game-session-check.py @@ -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" < "$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() diff --git a/dylib/tests/game-session-fixture.m b/dylib/tests/game-session-fixture.m new file mode 100644 index 00000000..127c66eb --- /dev/null +++ b/dylib/tests/game-session-fixture.m @@ -0,0 +1,66 @@ +#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; + } + 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"]; + 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), + @"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/dylib/tests/launchercheck.sh b/dylib/tests/launchercheck.sh index 0cbc38e8..593073c7 100644 --- a/dylib/tests/launchercheck.sh +++ b/dylib/tests/launchercheck.sh @@ -369,6 +369,10 @@ pick() { pick /cx/lib/wine/aarch64-unix "" is "FEX runner" "$T/game/runner" "$game_runner" is "Dock tile hidden" " LSUIElement" "$uielement_arg" +np_single_icon=1 +pick /cx/lib/wine/aarch64-unix "" +is "single-icon host uses its launcher" "|launcher" "$game_runner|$bundle_exec" +np_single_icon=0 pick /cx/lib/wine/aarch64-unix /extra is "own WINEDLLPATH" "" "$game_runner" pick /cx/lib/wine/x86_64-unix "" diff --git a/helpers/game-client.m b/helpers/game-client.m new file mode 100644 index 00000000..6446fbf8 --- /dev/null +++ b/helpers/game-client.m @@ -0,0 +1,58 @@ +#import +#import +#include + +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 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": 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); + method_setImplementation(method, (IMP)clientPolicy); + dispatch_async(dispatch_get_main_queue(), ^{ + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + announceClient(NO); + NSNotificationCenter *center = NSNotificationCenter.defaultCenter; + [center addObserverForName:NSApplicationDidFinishLaunchingNotification object:nil + queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *notification) { + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + announceClient(NO); + }]; + [center addObserverForName:NSWindowDidBecomeKeyNotification object:nil + queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *notification) { + announceClient(YES); + }]; + }); + } +} diff --git a/helpers/game-host.m b/helpers/game-host.m new file mode 100644 index 00000000..44ac857e --- /dev/null +++ b/helpers/game-host.m @@ -0,0 +1,187 @@ +#import +#import + +@interface NPGameHost : NSObject +@property(nonatomic, strong) NSTask *task; +@property(nonatomic, strong) NSMutableDictionary *clients; +@property(nonatomic, copy) NSString *channel; +@property(nonatomic) BOOL stopping; +@property(nonatomic, copy) NSString *gameExecutable; +@property(nonatomic, strong) NSMutableDictionary *processWatches; +@property(nonatomic, strong) NSNumber *primaryPID; +@property(nonatomic) BOOL primaryExited; +@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; + // 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.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.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.processWatches[pid]; + if (ended) dispatch_source_cancel(ended); + [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 + inModes:@[NSRunLoopCommonModes]]; + }); + dispatch_resume(watch); +} + +- (void)applicationDidBecomeActive:(NSNotification *)notification { + [self focusGame]; +} + +- (BOOL)applicationShouldHandleReopen:(NSApplication *)application hasVisibleWindows:(BOOL)visible { + [self focusGame]; + return NO; +} + +- (void)checkGameExit { + if (!self.stopping && self.primaryExited && self.processWatches.count == 0) + [NSApp terminate:nil]; +} + +- (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; + // 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); + } + [self performSelectorOnMainThread:@selector(finishShutdown) withObject:nil + waitUntilDone:NO modes:@[NSRunLoopCommonModes]]; + }); + return NSTerminateLater; +} + +- (void)applicationDidFinishLaunching:(NSNotification *)notification { + self.clients = [NSMutableDictionary new]; + self.processWatches = [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 + 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) { + // 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]) { + 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; +} diff --git a/tools/fork-sync.md b/tools/fork-sync.md new file mode 100644 index 00000000..5cfb6141 --- /dev/null +++ b/tools/fork-sync.md @@ -0,0 +1,25 @@ +# Fork synchronization + +Run `python3 tools/sync-fork.py --push` to fetch `origin` and `upstream`, merge +`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 +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..6aecd6f8 --- /dev/null +++ b/tools/sync-fork.py @@ -0,0 +1,149 @@ +#!/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', '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) + + +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') + # 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' + 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'] + 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): + 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', 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', 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..a94e92a1 --- /dev/null +++ b/tools/tests/sync-fork-test.py @@ -0,0 +1,50 @@ +#!/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','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') + 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')