From a65818a7f567acec94d288d045a88f41c9f4a263 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:55:11 +0200 Subject: [PATCH 01/54] Pass microphone input through CoreAudio and AVFoundation Voice chat (Vivox) saw no capture device and Chromium was told mic access was denied. Fixes for issue #75: - AVCaptureDevice: audio is Authorized when CoreAudio has a default input device; requestAccess answers asynchronously. Video stays Denied. - PulseAudio input device reports 2 channels on input scope, 0 on output (was copied from the output device). - PulseAudio input stream: real sample/host timestamps, one device buffer per IOProc call, holes as silence, IOProc errors no longer cork the stream, record buffer attrs sized to the device buffer. - AUHAL: two elements per scope like macOS, non-blocking AudioUnitRender on bus 1 with planar support, device records in the bus 1 format, input-only units route CurrentDevice/BufferFrameSize to the input device, input start errors handled. - New offline check tools/riot-audio-input-check.cpp. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 578 ++++++++++++++++++++++++++++-- tools/build-riot-audio-runtime.py | 25 +- tools/riot-audio-input-check.cpp | 150 ++++++++ 3 files changed, 711 insertions(+), 42 deletions(-) create mode 100644 tools/riot-audio-input-check.cpp diff --git a/patches/darling.patch b/patches/darling.patch index 5194fda..5914a9f 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -1642,10 +1642,40 @@ index 0425ae97f..fdf08d2dc 100644 #endif /* AUDIOHARDWARESTREAMPA_H */ - diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp -index d6825f1d5..739b70011 100644 +index d6825f1d5..fd9efc988 100644 --- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp +++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.cpp -@@ -25,6 +25,7 @@ along with Darling. If not, see . +@@ -21,10 +21,37 @@ along with Darling. If not, see . + #include "AudioHardwareImplPA.h" + #include + #include ++#include ++#include ++#include ++#include "../../AudioTrace.h" ++ ++static const mach_timebase_info_data_t& timebase() { ++ static mach_timebase_info_data_t tb = [] { ++ mach_timebase_info_data_t t = {0, 0}; ++ mach_timebase_info(&t); ++ if (!t.numer || !t.denom) t.numer = t.denom = 1; ++ return t; ++ }(); ++ return tb; ++} ++static uint64_t hostToNs(uint64_t host) { ++ return uint64_t((unsigned __int128) host * timebase().numer / timebase().denom); ++} ++static uint64_t nsToHost(uint64_t ns) { ++ return uint64_t((unsigned __int128) ns * timebase().denom / timebase().numer); ++} ++static unsigned g_readEvents = 0; ++static bool readShouldTrace() { ++ if (!rclolAudioTraceEnabled()) return false; ++ unsigned n = g_readEvents++; ++ return n < 40 || n % 1000 == 0; ++} + AudioHardwareStreamPAInput::AudioHardwareStreamPAInput(AudioHardwareImplPA* hw, AudioDeviceIOProc callback, void* clientData) : AudioHardwareStreamPA(hw, callback, clientData) { @@ -1653,7 +1683,7 @@ index d6825f1d5..739b70011 100644 // m_dump.open("/tmp/dump.raw", std::ios_base::out | std::ios_base::binary); } -@@ -32,7 +33,6 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi +@@ -32,25 +59,26 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi { AudioHardwareStreamPAInput* This = static_cast(self); @@ -1661,14 +1691,166 @@ index d6825f1d5..739b70011 100644 if (!This->m_running) return; -@@ -74,6 +74,7 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi - // std::cout << "AudioHardwareStreamPAInput::paStreamReadCB(): bytes=" << nbytes << std::endl; - OSStatus status = This->m_callback(This->m_hw->id(), &fake, abl, &fake, nullptr, nullptr, This->m_clientData); -+ if (!This->m_running) return; - pa_stream_drop(This->m_stream); +- AudioTimeStamp fake = {0}; ++ const pa_sample_spec* spec = pa_stream_get_sample_spec(This->m_stream); ++ const size_t frameBytes = spec ? pa_frame_size(spec) : 0; ++ const double rate = spec ? double(spec->rate) : 0; ++ if (!frameBytes || rate <= 0) ++ return; ++ + AudioBufferList* abl = static_cast(alloca(sizeof(AudioBufferList) + sizeof(AudioBuffer))); +- std::vector hole; - if (status != noErr) + // std::cout << "AudioHardwareStreamPAInput::paStreamReadCB()\n"; + + while (true) + { +- abl->mNumberBuffers = 1; +- abl->mBuffers[0].mNumberChannels = 2; +- +- size_t nbytes; ++ const void* data = nullptr; ++ size_t nbytes = 0; + +- int rv = pa_stream_peek(This->m_stream, (const void**) &abl->mBuffers[0].mData, &nbytes); ++ int rv = pa_stream_peek(This->m_stream, &data, &nbytes); + + if (rv < 0 || !nbytes) + { +@@ -58,29 +86,82 @@ void AudioHardwareStreamPAInput::paStreamReadCB(pa_stream* s, size_t length, voi + break; + } + +- if (nbytes && !abl->mBuffers[0].mData) ++ // The fragment at the read index was captured `latency` ago: source latency plus ++ // everything still unread. Without timing info yet, count only the unread data. ++ pa_usec_t usec = 0; ++ int negative = 0; ++ uint64_t latencyNs; ++ if (pa_stream_get_latency(This->m_stream, &usec, &negative) == 0) ++ latencyNs = negative ? 0 : uint64_t(usec) * 1000; ++ else + { +- // "If there is a hole, data will be NULL and nbytes will contain the length of the hole." +- hole.resize(nbytes); +- abl->mBuffers[0].mData = hole.data(); ++ size_t readable = pa_stream_readable_size(This->m_stream); ++ latencyNs = uint64_t(pa_bytes_to_usec(std::max(readable, nbytes), spec)) * 1000; + } +- abl->mBuffers[0].mDataByteSize = nbytes; +- +- // This->m_dump.write((char*) abl->mBuffers[0].mData, nbytes); +- // This->m_dump.flush(); +- if (This->m_convertSignedUnsigned) +- This->transformSignedUnsigned(abl); ++ const uint64_t nowHost = mach_absolute_time(); + +- // std::cout << "AudioHardwareStreamPAInput::paStreamReadCB(): bytes=" << nbytes << std::endl; +- OSStatus status = This->m_callback(This->m_hw->id(), &fake, abl, &fake, nullptr, nullptr, This->m_clientData); +- +- pa_stream_drop(This->m_stream); ++ const bool hole = !data; ++ if (hole || This->m_convertSignedUnsigned) ++ { ++ // "If there is a hole, data will be NULL and nbytes will contain the length of the hole." ++ // Holes are delivered as silence so the sample clock keeps counting real time. ++ // The peeked memory belongs to PulseAudio, so convert a private copy. ++ This->m_scratch.resize(nbytes); ++ if (data) ++ memcpy(This->m_scratch.data(), data, nbytes); ++ else ++ memset(This->m_scratch.data(), spec->format == PA_SAMPLE_U8 ? 0x80 : 0, nbytes); ++ data = This->m_scratch.data(); ++ } + +- if (status != noErr) ++ // Like a real HAL, hand the IOProc at most one device buffer per call. ++ const size_t block = std::max(frameBytes, This->m_blockBytes); ++ for (size_t offset = 0; offset + frameBytes <= nbytes; ) + { +- pa_stream_cork(This->m_stream, true, [](pa_stream*, int, void*) {}, nullptr); +- break; ++ size_t piece = std::min(block, nbytes - offset); ++ piece -= piece % frameBytes; ++ ++ abl->mNumberBuffers = 1; ++ abl->mBuffers[0].mNumberChannels = spec->channels; ++ abl->mBuffers[0].mData = const_cast(static_cast(data) + offset); ++ abl->mBuffers[0].mDataByteSize = piece; ++ ++ if (This->m_convertSignedUnsigned) ++ This->transformSignedUnsigned(abl); ++ ++ const uint64_t offsetNs = uint64_t(double(offset / frameBytes) * 1e9 / rate); ++ const uint64_t agoNs = latencyNs > offsetNs ? latencyNs - offsetNs : 0; ++ uint64_t inputHost = nowHost - std::min(nowHost, nsToHost(agoNs)); ++ // Keep host time monotonic even when PA's latency estimate jumps. ++ if (inputHost <= This->m_lastInputHost) ++ inputHost = This->m_lastInputHost + 1; ++ This->m_lastInputHost = inputHost; ++ ++ AudioTimeStamp input = {0}; ++ input.mSampleTime = double(This->m_frames); ++ input.mHostTime = inputHost; ++ input.mRateScalar = 1.0; ++ input.mFlags = kAudioTimeStampHostTimeValid | kAudioTimeStampSampleTimeValid | kAudioTimeStampRateScalarValid; ++ ++ AudioTimeStamp now = input; ++ now.mHostTime = std::max(nowHost, inputHost); ++ now.mSampleTime = input.mSampleTime + double(hostToNs(now.mHostTime - inputHost)) * rate / 1e9; ++ ++ // This->m_dump.write((char*) abl->mBuffers[0].mData, nbytes); ++ // This->m_dump.flush(); ++ // A real HAL ignores the IOProc's result; one failed callback must not ++ // silence the microphone for the rest of the session. ++ OSStatus status = This->m_callback(This->m_hw->id(), &now, abl, &input, nullptr, nullptr, This->m_clientData); ++ if (readShouldTrace()) ++ rclolAudioTrace("PA_IN iop bytes=%zu frames=%llu latencyMs=%.2f hole=%d status=%d\n", piece, ++ (unsigned long long) This->m_frames, double(latencyNs) / 1e6, hole, (int) status); ++ ++ if (!This->m_running) return; ++ This->m_frames += piece / frameBytes; ++ offset += piece; + } ++ ++ pa_stream_drop(This->m_stream); + } + } + +@@ -88,10 +169,19 @@ void AudioHardwareStreamPAInput::start() + { + struct pa_buffer_attr battr; + +- battr.fragsize = uint32_t(-1); +- battr.maxlength = uint32_t(m_bufferSize); ++ // Record streams only use maxlength and fragsize. Ask for fragments of one device buffer ++ // (kAudioDevicePropertyBufferFrameSize) for voice latency, and bound the server-side ++ // backlog so a stalled client can't accumulate seconds of delay. ++ const uint32_t bpf = m_hw->asbd().mBytesPerFrame; ++ uint32_t block = bpf ? (m_hw->bufferSize() / bpf) * bpf : m_bufferSize; ++ if (!block) ++ block = m_bufferSize; ++ m_blockBytes = block; ++ ++ battr.fragsize = block; ++ battr.maxlength = block * 16; + battr.minreq = uint32_t(-1); +- battr.prebuf = battr.maxlength; ++ battr.prebuf = uint32_t(-1); + battr.tlength = uint32_t(-1); + + // std::cout << "AudioHardwareStreamPAInput::start()\n"; +@@ -101,6 +191,9 @@ void AudioHardwareStreamPAInput::start() + int rv = pa_stream_connect_record(m_stream, nullptr, &battr, + pa_stream_flags_t(PA_STREAM_INTERPOLATE_TIMING | PA_STREAM_ADJUST_LATENCY | PA_STREAM_AUTO_TIMING_UPDATE) + ); ++ if (rclolAudioTraceEnabled()) ++ rclolAudioTrace("PA_IN start block=%u rate=%g channels=%u rv=%d\n", block, m_hw->asbd().mSampleRate, ++ m_hw->asbd().mChannelsPerFrame, rv); + + // std::cout << "AudioHardwareStreamPAInput::start() rv = " << rv << std::endl; + AudioHardwareStreamPA::start(); diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp index 278630f02..54b463c2f 100644 --- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAOutput.cpp @@ -2390,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..9321edcdf 100644 +index fe4d1eaa1..05088f87f 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2414,7 +2596,20 @@ index fe4d1eaa1..9321edcdf 100644 #pragma GCC visibility push(default) AUDIOCOMPONENT_ENTRY(AUOutputBaseFactory, AUHAL); -@@ -42,6 +52,37 @@ AUHAL::~AUHAL() +@@ -29,8 +39,11 @@ enum { + kInputBus + }; + ++// Like macOS: element 0 is the output (app -> device) side, element 1 the input ++// (device -> app) side, on both scopes. Without output element 1 the app's ++// recording format and AudioUnitRender(bus 1) had nowhere to go. + AUHAL::AUHAL(AudioComponentInstance inInstance, bool supportRecording) +-: AUBase(inInstance, 1, supportRecording ? 1 : 0) ++: AUBase(inInstance, supportRecording ? 2 : 1, supportRecording ? 2 : 1) + { + UInt32 propSize = sizeof(AudioDeviceID); + AudioHardwareGetProperty(kAudioHardwarePropertyDefaultOutputDevice, &propSize, &m_outputDevice); +@@ -42,6 +55,37 @@ AUHAL::~AUHAL() Stop(); } @@ -2452,7 +2647,7 @@ index fe4d1eaa1..9321edcdf 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +90,7 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,7 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { @@ -2461,7 +2656,59 @@ index fe4d1eaa1..9321edcdf 100644 } OSStatus AUHAL::Version() -@@ -76,19 +117,26 @@ OSStatus AUHAL::Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTim +@@ -57,45 +101,112 @@ OSStatus AUHAL::Version() + return 1; + } + +-// Provide data from the microphone +-OSStatus AUHAL::Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inNumberFrames) ++// Provide data from the microphone: AudioUnitRender(bus 1), normally called from the input callback. ++// Never blocks; frames not recorded yet are silence. Bypasses NeedsToRender(), as a real AUHAL ++// renders whatever timestamp the client passes. ++OSStatus AUHAL::RenderBus(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames) + { +- std::unique_lock lk(m_dataAvailableMutex); ++ if (inBusNumber != kInputBus || !m_enableInput) ++ return kAudioUnitErr_NoConnection; + +- m_dataAvailableCV.wait(lk, [=]{ return m_dataAvailable; }); ++ const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); ++ const UInt32 sampleBytes = desc.mBitsPerChannel / 8; ++ const UInt32 frameBytes = desc.mChannelsPerFrame * sampleBytes; ++ if (!sampleBytes || !frameBytes) ++ return kAudioUnitErr_FormatNotSupported; ++ const bool planar = desc.mFormatFlags & kAudioFormatFlagIsNonInterleaved; + + AudioBufferList& abl = GetOutput(kInputBus)->GetBufferList(); ++ std::lock_guard lk(m_dataAvailableMutex); ++ const UInt32 frames = std::min(inNumberFrames, m_bufferUsed / frameBytes); ++ const uint8_t* source = m_buffer.get(); + +- // TODO: Prepare for non-interleaved audio +- UInt32 howMuch = std::min(abl.mBuffers[0].mDataByteSize, m_bufferUsed); +- memcpy(abl.mBuffers[0].mData, m_buffer.get(), howMuch); +- abl.mBuffers[0].mDataByteSize = howMuch; ++ if (planar) ++ { ++ for (UInt32 c = 0; c < abl.mNumberBuffers && c < desc.mChannelsPerFrame; c++) ++ { ++ uint8_t* destination = static_cast(abl.mBuffers[c].mData); ++ for (UInt32 f = 0; f < frames; f++) ++ memcpy(destination + f * sampleBytes, source + f * frameBytes + c * sampleBytes, sampleBytes); ++ memset(destination + frames * sampleBytes, 0, (inNumberFrames - frames) * sampleBytes); ++ abl.mBuffers[c].mDataByteSize = inNumberFrames * sampleBytes; ++ } ++ } ++ else ++ { ++ uint8_t* destination = static_cast(abl.mBuffers[0].mData); ++ memcpy(destination, source, frames * frameBytes); ++ memset(destination + frames * frameBytes, 0, (inNumberFrames - frames) * frameBytes); ++ abl.mBuffers[0].mDataByteSize = inNumberFrames * frameBytes; ++ } + + return noErr; + } OSStatus AUHAL::Start() { @@ -2491,7 +2738,53 @@ index fe4d1eaa1..9321edcdf 100644 } if (m_enableInput) { -@@ -111,20 +159,42 @@ OSStatus AUHAL::Stop() +- AudioDeviceCreateIOProcID(m_inputDevice, recordCallback, this, &m_inputProcID); +- const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); +- AudioDeviceSetProperty(m_inputDevice, nullptr, 0, true, kAudioDevicePropertyStreamFormat, sizeof(AudioStreamBasicDescription), &desc); +- AudioDeviceStart(m_inputDevice, m_inputProcID); ++ // The device records directly in the client's bus 1 format (PulseAudio converts rate, ++ // channels and sample type); only planar layouts are converted, in RenderBus(). ++ OSStatus status = AudioDeviceCreateIOProcID(m_inputDevice, recordCallback, this, &m_inputProcID); ++ if (!status) ++ { ++ const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); ++ AudioStreamBasicDescription hardware = desc; ++ hardware.mFormatFlags &= ~kAudioFormatFlagIsNonInterleaved; ++ hardware.mBytesPerFrame = hardware.mChannelsPerFrame * hardware.mBitsPerChannel / 8; ++ hardware.mBytesPerPacket = hardware.mBytesPerFrame; ++ // The device keeps its buffer size in bytes; keep the frame count across the format change. ++ AudioObjectPropertyAddress p = {kAudioDevicePropertyBufferFrameSize, kAudioObjectPropertyScopeInput, kAudioObjectPropertyElementMaster}; ++ UInt32 frames = 0, size = sizeof(frames); ++ const bool haveFrames = AudioObjectGetPropertyData(m_inputDevice, &p, 0, nullptr, &size, &frames) == noErr; ++ status = AudioDeviceSetProperty(m_inputDevice, nullptr, 0, true, kAudioDevicePropertyStreamFormat, sizeof(hardware), &hardware); ++ if (!status && haveFrames) ++ AudioObjectSetPropertyData(m_inputDevice, &p, 0, nullptr, sizeof(frames), &frames); ++ { ++ std::lock_guard lk(m_dataAvailableMutex); ++ m_bufferUsed = 0; ++ } ++ if (!status) ++ status = AudioDeviceStart(m_inputDevice, m_inputProcID); ++ if (status) ++ { ++ AudioDeviceDestroyIOProcID(m_inputDevice, m_inputProcID); ++ m_inputProcID = 0; ++ } ++ } ++ if (status) ++ { ++ if (m_outputProcID) ++ { ++ AudioDeviceStop(m_outputDevice, m_outputProcID); ++ AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); ++ m_outputProcID = 0; ++ } ++ return status; ++ } + } + + m_running = m_enableOutput || m_enableInput; +@@ -111,20 +222,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2529,13 +2822,36 @@ index fe4d1eaa1..9321edcdf 100644 switch (inID) { + case kAudioDevicePropertyBufferFrameSize: { ++ // An input-only unit (EnableIO output=0) sizes its input device's IO cycle. + AudioObjectPropertyAddress p={inID,kAudioObjectPropertyScopeOutput,kAudioObjectPropertyElementMaster}; -+ return AudioObjectSetPropertyData(m_outputDevice,&p,0,nullptr,inDataSize,inData); ++ OSStatus status=noErr; ++ if(m_enableOutput || !m_enableInput) status=AudioObjectSetPropertyData(m_outputDevice,&p,0,nullptr,inDataSize,inData); ++ if(!status && m_enableInput) { ++ p.mScope=kAudioObjectPropertyScopeInput; ++ status=AudioObjectSetPropertyData(m_inputDevice,&p,0,nullptr,inDataSize,inData); ++ } ++ return status; + } case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -176,15 +246,23 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +301,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au + ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); + const AudioDeviceID* dev = static_cast(inData); + +- if (inElement == kOutputBus) ++ // macOS AUHAL has one current device, normally set on element 0; for an ++ // input-only unit (EnableIO input=1, output=0) that is the capture device. ++ if (inElement == kOutputBus && m_enableInput && !m_enableOutput) ++ { ++ m_inputDevice = *dev; ++ PropertyChanged(inID, inScope, inElement); ++ } ++ else if (inElement == kOutputBus) + { + m_outputDevice = *dev; + PropertyChanged(inID, inScope, inElement); +@@ -176,20 +323,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2560,7 +2876,13 @@ index fe4d1eaa1..9321edcdf 100644 case kAudioOutputUnitProperty_SetInputCallback: { outDataSize = sizeof(AURenderCallbackStruct); -@@ -210,13 +288,22 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope + outWritable = true; +- break; ++ return noErr; + } + case kAudioOutputUnitProperty_EnableIO: + { +@@ -210,13 +365,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2577,14 +2899,15 @@ index fe4d1eaa1..9321edcdf 100644 switch (inID) { + case kAudioDevicePropertyBufferFrameSize: { -+ AudioObjectPropertyAddress p={inID,kAudioObjectPropertyScopeOutput,kAudioObjectPropertyElementMaster}; ++ const bool input=m_enableInput && !m_enableOutput; ++ AudioObjectPropertyAddress p={inID,input?kAudioObjectPropertyScopeInput:kAudioObjectPropertyScopeOutput,kAudioObjectPropertyElementMaster}; + UInt32 size=sizeof(UInt32); -+ return AudioObjectGetPropertyData(m_outputDevice,&p,0,nullptr,&size,outData); ++ return AudioObjectGetPropertyData(input?m_inputDevice:m_outputDevice,&p,0,nullptr,&size,outData); + } case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +315,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +393,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2599,7 +2922,20 @@ index fe4d1eaa1..9321edcdf 100644 } else return kAudioUnitErr_InvalidElement; -@@ -277,9 +364,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -241,7 +406,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au + } + case kAudioOutputUnitProperty_CurrentDevice: + { +- if (inElement == kOutputBus) ++ if (inElement == kOutputBus && m_enableInput && !m_enableOutput) ++ { ++ memcpy(outData, &m_inputDevice, sizeof(m_inputDevice)); ++ } ++ else if (inElement == kOutputBus) + { + memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); + } +@@ -277,9 +446,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -2689,7 +3025,7 @@ index fe4d1eaa1..9321edcdf 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +457,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +539,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -2768,8 +3104,75 @@ index fe4d1eaa1..9321edcdf 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); +@@ -303,30 +618,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp + + OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) + { +- // TODO: Prepare for non-interleaved audio +- std::unique_lock lk(m_dataAvailableMutex); +- +- if (m_bufferSize < inInputData->mBuffers[0].mDataByteSize) +- { +- m_buffer.reset(new uint8_t[inInputData->mBuffers[0].mDataByteSize]); +- m_bufferSize = inInputData->mBuffers[0].mDataByteSize; +- } +- +- m_bufferUsed = inInputData->mBuffers[0].mDataByteSize; +- memcpy(m_buffer.get(), inInputData->mBuffers[0].mData, m_bufferUsed); +- +- m_dataAvailable = true; ++ // The device records interleaved data in the bus 1 output format (see Start()). ++ const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); ++ const UInt32 frameBytes = desc.mChannelsPerFrame * (desc.mBitsPerChannel / 8); ++ if (!inInputData || !inInputData->mNumberBuffers || !frameBytes) ++ return noErr; + +- lk.unlock(); +- m_dataAvailableCV.notify_one(); ++ const uint8_t* source = static_cast(inInputData->mBuffers[0].mData); ++ const UInt32 totalFrames = inInputData->mBuffers[0].mDataByteSize / frameBytes; ++ // AudioUnitRender refuses more than kAudioUnitProperty_MaximumFramesPerSlice frames. ++ const UInt32 maxFrames = std::max(1, GetMaxFramesPerSlice()); ++ static mach_timebase_info_data_t tb = {0, 0}; ++ if (!tb.denom) mach_timebase_info(&tb); + +- if (m_outputAvailableCb.inputProc) ++ for (UInt32 offset = 0; offset < totalFrames; ) + { +- AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PostRender; +- const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Output, kInputBus); +- UInt32 numFrames = m_bufferUsed / desc.mBytesPerFrame; ++ const UInt32 numFrames = std::min(maxFrames, totalFrames - offset); ++ const size_t bytes = size_t(numFrames) * frameBytes; ++ { ++ std::lock_guard lk(m_dataAvailableMutex); ++ if (m_bufferSize < bytes) ++ { ++ m_buffer.reset(new uint8_t[bytes]); ++ m_bufferSize = bytes; ++ } ++ memcpy(m_buffer.get(), source + size_t(offset) * frameBytes, bytes); ++ m_bufferUsed = bytes; ++ } + +- m_outputAvailableCb.inputProc(m_outputAvailableCb.inputProcRefCon, &flags, inInputTime, kInputBus, numFrames, nullptr); ++ AudioTimeStamp time = *inInputTime; ++ time.mSampleTime += offset; ++ if (offset && desc.mSampleRate > 0 && tb.numer) ++ time.mHostTime += uint64_t(double(offset) / desc.mSampleRate * 1e9 * tb.denom / tb.numer); ++ ++ // The client pulls the slice with AudioUnitRender(bus 1, numFrames) from this callback. ++ if (m_outputAvailableCb.inputProc) ++ { ++ AudioUnitRenderActionFlags flags = 0; ++ m_outputAvailableCb.inputProc(m_outputAvailableCb.inputProcRefCon, &flags, &time, kInputBus, numFrames, nullptr); ++ } ++ offset += numFrames; + } + + return noErr; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.h b/src/CoreAudio/CoreAudioComponent/AUHAL.h -index bc21bcbbb..e369f95d1 100644 +index bc21bcbbb..3d6ae48f8 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.h +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.h @@ -25,6 +25,7 @@ along with Darling. If not, see . @@ -2790,6 +3193,15 @@ index bc21bcbbb..e369f95d1 100644 bool CanScheduleParameters() const override; bool StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) override; OSStatus Version() override; +@@ -43,7 +47,7 @@ public: + OSStatus GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, UInt32& outDataSize, Boolean& outWritable) override; + OSStatus GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, void* outData) override; + +- OSStatus Render(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inNumberFrames) override; ++ OSStatus RenderBus(AudioUnitRenderActionFlags& ioActionFlags, const AudioTimeStamp& inTimeStamp, UInt32 inBusNumber, UInt32 inNumberFrames) override; + + static OSStatus ComponentEntryDispatch(ComponentParameters *params, AUHAL *This); + private: @@ -59,6 +63,7 @@ private: void* inClientData); @@ -2798,8 +3210,17 @@ index bc21bcbbb..e369f95d1 100644 OSStatus doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime); protected: -@@ -76,6 +81,24 @@ protected: - // TODO: Prepare for non-interleaved audio +@@ -69,13 +74,29 @@ protected: + AudioDeviceID m_outputDevice, m_inputDevice; + AudioDeviceIOProcID m_outputProcID = 0, m_inputProcID = 0; + +- bool m_dataAvailable = false; +- std::condition_variable m_dataAvailableCV; ++ // The latest recorded slice (interleaved, in the bus 1 output format), handed to ++ // AudioUnitRender(bus 1) from inside the input callback. + std::mutex m_dataAvailableMutex; +- +- // TODO: Prepare for non-interleaved audio std::unique_ptr m_buffer; size_t m_bufferSize = 0, m_bufferUsed = 0; + @@ -2984,10 +3405,16 @@ index cb2896957..b2c25fbd4 100644 + @end diff --git a/src/frameworks/AVFoundation/src/AVCaptureDevice.m b/src/frameworks/AVFoundation/src/AVCaptureDevice.m -index cab525b91..2fcab003d 100644 +index cab525b91..4b3dd6541 100644 --- a/src/frameworks/AVFoundation/src/AVCaptureDevice.m +++ b/src/frameworks/AVFoundation/src/AVCaptureDevice.m -@@ -21,6 +21,37 @@ +@@ -18,9 +18,75 @@ + */ + + #import ++#include ++#include ++#include @implementation AVCaptureDevice @@ -3006,20 +3433,52 @@ index cab525b91..2fcab003d 100644 +} + + ++// Darling has no TCC consent store. Microphone input works through CoreAudio ++// (PulseAudio), so audio is authorized whenever CoreAudio reports a default input ++// device. CoreAudio is looked up at run time: AVFoundation does not link it, and ++// this query only reads its device table (no PulseAudio connection is made). ++static BOOL hasDefaultInputDevice(void) ++{ ++ static OSStatus (*getProperty)(AudioObjectID, const AudioObjectPropertyAddress*, UInt32, const void*, UInt32*, void*); ++ static dispatch_once_t once; ++ dispatch_once(&once, ^{ ++ // Never closed: CoreAudio keeps process-wide state once loaded. ++ void* coreAudio = dlopen("/System/Library/Frameworks/CoreAudio.framework/CoreAudio", RTLD_LAZY | RTLD_LOCAL); ++ if (coreAudio) ++ getProperty = dlsym(coreAudio, "AudioObjectGetPropertyData"); ++ }); ++ if (!getProperty) ++ return NO; ++ AudioDeviceID device = kAudioObjectUnknown; ++ UInt32 size = sizeof(device); ++ AudioObjectPropertyAddress address = { ++ kAudioHardwarePropertyDefaultInputDevice, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMaster ++ }; ++ return getProperty(kAudioObjectSystemObject, &address, 0, NULL, &size, &device) == noErr ++ && device != kAudioObjectUnknown; ++} ++ ++ (AVAuthorizationStatus)authorizationStatusForMediaType:(AVMediaType)mediaType +{ -+ // Darling has no camera/microphone capture or TCC consent store; -+ // report Denied so callers take their no-access path instead of -+ // raising an unrecognized-selector exception. -+ (void)mediaType; ++ // There is no camera capture; report Denied so callers take their ++ // no-access path instead of raising an unrecognized-selector exception. ++ if ([mediaType isEqualToString: AVMediaTypeAudio] && hasDefaultInputDevice()) ++ return AVAuthorizationStatusAuthorized; + return AVAuthorizationStatusDenied; +} + ++ (void)requestAccessForMediaType:(AVMediaType)mediaType completionHandler:(void (^)(BOOL))handler +{ -+ (void)mediaType; -+ if (handler) -+ handler(NO); ++ if (!handler) ++ return; ++ BOOL granted = [self authorizationStatusForMediaType: mediaType] == AVAuthorizationStatusAuthorized; ++ // macOS calls the handler later on an arbitrary queue, never inside this call; ++ // callers may hold locks here or wait on the main thread for the result. ++ void (^copied)(BOOL) = [handler copy]; ++ dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ ++ copied(granted); ++ [copied release]; ++ }); +} + - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { @@ -10798,3 +11257,54 @@ index e789986..0d56b4e 100644 } } return t_server_socket; +diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp b/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp +index c1fd1d402..777ca7c38 100644 +--- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp ++++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareImplPAInput.cpp +@@ -31,15 +31,18 @@ OSStatus AudioHardwareImplPAInput::getPropertyData(const AudioObjectPropertyAddr + { + if (inAddress->mSelector == kAudioDevicePropertyStreamConfiguration) + { ++ // A capture device has its channels on the input scope only. Reporting them on the ++ // output scope (copied from the output device) made capture enumerators such as ++ // Vivox and Chromium see a playback-only device with no input channels. + size_t size = sizeof(AudioBufferList); + +- if (inAddress->mScope == kAudioDevicePropertyScopeOutput) ++ if (inAddress->mScope == kAudioDevicePropertyScopeInput) + size += 1* sizeof(AudioBuffer); + + // Number of returned buffers is the number of channels + if (AudioBufferList* abl = static_cast(outData); abl && *ioDataSize >= size) + { +- if (inAddress->mScope == kAudioDevicePropertyScopeOutput) ++ if (inAddress->mScope == kAudioDevicePropertyScopeInput) + { + abl->mNumberBuffers = 1; + abl->mBuffers[0].mNumberChannels = 2; +diff --git a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h +index 87db50d12..01a7b2a3e 100644 +--- a/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h ++++ b/src/CoreAudio/CoreAudio/pulse/AudioHardwareStreamPAInput.h +@@ -21,6 +21,8 @@ along with Darling. If not, see . + #define AUDIOHARDWARESTREAMPAINPUT_H + #include "AudioHardwareStreamPA.h" + #include ++#include ++#include + + class AudioHardwareStreamPAInput : public AudioHardwareStreamPA + { +@@ -30,6 +32,12 @@ private: + void start() override; + static void paStreamReadCB(pa_stream* s, size_t length, void* self); + ++ // Only touched on the PA queue. ++ uint64_t m_frames = 0; ++ uint64_t m_lastInputHost = 0; ++ size_t m_blockBytes = 0; ++ std::vector m_scratch; ++ + // std::ofstream m_dump; + }; + diff --git a/tools/build-riot-audio-runtime.py b/tools/build-riot-audio-runtime.py index 38bff39..c7b2488 100644 --- a/tools/build-riot-audio-runtime.py +++ b/tools/build-riot-audio-runtime.py @@ -43,14 +43,16 @@ if matrix_obj in link: link[link.index(matrix_obj)]=str(obj) else: link.append(str(obj)) -# The default-output unit must be recompiled too: its SetProperty refuses kAudioOutputUnitProperty_CurrentDevice unless patched. -obj=project/'build/riot-DefaultOutputAU.o' -subprocess.run(['clang++',*component_flags['CXX_DEFINES'],*component_flags['CXX_INCLUDES'], - *component_flags['CXX_FLAGS'],'-O2','-c',str(project/'build/darling-src/src/CoreAudio/CoreAudioComponent/DefaultOutputAU.cpp'), - '-o',str(obj)],check=True) -default_obj='CMakeFiles/CoreAudioComponent.dir/DefaultOutputAU.cpp.o' -assert default_obj in link, 'DefaultOutputAU object missing from the link line' -link[link.index(default_obj)]=str(obj) +# The AUHAL subclasses must be recompiled too: they share AUHAL.h's layout and vtable, and the default-output +# unit's SetProperty refuses kAudioOutputUnitProperty_CurrentDevice unless patched. +for name in ['DefaultOutputAU','SystemOutputAU']: + obj=project/f'build/riot-{name}.o' + subprocess.run(['clang++',*component_flags['CXX_DEFINES'],*component_flags['CXX_INCLUDES'], + *component_flags['CXX_FLAGS'],'-O2','-c',str(project/f'build/darling-src/src/CoreAudio/CoreAudioComponent/{name}.cpp'), + '-o',str(obj)],check=True) + default_obj=f'CMakeFiles/CoreAudioComponent.dir/{name}.cpp.o' + assert default_obj in link, f'{name} object missing from the link line' + link[link.index(default_obj)]=str(obj) link[link.index('-o')+1]=str(project/'build/riot-CoreAudioComponent') subprocess.run(link,cwd=component,check=True) toolbox=build/'src/CoreAudio/AudioToolbox' @@ -90,3 +92,10 @@ subprocess.run(['clang++',*cpp_flags['CXX_DEFINES'],*cpp_flags['CXX_INCLUDES'], subprocess.run(['clang++','-target','x86_64-apple-macosx11.0','-fuse-ld=lld','-nostdlib', '-isysroot','@DARLING_SDK@',str(obj),'-lSystem','-lc++', str(project/'build/riot-AudioToolbox'),'-o',str(project/'build/riot-audio-graph-check')],check=True) + +obj=project/'build/riot-audio-input-check.o' +subprocess.run(['clang++',*cpp_flags['CXX_DEFINES'],*cpp_flags['CXX_INCLUDES'], + *cpp_flags['CXX_FLAGS'],'-O2','-c',str(project/'tools/riot-audio-input-check.cpp'),'-o',str(obj)],check=True) +subprocess.run(['clang++','-target','x86_64-apple-macosx11.0','-fuse-ld=lld','-nostdlib', + '-isysroot','@DARLING_SDK@',str(obj),'-lSystem','-lc++','-lobjc','-framework','CoreAudio', + '-framework','AudioToolbox','-framework','AVFoundation','-o',str(project/'build/riot-audio-input-check')],check=True) diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp new file mode 100644 index 0000000..c2ccf99 --- /dev/null +++ b/tools/riot-audio-input-check.cpp @@ -0,0 +1,150 @@ +// Microphone path used by voice chat (Vivox/Chromium): AVFoundation audio authorization, the +// input device's channels, and an AUHAL input unit delivering recorded frames through +// AudioUnitRender(bus 1). Samples are only counted, never stored or printed. +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void* const AVMediaTypeAudio; +extern "C" void* const AVMediaTypeVideo; + +static AudioUnit unit; +static std::atomic callbacks{0}, renderErrors{0}, nonIncreasing{0}, zeroFrames{0}, badBuffers{0}; +static std::atomic totalFrames{0}; +static double lastSample=-1; +static bool planar; +static float samples[2][4096]; + +static OSStatus input(void*,AudioUnitRenderActionFlags* flags,const AudioTimeStamp* time,UInt32 bus,UInt32 frames,AudioBufferList*) { + ++callbacks; + if(!frames || frames>4096) {++zeroFrames;return noErr;} + if(!(time->mFlags&kAudioTimeStampSampleTimeValid) || time->mSampleTime<=lastSample) ++nonIncreasing; + lastSample=time->mSampleTime; + unsigned char storage[sizeof(AudioBufferList)+sizeof(AudioBuffer)]; + auto* list=(AudioBufferList*)storage; + list->mNumberBuffers=planar?2:1; + for(unsigned b=0;bmNumberBuffers;++b) { + list->mBuffers[b].mNumberChannels=1; + list->mBuffers[b].mDataByteSize=frames*sizeof(float); + list->mBuffers[b].mData=samples[b]; + } + AudioUnitRenderActionFlags renderFlags=0; + OSStatus status=AudioUnitRender(unit,&renderFlags,time,bus,frames,list); + if(status) ++renderErrors; + else for(unsigned b=0;bmNumberBuffers;++b) + if(list->mBuffers[b].mDataByteSize!=frames*sizeof(float)) ++badBuffers; + totalFrames+=frames; + return noErr; +} + +static int authorization() { + Class device=objc_getClass("AVCaptureDevice"); + if(!device) {printf("AUDIO_INPUT_AUTH class=0\n");return 1;} + SEL status=sel_registerName("authorizationStatusForMediaType:"); + auto query=(long(*)(id,SEL,void*))objc_msgSend; + long audio=query((id)device,status,AVMediaTypeAudio),video=query((id)device,status,AVMediaTypeVideo); + __block int granted=-1; + __block bool returned=false; + __block bool synchronous=false; + dispatch_semaphore_t done=dispatch_semaphore_create(0); + auto request=(void(*)(id,SEL,void*,void(^)(BOOL)))objc_msgSend; + request((id)device,sel_registerName("requestAccessForMediaType:completionHandler:"),AVMediaTypeAudio,^(BOOL value) { + synchronous=!returned;granted=value;dispatch_semaphore_signal(done); + }); + returned=true; + long waited=dispatch_semaphore_wait(done,dispatch_time(DISPATCH_TIME_NOW,2*NSEC_PER_SEC)); + printf("AUDIO_INPUT_AUTH audio=%ld video=%ld request_granted=%d async=%d\n",audio,video,(int)granted,!waited && !synchronous); + fflush(stdout); + return audio!=3 || video!=2 || waited || granted!=1 || synchronous; +} + +static int device(AudioDeviceID* out) { + AudioObjectPropertyAddress p={kAudioHardwarePropertyDefaultInputDevice,kAudioObjectPropertyScopeGlobal,kAudioObjectPropertyElementMaster}; + AudioDeviceID id=kAudioObjectUnknown;UInt32 size=sizeof(id); + OSStatus status=AudioObjectGetPropertyData(kAudioObjectSystemObject,&p,0,nullptr,&size,&id); + if(status || id==kAudioObjectUnknown) {printf("AUDIO_INPUT_DEVICE status=%d\n",status);return 1;} + unsigned channels[2]={0,0}; + for(unsigned i=0;i<2;++i) { + AudioObjectPropertyAddress c={kAudioDevicePropertyStreamConfiguration, + i?kAudioObjectPropertyScopeOutput:kAudioObjectPropertyScopeInput,kAudioObjectPropertyElementMaster}; + unsigned char storage[256]={};size=sizeof(storage); + if(AudioObjectGetPropertyData(id,&c,0,nullptr,&size,storage)) return 2; + auto* list=(AudioBufferList*)storage; + for(unsigned b=0;bmNumberBuffers;++b) channels[i]+=list->mBuffers[b].mNumberChannels; + } + AudioObjectPropertyAddress s={kAudioDevicePropertyStreams,kAudioObjectPropertyScopeInput,kAudioObjectPropertyElementMaster}; + UInt32 streams=0; + status=AudioObjectGetPropertyDataSize(id,&s,0,nullptr,&streams); + AudioObjectPropertyAddress d={kAudioHardwarePropertyDevices,kAudioObjectPropertyScopeGlobal,kAudioObjectPropertyElementMaster}; + AudioDeviceID devices[16];size=sizeof(devices); + bool listed=false; + if(!AudioObjectGetPropertyData(kAudioObjectSystemObject,&d,0,nullptr,&size,devices)) + for(unsigned i=0;i Date: Sun, 18 Oct 2026 23:03:23 +0200 Subject: [PATCH 02/54] X11: release modifiers whose release the compositor swallowed GNOME's overview delivers the Super press to the game window, eats the release and hands the keyboard back without a FocusOut. The backend kept Command held, so every later key and click reached the game as a Command shortcut until a real focus change reconciled it (issue #74). Each key, button, motion and crossing event carries the server's modifier state; release any modifier key we track as down whose modifier is missing from it. Synthetic events from other clients are ignored. Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 86 +++++++++++++++++++++++++++---------- tools/riot-modifier-check.m | 23 +++++++++- 2 files changed, 85 insertions(+), 24 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 72d30f8..ca2e81b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2687,7 +2687,7 @@ index 18189404..12cde429 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..47fb6552 100644 +index 5ae29bf4..a4a880b0 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3575,7 +3575,7 @@ index 5ae29bf4..47fb6552 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1625,96 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1625,136 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -3619,6 +3619,28 @@ index 5ae29bf4..47fb6552 100644 + [self postModifierKey:key state:state window:delegate]; + } +} ++ ++/* A compositor can eat a modifier release without moving focus: GNOME's ++ * overview delivers Super's press, swallows its release and hands the ++ * keyboard back with no FocusOut, leaving Command held in the guest. Every ++ * input event carries the server's modifier state, so release any key we ++ * think is held whose modifier is missing there. `except` is the key of a ++ * modifier event, whose state predates its own transition. */ ++- (void) releaseModifierKeysMissingFromState:(unsigned int)state except:(unsigned int)except window:(id)delegate { ++ static const unsigned char keys[] = { 50, 62, 37, 105, 64, 108, 133, 134 }; ++ for (unsigned int i = 0; i < sizeof(keys); ++i) { ++ unsigned int key = keys[i]; ++ if (key == except || !_modifierKeysDown[key]) continue; ++ unsigned int held = rclolModifierMask(key); ++ if (key == 108) held |= Mod1Mask; /* Right Alt is Mod1 on layouts without AltGr. */ ++ if (state & held) continue; ++ _modifierKeysDown[key] = 0; ++ unsigned int now = state & ~(ShiftMask | ControlMask | Mod1Mask | Mod4Mask | Mod5Mask); ++ for (unsigned int j = 0; j < sizeof(keys); ++j) ++ if (_modifierKeysDown[keys[j]]) now |= rclolModifierMask(keys[j]); ++ [self postModifierKey:key state:now window:delegate]; ++ } ++} + - (void) postXEvent: (XEvent *) ev { id event = nil; @@ -3649,6 +3671,24 @@ index 5ae29bf4..47fb6552 100644 + /* Expected warp echoes are among the events queued when the warp was synced; motion ages them itself. */ + if (_relativeMouse.active && ev->type != MotionNotify) + X11RelativeMouseTick(&_relativeMouse); ++ ++ /* Events sent by other clients carry whatever state they chose. */ ++ switch (ev->xany.send_event ? 0 : ev->type) { ++ case KeyPress: case KeyRelease: ++ [self releaseModifierKeysMissingFromState:ev->xkey.state except:ev->xkey.keycode window:delegate]; ++ break; ++ case ButtonPress: case ButtonRelease: ++ [self releaseModifierKeysMissingFromState:ev->xbutton.state except:0 window:delegate]; ++ break; ++ case MotionNotify: ++ [self releaseModifierKeysMissingFromState:ev->xmotion.state except:0 window:delegate]; ++ break; ++ case EnterNotify: case LeaveNotify: ++ [self releaseModifierKeysMissingFromState:ev->xcrossing.state except:0 window:delegate]; ++ break; ++ default: ++ break; ++ } switch (ev->type) { case KeyPress: @@ -3672,7 +3712,7 @@ index 5ae29bf4..47fb6552 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1030,7 +1724,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1030,7 +1764,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; if (ev->type == KeyPress) { @@ -3685,7 +3725,7 @@ index 5ae29bf4..47fb6552 100644 buf[strLen] = 0; } else { // Xutf8LookupString() may not be used with KeyRelease -@@ -1038,6 +1736,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1776,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -3704,7 +3744,7 @@ index 5ae29bf4..47fb6552 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1787,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1827,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; @@ -3737,7 +3777,7 @@ index 5ae29bf4..47fb6552 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1822,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1862,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -3748,7 +3788,7 @@ index 5ae29bf4..47fb6552 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1832,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1872,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -3759,7 +3799,7 @@ index 5ae29bf4..47fb6552 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1845,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1885,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -3786,7 +3826,7 @@ index 5ae29bf4..47fb6552 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1870,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1910,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3801,7 +3841,7 @@ index 5ae29bf4..47fb6552 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1883,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1923,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3812,7 +3852,7 @@ index 5ae29bf4..47fb6552 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1894,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1934,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3934,7 +3974,7 @@ index 5ae29bf4..47fb6552 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +1991,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +2031,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -3995,7 +4035,7 @@ index 5ae29bf4..47fb6552 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2053,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2093,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -4060,7 +4100,7 @@ index 5ae29bf4..47fb6552 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2145,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2185,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -4088,7 +4128,7 @@ index 5ae29bf4..47fb6552 100644 break; case MapRequest: -@@ -1317,10 +2173,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2213,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -4110,7 +4150,7 @@ index 5ae29bf4..47fb6552 100644 break; case ConfigureRequest: -@@ -1344,6 +2211,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2251,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -4119,7 +4159,7 @@ index 5ae29bf4..47fb6552 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2268,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1399,7 +2308,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -4130,7 +4170,7 @@ index 5ae29bf4..47fb6552 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2304,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2344,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { NSLog(@"Request code: %d:%d", errorEvent->request_code, errorEvent->minor_code); NSLog(@"Error code: %d", errorEvent->error_code); @@ -4141,7 +4181,7 @@ index 5ae29bf4..47fb6552 100644 return 0; } -@@ -1476,54 +2351,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2391,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -4194,8 +4234,8 @@ index 5ae29bf4..47fb6552 100644 - position.x, position.y); - XSync(_display, False); + [self warpPointerToRootX: x y: y]; -+} -+ + } + +/* Confinement preserves absolute motion and edge panning. Relative mode holds + * its own grab and is left alone while requested. */ +- (void) updateMouseConfinementForWindow:(X11Window *)window { @@ -4382,8 +4422,8 @@ index 5ae29bf4..47fb6552 100644 + X11RelativeMouseEnd(&_relativeMouse); + _relativeWindow = None; + rclolPointerStamp = 0; - } - ++} ++ +/* CGAssociateMouseAndMouseCursorPosition(!doGrab). */ - (void) grabMouse: (BOOL) doGrab { + BOOL rclolRelativeBefore = _relativeMouse.active; diff --git a/tools/riot-modifier-check.m b/tools/riot-modifier-check.m index 5700c72..116a45d 100644 --- a/tools/riot-modifier-check.m +++ b/tools/riot-modifier-check.m @@ -48,8 +48,29 @@ int main(void) { XEvent map={0};map.type=KeymapNotify;[display postXEvent:&map];expectModifier(0x37,0); key(display,KeyPress,66,0);expectModifier(0x39,NSAlphaShiftKeyMask); key(display,KeyRelease,66,LockMask);expectModifier(0x39,NSAlphaShiftKeyMask); + // GNOME overview: Super press delivered, release swallowed, no focus change. + // The next event's server state lacks Mod4, so Command must be released first. + key(display,KeyPress,133,LockMask);expectModifier(0x37,NSAlphaShiftKeyMask|NSCommandKeyMask); + NSUInteger before=[events count]; + key(display,KeyPress,50,LockMask); + assert([events count]==before+2); + NSEvent *released=[events objectAtIndex:before]; + assert([released keyCode]==0x37 && [released modifierFlags]==NSAlphaShiftKeyMask); + expectModifier(0x38,NSAlphaShiftKeyMask|NSShiftKeyMask); + // A modifier that is still held stays held. + key(display,KeyPress,133,LockMask|ShiftMask);expectModifier(0x37,NSAlphaShiftKeyMask|NSShiftKeyMask|NSCommandKeyMask); + before=[events count]; + key(display,KeyRelease,50,LockMask|ShiftMask|Mod4Mask); + assert([events count]==before+1); + expectModifier(0x38,NSAlphaShiftKeyMask|NSCommandKeyMask); + // Right Alt reports Mod1 on layouts without AltGr; that still counts as held. + key(display,KeyPress,108,LockMask|Mod4Mask); + before=[events count]; + key(display,KeyRelease,133,LockMask|Mod4Mask|Mod1Mask); + assert([events count]==before+1); + expectModifier(0x37,NSAlphaShiftKeyMask|NSAlternateKeyMask|NSFunctionKeyMask); method_setImplementation(method,original); - puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock"); + puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock, swallowed Super release"); [events release]; } return 0; From 92e1b5d5ab66df9992b69d5abd553cbd9e4b5c67 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:08:48 +0200 Subject: [PATCH 03/54] riot-audio-input-check: detect a synchronous access callback by thread The handler read a flag the caller wrote without synchronization, and inferred "called inside the request" from whether the call had returned, which misfires when the handler runs on another thread first. Compare the handler's thread with the caller's instead and keep the shared results atomic. Co-Authored-By: Claude Opus 5.5 --- tools/riot-audio-input-check.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp index c2ccf99..a2e62bc 100644 --- a/tools/riot-audio-input-check.cpp +++ b/tools/riot-audio-input-check.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include extern "C" void* const AVMediaTypeAudio; @@ -49,15 +50,16 @@ static int authorization() { SEL status=sel_registerName("authorizationStatusForMediaType:"); auto query=(long(*)(id,SEL,void*))objc_msgSend; long audio=query((id)device,status,AVMediaTypeAudio),video=query((id)device,status,AVMediaTypeVideo); - __block int granted=-1; - __block bool returned=false; - __block bool synchronous=false; + // macOS calls the handler on another queue, never on the requesting thread inside the call. + // Shared with the handler's thread, so atomic; read only after the handler signals. + static std::atomic granted{-1}; + static std::atomic synchronous{false}; + const pthread_t caller=pthread_self(); dispatch_semaphore_t done=dispatch_semaphore_create(0); auto request=(void(*)(id,SEL,void*,void(^)(BOOL)))objc_msgSend; request((id)device,sel_registerName("requestAccessForMediaType:completionHandler:"),AVMediaTypeAudio,^(BOOL value) { - synchronous=!returned;granted=value;dispatch_semaphore_signal(done); + synchronous=pthread_equal(pthread_self(),caller)!=0;granted=value;dispatch_semaphore_signal(done); }); - returned=true; long waited=dispatch_semaphore_wait(done,dispatch_time(DISPATCH_TIME_NOW,2*NSEC_PER_SEC)); printf("AUDIO_INPUT_AUTH audio=%ld video=%ld request_granted=%d async=%d\n",audio,video,(int)granted,!waited && !synchronous); fflush(stdout); From 662b3d414fc862efb66119181a1043e06d97e9be Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:13:07 +0200 Subject: [PATCH 04/54] X11: take modifier masks from the server's modifier map Right Alt (keycode 108) was hard-coded as Mod5/AltGr. On layouts where it is Mod1, the swallowed-release reconcile accepted Mod1 as held but rebuilt the state with Mod5, so the guest could see Option and Function together. Read the per-key masks from XGetModifierMapping (refreshed on MappingNotify) and use them everywhere; the old table is the fallback for unmapped keys. The probe now checks Right Alt against whichever mask the backend reports. Co-Authored-By: Claude Opus 5.5 --- patches/cocotron.patch | 93 ++++++++++++++++++++++++------------- tools/riot-modifier-check.m | 13 ++++-- 2 files changed, 71 insertions(+), 35 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index ca2e81b..22a1fa3 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -2687,7 +2687,7 @@ index 18189404..12cde429 100644 +- (NSRect) frameForRootRect: (NSRect) rect; @end diff --git a/AppKit/X11.backend/X11Display.m b/AppKit/X11.backend/X11Display.m -index 5ae29bf4..a4a880b0 100644 +index 5ae29bf4..3a3c68f8 100644 --- a/AppKit/X11.backend/X11Display.m +++ b/AppKit/X11.backend/X11Display.m @@ -48,12 +48,271 @@ @@ -3575,7 +3575,7 @@ index 5ae29bf4..a4a880b0 100644 } - (NSEventModifierFlags) modifierFlagsForState: (unsigned int) state { -@@ -1010,17 +1625,136 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1010,17 +1625,157 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { return result; } @@ -3583,15 +3583,37 @@ index 5ae29bf4..a4a880b0 100644 + * carries the state AFTER it. Keep both sides separately so releasing one + * Shift/Command key does not clear the other, and reconcile missed releases + * when the compositor intercepts a shortcut or changes keyboard focus. */ ++static unsigned char rclolServerModifiers[256]; ++static BOOL rclolServerModifiersLoaded; ++ ++/* Which modifier each keycode sets depends on the layout: Right Alt is Mod5 ++ * (AltGr) on most European layouts and Mod1 (Alt) on US. Ask the server. */ ++static void rclolLoadServerModifiers(Display *display) { ++ if (rclolServerModifiersLoaded) return; ++ rclolServerModifiersLoaded = YES; ++ memset(rclolServerModifiers, 0, sizeof(rclolServerModifiers)); ++ XModifierKeymap *map = XGetModifierMapping(display); ++ if (!map) return; ++ for (int mod = 0; mod < 8; ++mod) ++ for (int i = 0; i < map->max_keypermod; ++i) { ++ KeyCode code = map->modifiermap[mod * map->max_keypermod + i]; ++ if (code) rclolServerModifiers[code] |= 1 << mod; ++ } ++ XFreeModifiermap(map); ++} ++ +static unsigned int rclolModifierMask(unsigned int key) { ++ unsigned int fallback; + switch (key) { -+ case 50: case 62: return ShiftMask; -+ case 37: case 105: return ControlMask; -+ case 64: return Mod1Mask; -+ case 108: return Mod5Mask; -+ case 133: case 134: return Mod4Mask; ++ case 50: case 62: fallback = ShiftMask; break; ++ case 37: case 105: fallback = ControlMask; break; ++ case 64: fallback = Mod1Mask; break; ++ case 108: fallback = Mod5Mask; break; ++ case 133: case 134: fallback = Mod4Mask; break; + default: return 0; + } ++ unsigned int server = rclolServerModifiers[key] & (Mod1Mask | Mod4Mask | Mod5Mask | ShiftMask | ControlMask); ++ return server ? server : fallback; +} + +- (void) postModifierKey:(unsigned int)key state:(unsigned int)state window:(id)delegate { @@ -3631,9 +3653,7 @@ index 5ae29bf4..a4a880b0 100644 + for (unsigned int i = 0; i < sizeof(keys); ++i) { + unsigned int key = keys[i]; + if (key == except || !_modifierKeysDown[key]) continue; -+ unsigned int held = rclolModifierMask(key); -+ if (key == 108) held |= Mod1Mask; /* Right Alt is Mod1 on layouts without AltGr. */ -+ if (state & held) continue; ++ if (state & rclolModifierMask(key)) continue; + _modifierKeysDown[key] = 0; + unsigned int now = state & ~(ShiftMask | ControlMask | Mod1Mask | Mod4Mask | Mod5Mask); + for (unsigned int j = 0; j < sizeof(keys); ++j) @@ -3648,6 +3668,7 @@ index 5ae29bf4..a4a880b0 100644 X11Window* window = (X11Window*) [self windowForID: ev->xany.window]; id delegate = [window delegate]; ++ rclolLoadServerModifiers(_display); + static int inputTrace = -1; + if (inputTrace < 0) inputTrace = getenv("RCLOL_INPUT_TRACE") != NULL; + if (inputTrace && (ev->type == KeyPress || ev->type == KeyRelease || ev->type == ButtonPress)) @@ -3712,7 +3733,7 @@ index 5ae29bf4..a4a880b0 100644 NSEventModifierFlags modifierFlags = [self modifierFlagsForState: ev->xkey.state]; char buf[20] = {0}; KeySym keySym; -@@ -1030,7 +1764,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1030,7 +1785,11 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; if (ev->type == KeyPress) { @@ -3725,7 +3746,7 @@ index 5ae29bf4..a4a880b0 100644 buf[strLen] = 0; } else { // Xutf8LookupString() may not be used with KeyRelease -@@ -1038,6 +1776,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1038,6 +1797,18 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { buf[strLen] = 0; } @@ -3744,7 +3765,7 @@ index 5ae29bf4..a4a880b0 100644 id str = [[NSString alloc] initWithCString: buf encoding: NSUTF8StringEncoding]; NSPoint pos = -@@ -1077,6 +1827,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1077,6 +1848,32 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ButtonPress:; @@ -3777,7 +3798,7 @@ index 5ae29bf4..a4a880b0 100644 NSTimeInterval now = [[NSDate date] timeIntervalSinceReferenceDate]; if (now - lastClickTimeStamp < [self doubleClickInterval]) { -@@ -1086,8 +1862,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1086,8 +1883,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { } lastClickTimeStamp = now; @@ -3788,7 +3809,7 @@ index 5ae29bf4..a4a880b0 100644 switch (ev->xbutton.button) { case Button1: -@@ -1096,10 +1872,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1096,10 +1893,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseDown; break; @@ -3799,7 +3820,7 @@ index 5ae29bf4..a4a880b0 100644 default: type = NSOtherMouseDown; } -@@ -1113,14 +1885,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1113,14 +1906,22 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: 0.0]; @@ -3826,7 +3847,7 @@ index 5ae29bf4..a4a880b0 100644 CGFloat deltaY = 0.0; switch (ev->xbutton.button) { -@@ -1130,14 +1910,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1130,14 +1931,6 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { case Button3: type = NSRightMouseUp; break; @@ -3841,7 +3862,7 @@ index 5ae29bf4..a4a880b0 100644 default: type = NSOtherMouseUp; } -@@ -1151,7 +1923,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1151,7 +1944,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: clickCount deltaX: 0.0 deltaY: deltaY]; @@ -3852,7 +3873,7 @@ index 5ae29bf4..a4a880b0 100644 [self postEvent: event atStart: NO]; break; -@@ -1160,49 +1934,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1160,49 +1955,95 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { // ev->xmotion.x, ev->xmotion.y, ev->xmotion.x_root, // ev->xmotion.y_root); @@ -3974,7 +3995,7 @@ index 5ae29bf4..a4a880b0 100644 mouseEventWithType: type location: pos modifierFlags: -@@ -1211,28 +2031,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1211,28 +2052,58 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { clickCount: 1 deltaX: deltaX deltaY: deltaY]; @@ -4035,7 +4056,7 @@ index 5ae29bf4..a4a880b0 100644 NSLog(@"FocusIn"); if ([delegate attachedSheet]) { [[delegate attachedSheet] makeKeyAndOrderFront: delegate]; -@@ -1243,28 +2093,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1243,28 +2114,62 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { checkForAppDeactivation: NO]; lastFocusedWindow = nil; } @@ -4100,7 +4121,7 @@ index 5ae29bf4..a4a880b0 100644 O2Rect rect = NSMakeRect(ev->xexpose.x, ev->xexpose.y, ev->xexpose.width, ev->xexpose.height); -@@ -1301,11 +2185,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1301,11 +2206,23 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case UnmapNotify: @@ -4128,7 +4149,7 @@ index 5ae29bf4..a4a880b0 100644 break; case MapRequest: -@@ -1317,10 +2213,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1317,10 +2234,21 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case ConfigureNotify: @@ -4150,7 +4171,7 @@ index 5ae29bf4..a4a880b0 100644 break; case ConfigureRequest: -@@ -1344,6 +2251,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1344,6 +2272,8 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; case PropertyNotify: @@ -4159,7 +4180,17 @@ index 5ae29bf4..a4a880b0 100644 if ([window respondsToSelector: @selector(propertyNotify:)]) { [window propertyNotify: &ev->xproperty]; } -@@ -1399,7 +2308,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1392,6 +2322,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { + + case MappingNotify: + NSLog(@"MappingNotify"); ++ XRefreshKeyboardMapping(&ev->xmapping); ++ if (ev->xmapping.request == MappingModifier) ++ rclolServerModifiersLoaded = NO; + break; + + case GenericEvent: +@@ -1399,7 +2332,9 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { break; default: @@ -4170,7 +4201,7 @@ index 5ae29bf4..a4a880b0 100644 [self _invalidateRRCache]; break; } -@@ -1433,6 +2344,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { +@@ -1433,6 +2368,10 @@ static NSDictionary *modeInfoToDictionary(const XRRModeInfo *mi, int depth) { NSLog(@"Request code: %d:%d", errorEvent->request_code, errorEvent->minor_code); NSLog(@"Error code: %d", errorEvent->error_code); @@ -4181,7 +4212,7 @@ index 5ae29bf4..a4a880b0 100644 return 0; } -@@ -1476,54 +2391,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, +@@ -1476,54 +2415,279 @@ void CGNativeBorderFrameWidthsForStyle(NSUInteger styleMask, CGFloat *top, return frame; } @@ -4234,8 +4265,8 @@ index 5ae29bf4..a4a880b0 100644 - position.x, position.y); - XSync(_display, False); + [self warpPointerToRootX: x y: y]; - } - ++} ++ +/* Confinement preserves absolute motion and edge panning. Relative mode holds + * its own grab and is left alone while requested. */ +- (void) updateMouseConfinementForWindow:(X11Window *)window { @@ -4422,8 +4453,8 @@ index 5ae29bf4..a4a880b0 100644 + X11RelativeMouseEnd(&_relativeMouse); + _relativeWindow = None; + rclolPointerStamp = 0; -+} -+ + } + +/* CGAssociateMouseAndMouseCursorPosition(!doGrab). */ - (void) grabMouse: (BOOL) doGrab { + BOOL rclolRelativeBefore = _relativeMouse.active; diff --git a/tools/riot-modifier-check.m b/tools/riot-modifier-check.m index 116a45d..f0cfc81 100644 --- a/tools/riot-modifier-check.m +++ b/tools/riot-modifier-check.m @@ -63,14 +63,19 @@ int main(void) { key(display,KeyRelease,50,LockMask|ShiftMask|Mod4Mask); assert([events count]==before+1); expectModifier(0x38,NSAlphaShiftKeyMask|NSCommandKeyMask); - // Right Alt reports Mod1 on layouts without AltGr; that still counts as held. + // Right Alt is Mod1 (Option) on US layouts and Mod5 (AltGr) elsewhere; the + // backend reads the server map, so take whichever it reported and check it + // is still held, and reported once, when Command goes up. key(display,KeyPress,108,LockMask|Mod4Mask); + NSUInteger rightAlt=[[events lastObject] modifierFlags]&~(NSUInteger)(NSAlphaShiftKeyMask|NSCommandKeyMask); + assert(rightAlt==NSAlternateKeyMask || rightAlt==NSFunctionKeyMask); + unsigned int rightAltMask=rightAlt==NSAlternateKeyMask ? Mod1Mask : Mod5Mask; before=[events count]; - key(display,KeyRelease,133,LockMask|Mod4Mask|Mod1Mask); + key(display,KeyRelease,133,LockMask|Mod4Mask|rightAltMask); assert([events count]==before+1); - expectModifier(0x37,NSAlphaShiftKeyMask|NSAlternateKeyMask|NSFunctionKeyMask); + expectModifier(0x37,NSAlphaShiftKeyMask|rightAlt); method_setImplementation(method,original); - puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock, swallowed Super release"); + puts("PASS Super=Command, Alt=Option, both Command keys, post-transition flags, focus-loss release, missed-release reconciliation, Caps Lock, swallowed Super release, layout-mapped Right Alt"); [events release]; } return 0; From f7be4a2f931b75907a50aaf89699963cd1234c6e Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:13:16 +0200 Subject: [PATCH 05/54] AUHAL: accept interleaved recording formats on element 1 Vivox sets interleaved Float32 stereo on output scope element 1 and AUBase's canonical-only rule rejected it with -10868, so voice chat never started recording (issue #75 trace: 20 attempts, all failing there). A real AUHAL converts any linear PCM; element 1 now accepts native-endian float32 or 8/16/32-bit integers in either layout, which PulseAudio records and RenderBus copies. Playback (element 0) keeps the canonical rule. The input check gains a Vivox-style run: interleaved stereo 44.1 kHz with the device set on element 1. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 62 +++++++++++++++++++++++--------- tools/riot-audio-input-check.cpp | 33 ++++++++++------- 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index 5914a9f..abb04c6 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -2572,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..05088f87f 100644 +index fe4d1eaa1..ac8d76c0b 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2647,16 +2647,41 @@ index fe4d1eaa1..05088f87f 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +93,7 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,32 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { - return !m_running && IsInitialized(); + return !m_running; ++} ++ ++// AUBase only accepts the canonical layout (Float32, non-interleaved). A real AUHAL ++// converts any linear PCM, and voice engines (Vivox) ask for interleaved Float32 on the ++// recording side, so element 1 accepts whatever PulseAudio records and RenderBus() copies: ++// native-endian float32 or 8/16/32-bit integers, either layout (24-bit is left out: ++// PulseAudio may read it as 4-byte samples while RenderBus() copies 3). ++// Element 0 (playback) keeps the canonical rule. ++bool AUHAL::ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) ++{ ++ if (element != kInputBus || scope == kAudioUnitScope_Global) ++ return AUBase::ValidFormat(scope, element, format); ++ ++ const UInt32 flags = format.mFormatFlags; ++ const UInt32 bits = format.mBitsPerChannel; ++ const bool isFloat = flags & kAudioFormatFlagIsFloat; ++ if (format.mFormatID != kAudioFormatLinearPCM || format.mFramesPerPacket != 1 ++ || format.mSampleRate <= 0 || format.mChannelsPerFrame < 1 || format.mChannelsPerFrame > 32 ++ || (flags & kAudioFormatFlagIsBigEndian) != (kAudioFormatFlagsNativeEndian & kAudioFormatFlagIsBigEndian)) ++ return false; ++ if (isFloat ? bits != 32 : (bits != 8 && bits != 16 && bits != 32)) ++ return false; ++ ++ const UInt32 frameBytes = (flags & kAudioFormatFlagIsNonInterleaved) ? bits / 8 : format.mChannelsPerFrame * bits / 8; ++ return format.mBytesPerFrame == frameBytes && format.mBytesPerPacket == frameBytes; } OSStatus AUHAL::Version() -@@ -57,45 +101,112 @@ OSStatus AUHAL::Version() +@@ -57,45 +126,112 @@ OSStatus AUHAL::Version() return 1; } @@ -2784,7 +2809,7 @@ index fe4d1eaa1..05088f87f 100644 } m_running = m_enableOutput || m_enableInput; -@@ -111,20 +222,49 @@ OSStatus AUHAL::Stop() +@@ -111,20 +247,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2835,7 +2860,7 @@ index fe4d1eaa1..05088f87f 100644 case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -161,7 +301,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +326,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); const AudioDeviceID* dev = static_cast(inData); @@ -2851,7 +2876,7 @@ index fe4d1eaa1..05088f87f 100644 { m_outputDevice = *dev; PropertyChanged(inID, inScope, inElement); -@@ -176,20 +323,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -176,20 +348,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2882,7 +2907,7 @@ index fe4d1eaa1..05088f87f 100644 } case kAudioOutputUnitProperty_EnableIO: { -@@ -210,13 +365,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope +@@ -210,13 +390,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2907,7 +2932,7 @@ index fe4d1eaa1..05088f87f 100644 case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +393,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +418,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2922,7 +2947,7 @@ index fe4d1eaa1..05088f87f 100644 } else return kAudioUnitErr_InvalidElement; -@@ -241,7 +406,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -241,7 +431,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au } case kAudioOutputUnitProperty_CurrentDevice: { @@ -2935,7 +2960,7 @@ index fe4d1eaa1..05088f87f 100644 { memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); } -@@ -277,9 +446,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -277,9 +471,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -3025,7 +3050,7 @@ index fe4d1eaa1..05088f87f 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +539,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +564,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -3104,7 +3129,7 @@ index fe4d1eaa1..05088f87f 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); -@@ -303,30 +618,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -303,30 +643,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) { @@ -3172,7 +3197,7 @@ index fe4d1eaa1..05088f87f 100644 return noErr; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.h b/src/CoreAudio/CoreAudioComponent/AUHAL.h -index bc21bcbbb..3d6ae48f8 100644 +index bc21bcbbb..fad727962 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.h +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.h @@ -25,6 +25,7 @@ along with Darling. If not, see . @@ -3183,7 +3208,7 @@ index bc21bcbbb..3d6ae48f8 100644 class AUHAL : public AUBase { -@@ -32,6 +33,9 @@ public: +@@ -32,8 +33,12 @@ public: AUHAL(AudioComponentInstance inInstance, bool supportRecording = true); ~AUHAL(); @@ -3192,8 +3217,11 @@ index bc21bcbbb..3d6ae48f8 100644 + OSStatus SetAudioChannelLayout(AudioUnitScope scope,AudioUnitElement element,const AudioChannelLayout* layout) override; bool CanScheduleParameters() const override; bool StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) override; ++ bool ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) override; OSStatus Version() override; -@@ -43,7 +47,7 @@ public: + + OSStatus Start() override; +@@ -43,7 +48,7 @@ public: OSStatus GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, UInt32& outDataSize, Boolean& outWritable) override; OSStatus GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, AudioUnitElement inElement, void* outData) override; @@ -3202,7 +3230,7 @@ index bc21bcbbb..3d6ae48f8 100644 static OSStatus ComponentEntryDispatch(ComponentParameters *params, AUHAL *This); private: -@@ -59,6 +63,7 @@ private: +@@ -59,6 +64,7 @@ private: void* inClientData); OSStatus doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutputData, const AudioTimeStamp* inOutputTime); @@ -3210,7 +3238,7 @@ index bc21bcbbb..3d6ae48f8 100644 OSStatus doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime); protected: -@@ -69,13 +74,29 @@ protected: +@@ -69,13 +75,29 @@ protected: AudioDeviceID m_outputDevice, m_inputDevice; AudioDeviceIOProcID m_outputProcID = 0, m_inputProcID = 0; diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp index a2e62bc..667abdf 100644 --- a/tools/riot-audio-input-check.cpp +++ b/tools/riot-audio-input-check.cpp @@ -20,7 +20,8 @@ static std::atomic callbacks{0}, renderErrors{0}, nonIncreasing{0}, ze static std::atomic totalFrames{0}; static double lastSample=-1; static bool planar; -static float samples[2][4096]; +static unsigned interleavedChannels=1; +static float samples[2][2*4096]; static OSStatus input(void*,AudioUnitRenderActionFlags* flags,const AudioTimeStamp* time,UInt32 bus,UInt32 frames,AudioBufferList*) { ++callbacks; @@ -31,15 +32,15 @@ static OSStatus input(void*,AudioUnitRenderActionFlags* flags,const AudioTimeSta auto* list=(AudioBufferList*)storage; list->mNumberBuffers=planar?2:1; for(unsigned b=0;bmNumberBuffers;++b) { - list->mBuffers[b].mNumberChannels=1; - list->mBuffers[b].mDataByteSize=frames*sizeof(float); + list->mBuffers[b].mNumberChannels=planar?1:interleavedChannels; + list->mBuffers[b].mDataByteSize=frames*sizeof(float)*list->mBuffers[b].mNumberChannels; list->mBuffers[b].mData=samples[b]; } AudioUnitRenderActionFlags renderFlags=0; OSStatus status=AudioUnitRender(unit,&renderFlags,time,bus,frames,list); if(status) ++renderErrors; else for(unsigned b=0;bmNumberBuffers;++b) - if(list->mBuffers[b].mDataByteSize!=frames*sizeof(float)) ++badBuffers; + if(list->mBuffers[b].mDataByteSize!=frames*sizeof(float)*list->mBuffers[b].mNumberChannels) ++badBuffers; totalFrames+=frames; return noErr; } @@ -95,19 +96,24 @@ static int device(AudioDeviceID* out) { return channels[0]!=2 || channels[1]!=0 || status || !streams || !listed; } -static int record(AudioDeviceID id,bool nonInterleaved,unsigned milliseconds) { +static int record(AudioDeviceID id,bool nonInterleaved,unsigned channels,unsigned milliseconds) { AudioComponentDescription description={kAudioUnitType_Output,kAudioUnitSubType_HALOutput,kAudioUnitManufacturer_Apple,0,0}; AudioComponent component=AudioComponentFindNext(nullptr,&description); if(!component || AudioComponentInstanceNew(component,&unit) || !unit) return 1; - planar=nonInterleaved;callbacks=0;renderErrors=0;nonIncreasing=0;zeroFrames=0;badBuffers=0;totalFrames=0;lastSample=-1; + planar=nonInterleaved;interleavedChannels=channels;callbacks=0;renderErrors=0;nonIncreasing=0;zeroFrames=0;badBuffers=0;totalFrames=0;lastSample=-1; UInt32 on=1,off=0; if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_EnableIO,kAudioUnitScope_Input,1,&on,sizeof(on))) return 2; if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_EnableIO,kAudioUnitScope_Output,0,&off,sizeof(off))) return 3; - if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,&id,sizeof(id))) return 4; + // Vivox (the interleaved stereo run) sets the capture device on element 1. + const AudioUnitElement deviceElement=channels==2?1:0; + if(AudioUnitSetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,deviceElement,&id,sizeof(id))) return 4; AudioDeviceID current=0;UInt32 size=sizeof(current); - if(AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,0,¤t,&size) || current!=id) return 5; + if(AudioUnitGetProperty(unit,kAudioOutputUnitProperty_CurrentDevice,kAudioUnitScope_Global,deviceElement,¤t,&size) || current!=id) return 5; AudioStreamBasicDescription format=nonInterleaved ? AudioStreamBasicDescription{44100,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked|kAudioFormatFlagIsNonInterleaved,4,1,4,2,32,0} + : channels==2 + // Vivox's recording format: interleaved Float32 stereo at the device rate. + ? AudioStreamBasicDescription{44100,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked,8,1,8,2,32,0} : AudioStreamBasicDescription{48000,kAudioFormatLinearPCM,kAudioFormatFlagIsFloat|kAudioFormatFlagIsPacked,4,1,4,1,32,0}; OSStatus status=AudioUnitSetProperty(unit,kAudioUnitProperty_StreamFormat,kAudioUnitScope_Output,1,&format,sizeof(format)); if(status) {printf("AUDIO_INPUT_FORMAT status=%d\n",status);return 6;} @@ -126,8 +132,8 @@ static int record(AudioDeviceID id,bool nonInterleaved,unsigned milliseconds) { AudioUnitUninitialize(unit); AudioComponentInstanceDispose(unit); double seconds=double(totalFrames)/format.mSampleRate; - printf("AUDIO_INPUT_RECORD planar=%d rate=%.0f callbacks=%u frames=%llu seconds=%.3f render_errors=%u bad_buffers=%u " - "nonincreasing=%u zero_frames=%u stop_status=%d quiet_after_stop=%d\n",nonInterleaved,format.mSampleRate,count, + printf("AUDIO_INPUT_RECORD planar=%d channels=%u rate=%.0f callbacks=%u frames=%llu seconds=%.3f render_errors=%u bad_buffers=%u " + "nonincreasing=%u zero_frames=%u stop_status=%d quiet_after_stop=%d\n",nonInterleaved,(unsigned)format.mChannelsPerFrame,format.mSampleRate,count, (unsigned long long)totalFrames,seconds,(unsigned)renderErrors,(unsigned)badBuffers,(unsigned)nonIncreasing, (unsigned)zeroFrames,stopped,quiet); fflush(stdout); @@ -141,12 +147,15 @@ int main() { failures+=authorization()!=0; AudioDeviceID id=kAudioObjectUnknown; if(device(&id)) return 100+failures; - int result=record(id,false,1000); + int result=record(id,false,1,1000); if(result) printf("AUDIO_INPUT_RECORD failed step=%d\n",result); failures+=result!=0; - result=record(id,true,400); + result=record(id,true,2,400); if(result) printf("AUDIO_INPUT_RECORD failed step=%d planar=1\n",result); failures+=result!=0; + result=record(id,false,2,400); + if(result) printf("AUDIO_INPUT_RECORD failed step=%d interleaved_stereo=1\n",result); + failures+=result!=0; printf("AUDIO_INPUT_CHECK failures=%d\n",failures); return failures; } From 2462b2b755f220e4afef403a23134feabb2b00f6 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:25:23 +0200 Subject: [PATCH 06/54] AUHAL: only accept float or signed integer recording formats RenderBus pads missing frames with zeros, which is loud for unsigned PCM (silence is 0x80 for 8-bit, 0x8000 for 16-bit). Reject unsigned and 8-bit formats on element 1; voice engines use float or signed 16-bit. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index abb04c6..839e6c1 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -2572,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..ac8d76c0b 100644 +index fe4d1eaa1..1ee1da9c5 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2647,7 +2647,7 @@ index fe4d1eaa1..ac8d76c0b 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +93,32 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,33 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { @@ -2658,8 +2658,9 @@ index fe4d1eaa1..ac8d76c0b 100644 +// AUBase only accepts the canonical layout (Float32, non-interleaved). A real AUHAL +// converts any linear PCM, and voice engines (Vivox) ask for interleaved Float32 on the +// recording side, so element 1 accepts whatever PulseAudio records and RenderBus() copies: -+// native-endian float32 or 8/16/32-bit integers, either layout (24-bit is left out: -+// PulseAudio may read it as 4-byte samples while RenderBus() copies 3). ++// native-endian float32 or signed 16/32-bit integers, either layout. RenderBus() pads with ++// zeros, which is only silence for float and signed samples, so unsigned (and 8-bit) formats ++// are left out; so is 24-bit (PulseAudio may read 4-byte samples while RenderBus() copies 3). +// Element 0 (playback) keeps the canonical rule. +bool AUHAL::ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) +{ @@ -2673,7 +2674,7 @@ index fe4d1eaa1..ac8d76c0b 100644 + || format.mSampleRate <= 0 || format.mChannelsPerFrame < 1 || format.mChannelsPerFrame > 32 + || (flags & kAudioFormatFlagIsBigEndian) != (kAudioFormatFlagsNativeEndian & kAudioFormatFlagIsBigEndian)) + return false; -+ if (isFloat ? bits != 32 : (bits != 8 && bits != 16 && bits != 32)) ++ if (isFloat ? bits != 32 : (!(flags & kAudioFormatFlagIsSignedInteger) || (bits != 16 && bits != 32))) + return false; + + const UInt32 frameBytes = (flags & kAudioFormatFlagIsNonInterleaved) ? bits / 8 : format.mChannelsPerFrame * bits / 8; @@ -2681,7 +2682,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } OSStatus AUHAL::Version() -@@ -57,45 +126,112 @@ OSStatus AUHAL::Version() +@@ -57,45 +127,112 @@ OSStatus AUHAL::Version() return 1; } @@ -2809,7 +2810,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } m_running = m_enableOutput || m_enableInput; -@@ -111,20 +247,49 @@ OSStatus AUHAL::Stop() +@@ -111,20 +248,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2860,7 +2861,7 @@ index fe4d1eaa1..ac8d76c0b 100644 case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -161,7 +326,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +327,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); const AudioDeviceID* dev = static_cast(inData); @@ -2876,7 +2877,7 @@ index fe4d1eaa1..ac8d76c0b 100644 { m_outputDevice = *dev; PropertyChanged(inID, inScope, inElement); -@@ -176,20 +348,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -176,20 +349,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2907,7 +2908,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } case kAudioOutputUnitProperty_EnableIO: { -@@ -210,13 +390,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope +@@ -210,13 +391,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2932,7 +2933,7 @@ index fe4d1eaa1..ac8d76c0b 100644 case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +418,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +419,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2947,7 +2948,7 @@ index fe4d1eaa1..ac8d76c0b 100644 } else return kAudioUnitErr_InvalidElement; -@@ -241,7 +431,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -241,7 +432,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au } case kAudioOutputUnitProperty_CurrentDevice: { @@ -2960,7 +2961,7 @@ index fe4d1eaa1..ac8d76c0b 100644 { memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); } -@@ -277,9 +471,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -277,9 +472,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -3050,7 +3051,7 @@ index fe4d1eaa1..ac8d76c0b 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +564,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +565,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -3129,7 +3130,7 @@ index fe4d1eaa1..ac8d76c0b 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); -@@ -303,30 +643,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -303,30 +644,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) { From d2ec718c48ad1e8523986ee78d3fdaa299bfbc8e Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:41:10 +0200 Subject: [PATCH 07/54] AUHAL: accept Vivox's playback format and trace recorded levels The issue #75 trace shows recording now runs, but Vivox's playback unit (mic test loopback, other players' voices) sets interleaved Float32 stereo at 32 kHz on input scope element 0 and got -10868. Apply the relaxed rule to both client sides (input scope element 0, output scope element 1); the playback path already interleaves for the device and PulseAudio resamples. AudioUnitRender(bus 1) now traces frames and peak level (no samples) so a log shows whether the client pulls sound or silence. The input check also sets Vivox's playback format and expects unsigned 16-bit to be refused. Co-Authored-By: Claude Opus 5.5 --- patches/darling.patch | 68 +++++++++++++++++++++----------- tools/riot-audio-input-check.cpp | 18 +++++++++ 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/patches/darling.patch b/patches/darling.patch index 839e6c1..ae2cfd8 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -2572,7 +2572,7 @@ index ff83d1e15..1e86f9e99 100644 bool m_suspended = true; }; diff --git a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp -index fe4d1eaa1..1ee1da9c5 100644 +index fe4d1eaa1..769b06586 100644 --- a/src/CoreAudio/CoreAudioComponent/AUHAL.cpp +++ b/src/CoreAudio/CoreAudioComponent/AUHAL.cpp @@ -1,3 +1,4 @@ @@ -2647,7 +2647,7 @@ index fe4d1eaa1..1ee1da9c5 100644 bool AUHAL::CanScheduleParameters() const { return true; -@@ -49,7 +93,33 @@ bool AUHAL::CanScheduleParameters() const +@@ -49,7 +93,36 @@ bool AUHAL::CanScheduleParameters() const bool AUHAL::StreamFormatWritable(AudioUnitScope scope, AudioUnitElement element) { @@ -2656,15 +2656,18 @@ index fe4d1eaa1..1ee1da9c5 100644 +} + +// AUBase only accepts the canonical layout (Float32, non-interleaved). A real AUHAL -+// converts any linear PCM, and voice engines (Vivox) ask for interleaved Float32 on the -+// recording side, so element 1 accepts whatever PulseAudio records and RenderBus() copies: -+// native-endian float32 or signed 16/32-bit integers, either layout. RenderBus() pads with -+// zeros, which is only silence for float and signed samples, so unsigned (and 8-bit) formats -+// are left out; so is 24-bit (PulseAudio may read 4-byte samples while RenderBus() copies 3). -+// Element 0 (playback) keeps the canonical rule. ++// converts any linear PCM, and Vivox asks for interleaved Float32 on both client sides ++// (recording: output scope element 1; playback: input scope element 0, at its own rate). ++// Those accept what PulseAudio converts and our copies handle: native-endian float32 or ++// signed 16/32-bit integers, either layout. Missing frames are padded with zeros, which is ++// only silence for float and signed samples, so unsigned (and 8-bit) formats are left out; ++// so is 24-bit (PulseAudio may use 4-byte samples while we copy 3). Device sides keep the ++// canonical rule. +bool AUHAL::ValidFormat(AudioUnitScope scope, AudioUnitElement element, const CAStreamBasicDescription& format) +{ -+ if (element != kInputBus || scope == kAudioUnitScope_Global) ++ const bool clientSide = (scope == kAudioUnitScope_Output && element == kInputBus) ++ || (scope == kAudioUnitScope_Input && element == kOutputBus); ++ if (!clientSide) + return AUBase::ValidFormat(scope, element, format); + + const UInt32 flags = format.mFormatFlags; @@ -2682,7 +2685,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } OSStatus AUHAL::Version() -@@ -57,45 +127,112 @@ OSStatus AUHAL::Version() +@@ -57,45 +130,129 @@ OSStatus AUHAL::Version() return 1; } @@ -2709,11 +2712,7 @@ index fe4d1eaa1..1ee1da9c5 100644 + std::lock_guard lk(m_dataAvailableMutex); + const UInt32 frames = std::min(inNumberFrames, m_bufferUsed / frameBytes); + const uint8_t* source = m_buffer.get(); - -- // TODO: Prepare for non-interleaved audio -- UInt32 howMuch = std::min(abl.mBuffers[0].mDataByteSize, m_bufferUsed); -- memcpy(abl.mBuffers[0].mData, m_buffer.get(), howMuch); -- abl.mBuffers[0].mDataByteSize = howMuch; ++ + if (planar) + { + for (UInt32 c = 0; c < abl.mNumberBuffers && c < desc.mChannelsPerFrame; c++) @@ -2731,6 +2730,27 @@ index fe4d1eaa1..1ee1da9c5 100644 + memcpy(destination, source, frames * frameBytes); + memset(destination + frames * frameBytes, 0, (inNumberFrames - frames) * frameBytes); + abl.mBuffers[0].mDataByteSize = inNumberFrames * frameBytes; ++ } + +- // TODO: Prepare for non-interleaved audio +- UInt32 howMuch = std::min(abl.mBuffers[0].mDataByteSize, m_bufferUsed); +- memcpy(abl.mBuffers[0].mData, m_buffer.get(), howMuch); +- abl.mBuffers[0].mDataByteSize = howMuch; ++ // Level only (no samples): shows whether the client pulls real sound or silence. ++ static std::atomic renders{0}; ++ const unsigned n = renders++; ++ if (rclolAudioTraceEnabled() && (n < 40 || n % 1000 == 0)) ++ { ++ float peak = 0; ++ const UInt32 count = frames * desc.mChannelsPerFrame; ++ if (desc.IsFloat() && sampleBytes == 4) ++ for (UInt32 i = 0; i < count; i++) ++ peak = std::max(peak, std::abs(reinterpret_cast(source)[i])); ++ else if (sampleBytes == 2) ++ for (UInt32 i = 0; i < count; i++) ++ peak = std::max(peak, std::abs(reinterpret_cast(source)[i] / 32768.0f)); ++ rclolAudioTrace("AUDIO_INPUT_RENDER n=%u frames=%u available=%u planar=%d peak=%.4f\n", ++ n, inNumberFrames, frames, (int) planar, peak); + } return noErr; @@ -2810,7 +2830,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } m_running = m_enableOutput || m_enableInput; -@@ -111,20 +248,49 @@ OSStatus AUHAL::Stop() +@@ -111,20 +268,49 @@ OSStatus AUHAL::Stop() { AudioDeviceStop(m_outputDevice, m_outputProcID); AudioDeviceDestroyIOProcID(m_outputDevice, m_outputProcID); @@ -2861,7 +2881,7 @@ index fe4d1eaa1..1ee1da9c5 100644 case kAudioOutputUnitProperty_SetInputCallback: { ca_require(inDataSize == sizeof(AURenderCallbackStruct), InvalidPropertyValue); -@@ -161,7 +327,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -161,7 +347,14 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au ca_require(inDataSize == sizeof(AudioDeviceID), InvalidPropertyValue); const AudioDeviceID* dev = static_cast(inData); @@ -2877,7 +2897,7 @@ index fe4d1eaa1..1ee1da9c5 100644 { m_outputDevice = *dev; PropertyChanged(inID, inScope, inElement); -@@ -176,20 +349,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -176,20 +369,28 @@ OSStatus AUHAL::SetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au return noErr; } } @@ -2908,7 +2928,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } case kAudioOutputUnitProperty_EnableIO: { -@@ -210,13 +391,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope +@@ -210,13 +411,23 @@ OSStatus AUHAL::GetPropertyInfo(AudioUnitPropertyID inID, AudioUnitScope inScope return noErr; } } @@ -2933,7 +2953,7 @@ index fe4d1eaa1..1ee1da9c5 100644 case kAudioOutputUnitProperty_SetInputCallback: { memcpy(outData, &m_outputAvailableCb, sizeof(m_outputAvailableCb)); -@@ -228,12 +419,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -228,12 +439,12 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au if (inElement == kOutputBus) { UInt32 value = m_enableOutput; @@ -2948,7 +2968,7 @@ index fe4d1eaa1..1ee1da9c5 100644 } else return kAudioUnitErr_InvalidElement; -@@ -241,7 +432,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au +@@ -241,7 +452,11 @@ OSStatus AUHAL::GetProperty(AudioUnitPropertyID inID, AudioUnitScope inScope, Au } case kAudioOutputUnitProperty_CurrentDevice: { @@ -2961,7 +2981,7 @@ index fe4d1eaa1..1ee1da9c5 100644 { memcpy(outData, &m_outputDevice, sizeof(m_outputDevice)); } -@@ -277,9 +472,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, +@@ -277,9 +492,89 @@ OSStatus AUHAL::recordCallback(AudioObjectID inObjectID, return This->doRecord(inNow, inInputData, inInputTime); } @@ -3051,7 +3071,7 @@ index fe4d1eaa1..1ee1da9c5 100644 if (!HasInput(0)) { // std::cerr << "No connection\n"; -@@ -290,9 +565,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -290,9 +585,75 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp AudioUnitRenderActionFlags flags = kAudioUnitRenderAction_PreRender; const CAStreamBasicDescription& desc = GetStreamFormat(kAudioUnitScope_Input, kOutputBus); @@ -3130,7 +3150,7 @@ index fe4d1eaa1..1ee1da9c5 100644 // std::cout << "Pull result: " << result << std::endl; // std::cout << "Bytes: " << outOutputData->mBuffers[0].mDataByteSize << std::endl; // m_auhalData.write((char*) outOutputData->mBuffers[0].mData, outOutputData->mBuffers[0].mDataByteSize); -@@ -303,30 +644,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp +@@ -303,30 +664,46 @@ OSStatus AUHAL::doPlayback(const AudioTimeStamp* inNow, AudioBufferList* outOutp OSStatus AUHAL::doRecord(const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime) { diff --git a/tools/riot-audio-input-check.cpp b/tools/riot-audio-input-check.cpp index 667abdf..5533694 100644 --- a/tools/riot-audio-input-check.cpp +++ b/tools/riot-audio-input-check.cpp @@ -142,6 +142,23 @@ static int record(AudioDeviceID id,bool nonInterleaved,unsigned channels,unsigne || seconds Date: Mon, 19 Oct 2026 00:58:45 +0200 Subject: [PATCH 08/54] tests: expect the shared-identity step at the end of rootless update #79 made update.sh stop Darling again after the overlays and run configure-darling-platform.py --replace-known-defaults --write. The rootless update test still expected the old call list and failed. Stub the identity tool, expect the stop + identity calls last, and check a failed install never reaches them. Co-Authored-By: Claude Opus 5.5 --- tests/test_nix_repairs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 1a6e8c4..df02a09 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -491,6 +491,9 @@ class SessionTests(unittest.TestCase): path.chmod(0o755) (scripts / 'nix-state.py').write_text('import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write(sys.argv[1]+"\\n")\n') (tools / 'stop-darling-runtime.py').write_text('import os\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("stop\\n")\n') + (root / 'tools').mkdir() + (root / 'tools/configure-darling-platform.py').write_text( + 'import os, sys\nwith open(os.environ["TEST_LOG"], "a") as f: f.write("identity " + " ".join(sys.argv[1:]) + "\\n")\n') for name in ['cmake', 'sudo', 'nix-store']: path = bin_dir / name path.write_text('#!/bin/sh\necho ' + name + ' >> "$TEST_LOG"\n' + @@ -502,12 +505,15 @@ class SessionTests(unittest.TestCase): result = run('sh', str(scripts / 'update.sh'), env=env) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(log.read_text().splitlines(), - ['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays']) + ['nix-store', 'invalidate-base', 'build', 'stop', 'cmake', 'stamp-base', 'overlays', + # The shared serial/UUID replacement writes the prefix, so Darling is stopped first. + 'stop', 'identity --replace-known-defaults --write']) log.write_text('') env['INSTALL_EXIT'] = '23' self.assertEqual(run('sh', str(scripts / 'update.sh'), env=env).returncode, 23) self.assertNotIn('stamp-base', log.read_text()) self.assertNotIn('overlays', log.read_text()) + self.assertNotIn('identity', log.read_text()) self.assertNotIn('sudo', log.read_text()) def test_rootless_update_never_uses_sudo_and_stamps_only_after_success(self): From 27553d88744806e04c2551017a64bf6582fa00ad Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:10:03 +0200 Subject: [PATCH 09/54] Fix Metal VSync propagation to window swap intervals --- docs/TESTING.md | 20 +++- docs/TROUBLESHOOTING.md | 15 +++ patches/cocotron.patch | 44 ++++++-- patches/darling.patch | 58 +++++------ tests/test_metal_vsync.py | 139 +++++++++++++++++++++++++ tools/riot-metal-presentation-check.mm | 6 ++ 6 files changed, 243 insertions(+), 39 deletions(-) create mode 100644 tests/test_metal_vsync.py diff --git a/docs/TESTING.md b/docs/TESTING.md index b1d7df4..7ab764c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -167,7 +167,7 @@ Useful probes (build tokenized Python helpers from `build/runtime/tools/`): - `riot-event-coalescing-check`: preserve key events and latest mouse motion. - `m327-cgl-resolver-check`: first calls through dynamically looked-up GL functions retain floating-point arguments; expected `first_sampler_linear=1 first_color_arguments=1 errors=0,0`. Build with `build-m58f327-opengl.py` and run with `run-riot-native-check.py m327-cgl-resolver-check`. - `m327-cgl-direct-check`: with `RCLOL_GPU_DIRECT_OTHER_WINDOW_TEST=1` and an owned 337x339 native target, interleave 338x339 fallback surfaces; expected `active_presenter_preserved=1 fallback_frames=357`. This needs the native presentation fixture; it is not a `run-riot-native-check.py` choice. -- `riot-metal-presentation-check`: Vulkan clear reaches the GL texture, including late window/context attachment. Set `RIOT_PRESENTATION_NESTED=1` to place the Metal layer beneath two ordinary layers and verify that its drawables are still presented and recycled past the three-drawable pool limit. +- `riot-metal-presentation-check`: Vulkan clear reaches the GL texture, including late window/context attachment. Each frame toggles `displaySyncEnabled` and checks the CGL window swap interval. Set `RIOT_PRESENTATION_NESTED=1` to place the Metal layer beneath two ordinary layers and verify that its drawables are still presented and recycled past the three-drawable pool limit. - `riot-ca-render-policy-check`: headless check of the actual compositor render policy. A nested Metal layer must still be prepared when a full-window IOSurface is present; surface-only trees should retain the local-render shortcut. Built by `build-riot-metal-presentation.py`; expected `PASS: mixed-tree Metal preparation survives IOSurface shortcut`. Creates no window or GPU work and does not publish its test surface. - `riot-metal-concurrency-check`: submission ordering, resource lifetime and concurrent recording. @@ -533,3 +533,21 @@ launch, window placement, click alignment and edge panning before rollout. After installing the tested X11 and CoreGraphics binaries locally, the user reported that everything was working (2026-10-16). Fullscreen policy was unchanged. + +## Metal VSync regression (#46) + +```sh +python3 -m unittest discover -s tests -p 'test_metal_vsync.py' +``` + +These host checks compile the patched layer-tree policy, flush body and CGL +swap methods against inert fixtures. They cover nested layers, VSync toggles, +shared-window requests, independent contexts, late attachment and failed +context/interval changes. They do not measure physical display refresh. + +For manual validation after updating with the clients closed, compare the +same scene with in-game VSync on and off. With VSync on, presentation should +follow the display/compositor cadence; with it off, the game's frame cap +should continue to work. Record monitor refresh, compositor, X11/Xwayland, +GPU/driver and window mode. The window presentation probe above checks the +API wiring; an in-game/compositor timing comparison remains necessary. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3574291..b7d57fd 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -541,3 +541,18 @@ Recovery requires every matching client to be at least 60 seconds old and a complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second process scan after window inspection must find the same client identities and no game. Missing information or shutdown failure leaves normal launch in charge. + +## VSync and frame caps (#46) + +The Metal layer's `displaySyncEnabled` property previously had no effect on +the window swap interval. A game that delegates pacing to VSync could run +uncapped when VSync was enabled. The compositor now applies that property to +the window before swapping, including nested Metal layers and setting changes. +If several Metal layers share a window, any layer requesting VSync enables it +for that window. Other windows retain independent swap settings. + +Exit the game and both clients, then run `./scripts/update.sh` to rebuild and +install the QuartzCore and OpenGL changes. Until updated, disable in-game +VSync to use the game's frame cap, as the issue reporter confirmed. Physical +scanout timing still depends on the driver and desktop compositor; the fix +has standalone regression coverage but needs an in-game timing retest. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 72d30f8..828ecc3 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -9418,13 +9418,14 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c5..3a7f937 100644 +index 5dce8c56..8e636724 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m -@@ -1,47 +1,77 @@ +@@ -1,47 +1,92 @@ #import +#import #import ++#import #import #import #import "CALayerInternal.h" @@ -9446,6 +9447,20 @@ index 5dce8c5..3a7f937 100644 + return NO; +} + ++/* One window has one swap interval. Any synchronized Metal layer requests ++ * VSync for that window; -1 leaves non-Metal windows on their existing policy. ++ * Re-evaluate at flush so toggles and nested/replaced layers take effect. */ ++static GLint metalSwapInterval(CALayer *layer) { ++ GLint interval = -1; ++ if ([[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]) ++ interval = [(CAMetalLayer *)layer displaySyncEnabled] ? 1 : 0; ++ for (CALayer *child in layer.sublayers) { ++ GLint childInterval = metalSwapInterval(child); ++ if (childInterval > interval) interval = childInterval; ++ } ++ return interval; ++} ++ +/* The X11 transport presents Chromium's flattened full-window surface. + * Avoid uploading and compositing that same window again into a covered GL + * child. Restrict this to the explicitly configured native presentation path. */ @@ -9518,7 +9533,7 @@ index 5dce8c5..3a7f937 100644 [super dealloc]; } -@@ -51,12 +81,22 @@ +@@ -51,12 +96,22 @@ } - (void) setLayer: (CALayer *) layer { @@ -9541,7 +9556,7 @@ index 5dce8c5..3a7f937 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +108,50 @@ +@@ -68,16 +123,50 @@ } _subwindow = [subwindow retain]; @@ -9593,7 +9608,7 @@ index 5dce8c5..3a7f937 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,8 +167,27 @@ +@@ -93,8 +182,27 @@ [self assignTextureIdsToLayerTree: child]; } @@ -9621,7 +9636,7 @@ index 5dce8c5..3a7f937 100644 glEnable(GL_DEPTH_TEST); glShadeModel(GL_SMOOTH); -@@ -118,27 +211,62 @@ +@@ -118,27 +226,62 @@ [_deleteTextureIds removeAllObjects]; @@ -9689,7 +9704,7 @@ index 5dce8c5..3a7f937 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +275,7 @@ +@@ -147,7 +290,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -9698,14 +9713,25 @@ index 5dce8c5..3a7f937 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +283,9 @@ +@@ -155,7 +298,20 @@ } - (void) flush { - CGLFlushDrawable(_glContext); + if(skipLocalRender(_layer, _frame.size))return; -+ if (_glContext != NULL && _cglWindow != NULL) ++ if (_glContext != NULL && _cglWindow != NULL) { ++ CGLContextObj previous = CGLGetCurrentContext(); ++ // EGL applies the interval to the current draw surface, so attach this ++ // window before setting it (including after late window attachment). ++ if (CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow) != kCGLNoError) ++ return; ++ GLint interval = metalSwapInterval(_layer); ++ if (interval >= 0) ++ CGLSetParameter(_glContext, kCGLCPSwapInterval, &interval); + CGLFlushDrawable(_glContext); ++ if (previous != _glContext) ++ CGLSetCurrentContext(previous); ++ } } @end diff --git a/patches/darling.patch b/patches/darling.patch index 5194fda..5508e49 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -6257,7 +6257,7 @@ index deca16b87..d75d5e22d 100644 set_property(TARGET OpenGL APPEND_STRING PROPERTY LINK_FLAGS diff --git a/src/frameworks/OpenGL/OpenGL.c b/src/frameworks/OpenGL/OpenGL.c -index ffd7fe054..f25fd9aa5 100644 +index ffd7fe054..a40a634d3 100644 --- a/src/frameworks/OpenGL/OpenGL.c +++ b/src/frameworks/OpenGL/OpenGL.c @@ -2,11 +2,22 @@ @@ -6358,7 +6358,7 @@ index ffd7fe054..f25fd9aa5 100644 static EGLConfig config; static int num_config; -@@ -48,10 +124,18 @@ struct _CGLContextObj { +@@ -48,10 +124,19 @@ struct _CGLContextObj { GLuint retain_count; pthread_mutex_t lock; EGLContext egl_context; @@ -6368,6 +6368,7 @@ index ffd7fe054..f25fd9aa5 100644 // EGL has no function for getting the current swap interval, // so we need to save the last set value. The default is 1. int swap_interval; ++ int swap_interval_requested; + EGLSurface native_present_surface; + EGLContext native_present_context; + DarlingNativePresentTarget *native_present_target; @@ -6377,7 +6378,7 @@ index ffd7fe054..f25fd9aa5 100644 }; struct _CGLPixelFormatObj { -@@ -59,6 +143,24 @@ struct _CGLPixelFormatObj { +@@ -59,6 +144,24 @@ struct _CGLPixelFormatObj { CGLPixelFormatAttribute *attributes; }; @@ -6402,7 +6403,7 @@ index ffd7fe054..f25fd9aa5 100644 static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { switch (attr) { case kCGLPFAAuxBuffers: -@@ -69,6 +171,9 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { +@@ -69,6 +172,9 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { case kCGLPFAAccumSize: case kCGLPFARendererID: case kCGLPFADisplayMask: @@ -6412,7 +6413,7 @@ index ffd7fe054..f25fd9aa5 100644 return 1; default: return 0; -@@ -78,6 +183,7 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { +@@ -78,6 +184,7 @@ static inline int attribute_has_argument(CGLPixelFormatAttribute attr) { __attribute__((constructor)) static void _CGLInitialize(void) { @@ -6420,7 +6421,7 @@ index ffd7fe054..f25fd9aa5 100644 g_displays = CFDictionaryCreateMutable(NULL, 0, NULL, NULL); } -@@ -93,41 +199,71 @@ static int attributes_count(const CGLPixelFormatAttribute *attrs) { +@@ -93,41 +200,71 @@ static int attributes_count(const CGLPixelFormatAttribute *attrs) { CGLError CGLRegisterNativeDisplay(void *native_display) { @@ -6500,7 +6501,7 @@ index ffd7fe054..f25fd9aa5 100644 pthread_mutex_lock(&g_displaysMutex); CFDictionaryAddValue(g_displays, (const void*)(unsigned long) cid, rv); -@@ -139,6 +275,8 @@ static struct _CGLDisplay* getCGLDisplay(CGSConnectionID cid) +@@ -139,6 +276,8 @@ static struct _CGLDisplay* getCGLDisplay(CGSConnectionID cid) CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, CGSSurfaceID sid) { @@ -6509,7 +6510,7 @@ index ffd7fe054..f25fd9aa5 100644 struct _CGLDisplay* disp = getCGLDisplay(cid); if (!disp) return kCGLBadConnection; -@@ -152,7 +290,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C +@@ -152,7 +291,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C if (!window) return kCGLBadWindow; @@ -6518,7 +6519,7 @@ index ffd7fe054..f25fd9aa5 100644 if (gl->egl_surface == EGL_NO_SURFACE) return kCGLBadState; return kCGLNoError; -@@ -161,7 +299,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C +@@ -161,7 +300,7 @@ CGLError CGLSetSurface(CGLContextObj gl, CGSConnectionID cid, CGSWindowID wid, C CGLWindowRef CGLGetWindow(void *native_window) { EGLNativeWindowType window = (EGLNativeWindowType) native_window; @@ -6527,7 +6528,7 @@ index ffd7fe054..f25fd9aa5 100644 if (surface == EGL_NO_SURFACE) { return NULL; -@@ -170,15 +308,53 @@ CGLWindowRef CGLGetWindow(void *native_window) { +@@ -170,15 +309,53 @@ CGLWindowRef CGLGetWindow(void *native_window) { return (CGLWindowRef) surface; } @@ -6583,7 +6584,7 @@ index ffd7fe054..f25fd9aa5 100644 } static pthread_key_t current_context_key; -@@ -198,18 +374,42 @@ CGLContextObj CGLGetCurrentContext(void) { +@@ -198,18 +375,42 @@ CGLContextObj CGLGetCurrentContext(void) { return pthread_getspecific(key); } @@ -6631,7 +6632,7 @@ index ffd7fe054..f25fd9aa5 100644 } CGLError CGLSetFullScreen(CGLContextObj ctx) { -@@ -223,11 +423,13 @@ CGLError CGLChoosePixelFormat( +@@ -223,11 +424,13 @@ CGLError CGLChoosePixelFormat( CGLPixelFormatObj *result, GLint *number_of_screens ) { @@ -6646,7 +6647,7 @@ index ffd7fe054..f25fd9aa5 100644 for (int i = 0; i < count; i++) { format->attributes[i] = attrs[i]; } -@@ -251,6 +453,10 @@ CGLError CGLDescribePixelFormat( +@@ -251,6 +454,10 @@ CGLError CGLDescribePixelFormat( CGLPixelFormatAttribute attr, GLint *value ) { @@ -6657,7 +6658,7 @@ index ffd7fe054..f25fd9aa5 100644 for (int i = 0; format->attributes[i] != 0; i++) { int has_arg = attribute_has_argument(format->attributes[i]); -@@ -303,37 +509,98 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { +@@ -303,37 +510,99 @@ GLuint CGLGetPixelFormatRetainCount(CGLPixelFormatObj pixelFormat) { return pixelFormat->retain_count; } @@ -6719,6 +6720,7 @@ index ffd7fe054..f25fd9aa5 100644 + context->egl_config = contextConfig; context->egl_surface = NULL; context->swap_interval = 1; ++ context->swap_interval_requested = 0; + context->native_present_surface=EGL_NO_SURFACE; + context->native_present_context=EGL_NO_CONTEXT; + context->native_present_target=NULL; @@ -6759,7 +6761,7 @@ index ffd7fe054..f25fd9aa5 100644 return context; } -@@ -342,11 +609,17 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -342,11 +611,17 @@ void CGLReleaseContext(CGLContextObj context) { return; } @@ -6780,7 +6782,7 @@ index ffd7fe054..f25fd9aa5 100644 if (CGLGetCurrentContext() == context) { CGLSetCurrentContext(NULL); -@@ -354,7 +627,11 @@ void CGLReleaseContext(CGLContextObj context) { +@@ -354,7 +629,11 @@ void CGLReleaseContext(CGLContextObj context) { pthread_mutex_destroy(&(context->lock)); @@ -6793,12 +6795,9 @@ index ffd7fe054..f25fd9aa5 100644 free(context); } -@@ -383,9 +660,330 @@ CGLError CGLUnlockContext(CGLContextObj context) { - return kCGLNoError; +@@ -384,8 +663,325 @@ CGLError CGLUnlockContext(CGLContextObj context) { } -+static int swapIntervalRequested; -+ CGLError CGLFlushDrawable(CGLContextObj context) { - eglSwapBuffers(display,context->egl_surface); - return kCGLNoError; @@ -6807,11 +6806,9 @@ index ffd7fe054..f25fd9aa5 100644 + /* darling-lol: EGL defaults to a swap interval of 1. When the window is covered or the compositor throttles it + * (Xwayland answers about once a second), every swap blocks that long and the game's frame rate collapses to ~1 fps. + * macOS apps get an unthrottled flush unless they ask for vsync through kCGLCPSwapInterval, which is handled in -+ * CGLSetParameter; apply "no vsync" once per context here, while it is current. */ -+ static CGLContextObj swapIntervalContext; -+ if (!swapIntervalRequested && context != swapIntervalContext && p_eglSwapInterval && CGLGetCurrentContext() == context) { ++ * CGLSetParameter; apply "no vsync" to unconfigured contexts while current. */ ++ if (!context->swap_interval_requested && p_eglSwapInterval && CGLGetCurrentContext() == context) { + p_eglSwapInterval(context->egl_display, 0); -+ swapIntervalContext = context; + } + return p_eglSwapBuffers(context->egl_display, context->egl_surface) ? kCGLNoError : kCGLBadState; +} @@ -7126,18 +7123,21 @@ index ffd7fe054..f25fd9aa5 100644 } CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, const GLint *value) { -@@ -394,8 +992,9 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c - +@@ -395,9 +991,11 @@ CGLError CGLSetParameter(CGLContextObj context, CGLContextParameter parameter, c if (parameter == kCGLCPSwapInterval) { -+ swapIntervalRequested = 1; GLint v = *value; - EGLBoolean success = eglSwapInterval(display, v); +- if (success) + EGLBoolean success = p_eglSwapInterval(display, v); - if (success) ++ if (success) { context->swap_interval = v; ++ context->swap_interval_requested = 1; ++ } return success ? kCGLNoError : kCGLBadValue; -@@ -417,14 +1016,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G + } + fprintf(stderr, "CGLSetParameter unimplemented for parameter %d\n", parameter); +@@ -417,14 +1015,19 @@ CGLError CGLGetParameter(CGLContextObj context, CGLContextParameter parameter, G return kCGLNoError; } diff --git a/tests/test_metal_vsync.py b/tests/test_metal_vsync.py new file mode 100644 index 0000000..49bba1d --- /dev/null +++ b/tests/test_metal_vsync.py @@ -0,0 +1,139 @@ +"""Exercise the patched presentation policy and CGL call order without a GPU.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +import test_runtime_compatibility as sources + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class MetalVSyncChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def test_explicit_vsync_is_scoped_to_each_cgl_context(self): + source = self.patched('darling', 'src/frameworks/OpenGL/OpenGL.c') + methods = '\n'.join(sources.function(source, name) for name in ('CGLFlushDrawable', 'CGLSetParameter')) + fixture = r''' +#include +#include +using GLint=int; +using CGLError=int; +using CGLContextParameter=int; +using EGLBoolean=int; +constexpr int kCGLNoError=0, kCGLBadContext=1, kCGLBadState=2, kCGLBadAddress=3, kCGLBadValue=4; +constexpr int kCGLCPSwapInterval=1; +struct Context { int egl_display=1, egl_surface=1, swap_interval=1, swap_interval_requested=0; }; +using CGLContextObj=Context*; +static Context* current; +static int display=1, applied=-1; +static bool fail=false; +static int swapInterval(int, int value) { if(fail) return 0; applied=value; return 1; } +static auto p_eglSwapInterval=swapInterval; +static int p_eglSwapBuffers(int,int) { return 1; } +static Context* CGLGetCurrentContext() { return current; } +''' + methods + r''' +int main() { + Context a, b; + current=&a; + int interval=1; + assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==1); + CGLFlushDrawable(&a); assert(applied==1); + current=&b; + CGLFlushDrawable(&b); assert(applied==0); // Another window retains its default. + current=&a; + interval=0; + assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==0); + interval=1; + assert(CGLSetParameter(&a,kCGLCPSwapInterval,&interval)==0 && applied==1); + current=&b; fail=true; + assert(CGLSetParameter(&b,kCGLCPSwapInterval,&interval)==kCGLBadValue); + assert(!b.swap_interval_requested); // Failed requests must not change policy. + fail=false; CGLFlushDrawable(&b); assert(applied==0); + assert(CGLFlushDrawable(nullptr)==kCGLBadContext); +} +''' + self.run_cpp(fixture) + + def test_nested_layers_toggles_windows_and_context_failures(self): + source = self.patched('cocotron', 'QuartzCore/CALayerContext.m') + policy = sources.function(source, 'metalSwapInterval') + # Translate only Objective-C message/iteration syntax; compile the actual + # policy and flush body against inert layer and CGL fixtures. + policy = policy.replace('[[layer class] isSubclassOfClass: [CAMetalLayerInternal class]]', 'layer->metal') + policy = policy.replace('[(CAMetalLayer *)layer displaySyncEnabled]', 'layer->sync') + policy = policy.replace('for (CALayer *child in layer.sublayers)', 'for (CALayer *child : layer->children)') + flush = source.split('- (void) flush {', 1)[1].split('\n}\n', 1)[0] + fixture = r''' +#include +#include +using GLint = int; +using CGLContextObj = int; +constexpr int kCGLNoError = 0, kCGLCPSwapInterval = 1; +constexpr int NULL_CONTEXT = 0; +struct CALayer { bool metal=false, sync=true; std::vector children; }; +struct Context { int interval=-2, swaps=0; } contexts[3]; +static int current=2, attached=0; +static bool fail=false, skip=false; +static int CGLGetCurrentContext() { return current; } +static int CGLSetCurrentContext(int context) { current=context; return 0; } +static int CGLContextMakeCurrentAndAttachToWindow(int context, int window) { + if(fail) return 1; + current=context; attached=window; return 0; +} +static int CGLSetParameter(int context, int parameter, const int* interval) { + assert(current==context && attached==context && parameter==kCGLCPSwapInterval); + contexts[context].interval=*interval; return 0; +} +static int CGLFlushDrawable(int context) { + assert(current==context && attached==context); + ++contexts[context].swaps; return 0; +} +static bool skipLocalRender(CALayer*, int) { return skip; } +''' + fixture += policy + fixture += '\nstruct Window { int _glContext=1, _cglWindow=1, _frame=0; CALayer* _layer; void flush() {' + fixture += flush.replace('NULL', 'NULL_CONTEXT').replace('_frame.size', '_frame') + '\n} };\n' + fixture += r''' +int main() { + CALayer root, container, metal, other; + metal.metal=other.metal=true; + root.children={&container}; container.children={&metal}; + assert(metalSwapInterval(&root)==1); + Window a{1,1,0,&root}; + a.flush(); assert(contexts[1].interval==1 && current==2); + metal.sync=false; + a.flush(); assert(contexts[1].interval==0 && current==2); + container.children.push_back(&other); + a.flush(); assert(contexts[1].interval==1); + other.sync=false; + a.flush(); assert(contexts[1].interval==0); + CALayer plain; + assert(metalSwapInterval(&plain)==-1); + Window b{2,2,0,&plain}; + b.flush(); assert(contexts[2].interval==-2); + other.sync=true; + a.flush(); assert(contexts[1].interval==1 && contexts[2].interval==-2); + a._cglWindow=0; + int swaps=contexts[1].swaps; + a.flush(); assert(contexts[1].swaps==swaps); + a._cglWindow=1; fail=true; + a.flush(); assert(contexts[1].swaps==swaps && current==2); + fail=false; skip=true; + a.flush(); assert(contexts[1].swaps==swaps); + skip=false; current=0; + a.flush(); assert(current==0); + current=1; a.flush(); assert(current==1); + root.children.clear(); + assert(metalSwapInterval(&root)==-1); +} +''' + self.run_cpp(fixture) + + def run_cpp(self, fixture): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path/'check.cpp').write_text(fixture) + result = subprocess.run(['c++', '-std=c++17', str(path/'check.cpp'), '-o', str(path/'check')], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + subprocess.run([str(path/'check')], check=True, timeout=10) diff --git a/tools/riot-metal-presentation-check.mm b/tools/riot-metal-presentation-check.mm index 5266d8d..cf1891b 100644 --- a/tools/riot-metal-presentation-check.mm +++ b/tools/riot-metal-presentation-check.mm @@ -35,6 +35,7 @@ int main() { id device = MTLCreateSystemDefaultDevice(); verifyPresentation(device != nil, "device"); CAMetalLayer* layer = [CAMetalLayer layer]; + verifyPresentation(layer.displaySyncEnabled == YES, "VSync enabled by default"); layer.device = device; layer.drawableSize = CGSizeMake(160,96); layer.frame = CGRectMake(0,0,160,96); @@ -66,6 +67,8 @@ int main() { verifyPresentation(flag != nullptr, "request second bounded capture"); fclose(flag); } + // Toggle the public property while the same layer/window stays live. + layer.displaySyncEnabled = (frame % 3) != 1; @autoreleasepool { id drawable = [layer nextDrawable]; verifyPresentation(drawable != nil, "drawable after late window/context attachment"); @@ -82,6 +85,9 @@ int main() { [commands commit]; [commands waitUntilCompleted]; pump(app); + GLint interval = -1; + CGLGetParameter([(CALayerContext*)[layer _context] glContext], kCGLCPSwapInterval, &interval); + verifyPresentation(interval == (layer.displaySyncEnabled ? 1 : 0), "Metal VSync reaches window swap interval"); } } CGLContextObj previous = CGLGetCurrentContext(); From 41da6d9e0adfd0af8162fd68ff1df15839f4bc9e Mon Sep 17 00:00:00 2001 From: ekisu Date: Sun, 18 Oct 2026 21:06:22 -0300 Subject: [PATCH 10/54] Fix X11 decorations for client-drawn Cocoa windows Synchronize Motif decoration hints on style and titlebar-property changes, including native-window recreation and restoring ordinary WM decorations. Add a ten-case standalone Cocoa/X11 regression probe and document validation on KWin/Xwayland. --- docs/TESTING.md | 27 ++++++++ patches/cocotron.patch | 89 +++++++++++++++++---------- tools/build-riot-x11-runtime.py | 12 +++- tools/riot-window-decorations-check.m | 86 ++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 35 deletions(-) create mode 100644 tools/riot-window-decorations-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index b1d7df4..11a684d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -119,6 +119,33 @@ Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. +### X11 window decorations + +`python3 tools/build-riot-x11-runtime.py` also builds +`build/riot-window-decorations-check`. Install and activate both the patched +AppKit and X11 backend before running it, with clients and the game closed: + +```sh +darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \ + DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-window-decorations-check +``` + +Expected: `WINDOW_DECORATIONS_CHECK cases=10 pass=1`. The independent Cocoa +probe reads `_MOTIF_WM_HINTS` on its own unmapped native windows. It covers a +normal titled window, hidden title alone, hidden plus transparent titlebar, +both reverse transitions, untitled masks with additional flags, switching to +borderless after creation, native-window recreation, and properties set before +the native window exists. It sends no input, maps no windows and does not +launch or contact Riot/League. Check the success marker, not only the launcher +exit status: some Darling versions do not propagate a guest assertion failure. + +Before the fix the hidden/transparent case fails. With the corrected overlays, +the ten cases passed on KDE Wayland/Xwayland. Passive inspection of the reopened +Riot Client also confirmed the no-decoration hint and identical compositor frame +and client dimensions (1536×864), instead of the previous 28-pixel titlebar. +This validates the Riot Client window, not every LeagueClientUx or game mode. + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 72d30f8..186b72f 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1946,7 +1946,7 @@ index d545120a..79760f47 100644 - (BOOL) inLiveResize { return _isInLiveResize; -@@ -1561,6 +1766,117 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1561,6 +1766,123 @@ static BOOL _allowsAutomaticWindowTabbing; return point; } @@ -1976,7 +1976,10 @@ index d545120a..79760f47 100644 +} + +- (void) setTitlebarAppearsTransparent: (BOOL) value { ++ if (_titlebarAppearsTransparent == value) ++ return; + _titlebarAppearsTransparent = value; ++ [_platformWindow setStyleMask: _styleMask]; +} + +- (NSInteger) titleVisibility { @@ -1984,7 +1987,10 @@ index d545120a..79760f47 100644 +} + +- (void) setTitleVisibility: (NSInteger) value { ++ if (_titleVisibility == value) ++ return; + _titleVisibility = value; ++ [_platformWindow setStyleMask: _styleMask]; +} + +- (BOOL) movableByWindowBackground { @@ -2064,7 +2070,7 @@ index d545120a..79760f47 100644 - (NSRect) frameRectForContentRect: (NSRect) contentRect { // hasMainMenu is an instance method so we can't just use the class method // frameRectForContentRect:styleMask: -@@ -1580,6 +1896,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -1580,6 +1902,10 @@ static BOOL _allowsAutomaticWindowTabbing; } - (NSRect) contentRectForFrameRect: (NSRect) frameRect { @@ -2075,7 +2081,7 @@ index d545120a..79760f47 100644 NSRect result = [[NSDisplay currentDisplay] insetRect: frameRect forNativeWindowBorderWithStyle: [self styleMask]]; -@@ -2155,6 +2475,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2155,6 +2481,7 @@ static BOOL _allowsAutomaticWindowTabbing; - (void) orderWindow: (NSWindowOrderingMode) place relativeTo: (NSInteger) relativeTo { @@ -2083,7 +2089,7 @@ index d545120a..79760f47 100644 // The move notifications are sent under unknown conditions around // orderFront: in the Apple AppKit, we do them all the time here until it's // figured out. I suspect it is a side effect of off-screen windows being at -@@ -2208,6 +2529,8 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2208,6 +2535,8 @@ static BOOL _allowsAutomaticWindowTabbing; break; } @@ -2092,7 +2098,7 @@ index d545120a..79760f47 100644 [self postNotificationName: NSWindowDidMoveNotification]; } -@@ -2257,6 +2580,12 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2257,6 +2586,12 @@ static BOOL _allowsAutomaticWindowTabbing; } - (void) sendEvent: (NSEvent *) event { @@ -2105,7 +2111,7 @@ index d545120a..79760f47 100644 // Some events can cause our window to be destroyed // So make sure self lives at least through this current run loop... [[self retain] autorelease]; -@@ -2300,6 +2629,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2300,6 +2635,10 @@ static BOOL _allowsAutomaticWindowTabbing; case NSLeftMouseDown: { NSView *view = [_backgroundView hitTest: [event locationInWindow]]; @@ -2116,7 +2122,7 @@ index d545120a..79760f47 100644 if ([view acceptsFirstResponder]) { if ([view needsPanelToBecomeKey]) { -@@ -2307,6 +2640,27 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2307,6 +2646,27 @@ static BOOL _allowsAutomaticWindowTabbing; } } @@ -2144,7 +2150,7 @@ index d545120a..79760f47 100644 // Event goes to view, not first responder. [view mouseDown: event]; _mouseDownLocationInWindow = [event locationInWindow]; -@@ -2330,6 +2684,31 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2330,6 +2690,31 @@ static BOOL _allowsAutomaticWindowTabbing; _mouseDownLocationInWindow = NSMakePoint(NAN, NAN); break; @@ -2176,7 +2182,7 @@ index d545120a..79760f47 100644 case NSMouseMoved: { NSView *hit = [_backgroundView hitTest: [event locationInWindow]]; -@@ -2478,6 +2857,197 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2478,6 +2863,197 @@ static BOOL _allowsAutomaticWindowTabbing; _draggedTypes = nil; } @@ -2374,7 +2380,7 @@ index d545120a..79760f47 100644 - (void) dragImage: (NSImage *) image at: (NSPoint) location offset: (NSSize) offset -@@ -2486,6 +3056,10 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2486,6 +3062,10 @@ static BOOL _allowsAutomaticWindowTabbing; source: (id) source slideBack: (BOOL) slideBack { @@ -2385,7 +2391,7 @@ index d545120a..79760f47 100644 [[NSDraggingManager draggingManager] dragImage: image at: location offset: offset -@@ -2536,6 +3110,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2536,6 +3116,7 @@ static BOOL _allowsAutomaticWindowTabbing; } else { [[self platformWindow] hideWindow]; } @@ -2393,7 +2399,7 @@ index d545120a..79760f47 100644 } - (void) orderFront: (id) sender { -@@ -3064,6 +3639,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3645,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2407,7 +2413,7 @@ index d545120a..79760f47 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3842,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3848,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2417,7 +2423,7 @@ index d545120a..79760f47 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +4040,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4046,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -5191,7 +5197,7 @@ index f4925b66..730d4692 100644 _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; // FIXME: There should be no need for this. -@@ -254,7 +396,8 @@ static NSData *makeWindowIcon() { +@@ -254,19 +396,96 @@ static NSData *makeWindowIcon() { XNFocusWindow, _window, NULL); @@ -5201,7 +5207,10 @@ index f4925b66..730d4692 100644 [(X11Display *) [NSDisplay currentDisplay] setWindow: self forID: _window]; -@@ -265,8 +408,88 @@ static NSData *makeWindowIcon() { +- // FIXME: Move this call into setStyleMaskInternal below! +- if (_styleMask == NSBorderlessWindowMask) { +- [[self class] removeDecorationForWindow: _window onDisplay: _display]; +- } [self setStyleMaskInternal: _styleMask force: YES]; [self setWindowIcon]; @@ -5291,7 +5300,7 @@ index f4925b66..730d4692 100644 } - (void) dealloc { -@@ -279,24 +502,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +498,30 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -5338,7 +5347,7 @@ index f4925b66..730d4692 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +533,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +529,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -5347,7 +5356,7 @@ index f4925b66..730d4692 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +541,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +537,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -5364,7 +5373,7 @@ index f4925b66..730d4692 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +555,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +551,47 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } @@ -5373,6 +5382,18 @@ index f4925b66..730d4692 100644 +} + +- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { ++ // Cocoa chrome may change after the native window already exists. A hidden, ++ // transparent titlebar is client-drawn chrome, even with the titled bit set. ++ // NSWindowTitleHidden is 1; this SDK exposes titleVisibility as NSInteger. ++ BOOL clientChrome = [_delegate titlebarAppearsTransparent] && ++ [_delegate titleVisibility] == 1; ++ if (!(mask & NSTitledWindowMask) || clientChrome) { ++ [[self class] removeDecorationForWindow: _window onDisplay: _display]; ++ } else { ++ // Return decoration ownership to the WM on the reverse transition. ++ XDeleteProperty(_display, _window, ++ XInternAtom(_display, "_MOTIF_WM_HINTS", False)); ++ } + if (force || (mask & NSWindowStyleMaskResizable) != + (_styleMask & NSWindowStyleMaskResizable)) { + XSizeHints *sh = XAllocSizeHints(); @@ -5400,7 +5421,7 @@ index f4925b66..730d4692 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +595,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +603,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5408,7 +5429,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +612,14 @@ static NSData *makeWindowIcon() { +@@ -356,8 +620,14 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5423,7 +5444,7 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +639,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +647,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5456,7 +5477,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +667,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +675,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -5485,7 +5506,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +717,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +725,392 @@ static NSData *makeWindowIcon() { } } @@ -5878,7 +5899,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1152,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1160,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5912,7 +5933,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1188,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1196,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -5938,7 +5959,7 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1262,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1270,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -5960,7 +5981,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1293,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1301,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -5990,7 +6011,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1349,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1357,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6003,7 +6024,7 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1373,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1381,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6016,7 +6037,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1416,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1424,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6025,7 +6046,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1430,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1438,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6033,7 +6054,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1475,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1483,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3068865..3bef722 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -48,4 +48,14 @@ subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FL str(project / 'tools/riot-fullscreen-geometry-check.m'), '-o', str(probe)], check=True) subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'Foundation', - '-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True) + '-o', str(project / 'build/riot-fullscreen-geometry-check')], check=True) + +# Query the actual native hints after Cocoa title/style changes, without mapping windows. +probe = project / 'build/riot-window-decorations-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O3', '-march=native', '-mtune=generic', '-UNDEBUG', '-Wno-macro-redefined', '-c', + str(project / 'tools/riot-window-decorations-check.m'), '-o', str(probe)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', + '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit', + '-framework', 'Foundation', '-framework', 'CoreGraphics', + '-o', str(project / 'build/riot-window-decorations-check')], check=True) diff --git a/tools/riot-window-decorations-check.m b/tools/riot-window-decorations-check.m new file mode 100644 index 0000000..0bff1f1 --- /dev/null +++ b/tools/riot-window-decorations-check.m @@ -0,0 +1,86 @@ +/* Standalone Cocoa/X11 regression: no client windows, hooks or input. */ +#import +#import +#include +#include +#include +#include + +@protocol DecorationProbeDisplay +- (void *)display; +@end + +@interface NSObject (DecorationProbe) +- (unsigned long)windowHandle; +- (void)recreateXWindowWithAlpha:(BOOL)alpha; +@end + +static BOOL undecorated(NSWindow *window) { + void *xlib = dlopen("/usr/lib/native/libX11.dylib", RTLD_LAZY); + assert(xlib); + Atom (*intern)(Display *, const char *, Bool) = dlsym(xlib, "XInternAtom"); + int (*sync)(Display *, Bool) = dlsym(xlib, "XSync"); + int (*get)(Display *, Window, Atom, long, long, Bool, Atom, + Atom *, int *, unsigned long *, unsigned long *, unsigned char **) = + dlsym(xlib, "XGetWindowProperty"); + int (*freeData)(void *) = dlsym(xlib, "XFree"); + assert(intern && sync && get && freeData); + id native = [window platformWindow]; + Display *display = (Display *)[(id)[NSDisplay currentDisplay] display]; + sync(display, False); + Atom type = None; + int format = 0; + unsigned long count = 0, remaining = 0; + unsigned char *data = NULL; + int status = get(display, [native windowHandle], intern(display, "_MOTIF_WM_HINTS", False), + 0, 5, False, AnyPropertyType, &type, &format, &count, &remaining, &data); + assert(status == Success); + BOOL result = format == 32 && count >= 3 && data && + (((unsigned long *)data)[0] & 2) && ((unsigned long *)data)[2] == 0; + if (data) freeData(data); + dlclose(xlib); + return result; +} + +static void assertDecoration(NSWindow *window, BOOL expected, const char *name) { + BOOL actual = undecorated(window); + printf("WINDOW_DECORATION case=%s undecorated=%d expected=%d\n", name, actual, expected); + assert(actual == expected); +} + +int main(void) { + setbuf(stdout, NULL); + @autoreleasepool { + [NSApplication sharedApplication]; + NSWindow *window = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180) + styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO]; + assertDecoration(window, NO, "ordinary titled window"); + [window setTitleVisibility:1]; + assertDecoration(window, NO, "hidden title alone preserves frame"); + [window setTitlebarAppearsTransparent:YES]; + assertDecoration(window, YES, "hidden and transparent title removes frame"); + [window setTitleVisibility:0]; + assertDecoration(window, NO, "visible title restores frame"); + [window setTitleVisibility:1]; + [window setTitlebarAppearsTransparent:NO]; + assertDecoration(window, NO, "opaque title restores frame"); + [window setStyleMask:NSTexturedBackgroundWindowMask]; + assertDecoration(window, YES, "untitled with extra flags"); + [window setStyleMask:NSTitledWindowMask]; + assertDecoration(window, NO, "titled style restored"); + [window setStyleMask:NSBorderlessWindowMask]; + assertDecoration(window, YES, "borderless after creation"); + [window setStyleMask:NSTitledWindowMask]; + [window setTitlebarAppearsTransparent:YES]; + [(id)[window platformWindow] recreateXWindowWithAlpha:NO]; + assertDecoration(window, YES, "recreated native window retains client chrome"); + NSWindow *deferred = [[NSWindow alloc] initWithContentRect:NSMakeRect(20, 20, 320, 180) + styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:YES]; + [deferred setTitleVisibility:1]; + [deferred setTitlebarAppearsTransparent:YES]; + assertDecoration(deferred, YES, "properties set before native creation"); + puts("WINDOW_DECORATIONS_CHECK cases=10 pass=1"); + // Windows stay unmapped: the probe never changes compositor focus. + } + return 0; +} From 8f97990d0329377bf2d04f9b8b74c62bdb3b8ce0 Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 01:15:03 -0300 Subject: [PATCH 11/54] Implement Cocoa local event monitors for native client dragging --- docs/TESTING.md | 32 ++++++++++ patches/cocotron.patch | 88 ++++++++++++++++++++++++--- tools/build-riot-x11-runtime.py | 9 +++ tools/riot-event-monitors-check.m | 98 +++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 tools/riot-event-monitors-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 11a684d..7f8c1b9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -119,6 +119,38 @@ Expected: `CORETEXT_FONT_LIST pass=1` with nonzero family/face counts (306/1124 The font probe reproduces Chromium's installed-family enumeration, mandatory family matching and localized-name lookup with a null language output. It checks ownership of the single-match result and that a nonexistent mandatory family produces no substitute. The resize probe checks backing-layer bounds immediately after frame/bounds changes, including a scaled view and a child layer, without relying on input or coordinate conversion to refresh them. It also verifies that native parent size is current during child layout, and that adding an overlay sibling after the content view does not redirect subsequent content resizing into that overlay. The old sibling selection left content at 1600×900 while stretching the 30×20 overlay to 1280×720; the corrected layout resizes the content and preserves the overlay. Before the fixes, these checks report a stale 1280×720 layer and native parent after a 1600×900 resize. The `FRAME_SIZE_HOOK` check uses a subclass whose `setFrameSize:` publishes its viewport size. It checks growth and shrinkage through parent autoresizing, direct `setFrame:`, and direct `setFrameSize:`. Before component-setter dispatch was corrected only the final direct size setter reached the subclass (one callback); afterward all four updates do, with current geometry visible inside the callback. No probe launches or contacts Riot/League. The user confirmed that the language dropdown and League window resizing work after the final X11 correction (2026-10-15). The practice-tool crash remains unresolved. +### Local event monitors and client dragging + +`python3 tools/build-riot-x11-runtime.py` builds +`build/riot-event-monitors-check`. With patched AppKit installed and clients +closed, run: + +```sh +darling shell env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries \ + DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks \ + /Volumes/SystemRoot/path/to/darling-lol/build/riot-event-monitors-check +``` + +Expected: nine `PASS` lines and exit status 0. This independent probe tests +multiple monitors, mask filtering, removal, consumption, event replacement, +self-removal, and the in-flight snapshot when another monitor is removed. +Monitor order is unspecified and is deliberately not asserted. It dispatches +only synthetic events to its own unmapped window. +The same source runs against native macOS AppKit: + +```sh +clang -fblocks -framework AppKit tools/riot-event-monitors-check.m \ + -o /tmp/riot-event-monitors-check +/tmp/riot-event-monitors-check +``` + +Native reference: macOS 26.6.2. LeagueClientUx uses local +monitors for custom window movement; intercepting a fixed titlebar strip instead +would bypass its own drag policy and risk stealing its window-control clicks. +For a live regression check, drag its header, drag ordinary content (which must +not move the window), minimize and restore it, and check Riot Client header +dragging separately. No match is required. + ### X11 window decorations `python3 tools/build-riot-x11-runtime.py` also builds diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 186b72f..6934ffc 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -126,7 +126,7 @@ index d543e329..e320a3af 100644 { return [NSMethodSignature signatureWithObjCTypes: "v@:"]; diff --git a/AppKit/NSApplication.m b/AppKit/NSApplication.m -index 8e7508e2..6fc86b32 100644 +index 8e7508e2..5abc49b7 100644 --- a/AppKit/NSApplication.m +++ b/AppKit/NSApplication.m @@ -17,6 +17,9 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER @@ -239,10 +239,14 @@ index 8e7508e2..6fc86b32 100644 do { pool = [NSAutoreleasePool new]; -@@ -716,6 +744,12 @@ NSApplication *NSApp = nil; +@@ -716,6 +744,16 @@ NSApplication *NSApp = nil; } - (void) sendEvent: (NSEvent *) event { ++ extern NSEvent *_NSApplyLocalEventMonitors(NSEvent *); ++ event = _NSApplyLocalEventMonitors(event); ++ if (event == nil) ++ return; + static int inputTrace = -1; + if (inputTrace < 0) inputTrace = getenv("RCLOL_INPUT_TRACE") != NULL; + if (inputTrace && ([event type] == NSKeyDown || [event type] == NSLeftMouseDown)) @@ -252,7 +256,7 @@ index 8e7508e2..6fc86b32 100644 if ([event type] == NSKeyDown) { unsigned modifierFlags = [event modifierFlags]; -@@ -742,6 +776,61 @@ NSApplication *NSApp = nil; +@@ -742,6 +780,61 @@ NSApplication *NSApp = nil; do { NSAutoreleasePool *pool = [NSAutoreleasePool new]; @@ -648,10 +652,49 @@ index cacd8999..9422d869 100644 NSInvalidAbstractInvocation(); return nil; diff --git a/AppKit/NSEvent.subproj/NSEvent.m b/AppKit/NSEvent.subproj/NSEvent.m -index d0bad986..d0b1b4aa 100644 +index d0bad986..fecfa9f8 100644 --- a/AppKit/NSEvent.subproj/NSEvent.m +++ b/AppKit/NSEvent.subproj/NSEvent.m -@@ -40,6 +40,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +@@ -29,6 +29,38 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + #import + #import + #import ++#import ++ ++@interface _NSLocalEventMonitor : NSObject { ++@public ++ NSEventMask _mask; ++ NSEvent * (^_handler)(NSEvent *); ++} ++@end ++ ++@implementation _NSLocalEventMonitor ++- (void) dealloc { ++ [_handler release]; ++ [super dealloc]; ++} ++@end ++ ++static NSMutableArray *_NSLocalEventMonitors; ++ ++/* Snapshot the registrations: changes made by a handler affect subsequent ++ * dispatches, not the event already in flight. Handlers may also enter a nested ++ * event loop, which takes its own snapshot. */ ++NSEvent *_NSApplyLocalEventMonitors(NSEvent *event) { ++ NSArray *monitors = [[_NSLocalEventMonitors copy] autorelease]; ++ for (_NSLocalEventMonitor *monitor in [monitors reverseObjectEnumerator]) { ++ if (event == nil) ++ break; ++ NSUInteger type = [event type]; ++ if (type < sizeof(NSEventMask) * 8 && (monitor->_mask & ((NSEventMask)1 << type))) ++ event = monitor->_handler(event); ++ } ++ return event; ++} + + @implementation NSEvent + +@@ -40,6 +72,10 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ return [[NSDisplay currentDisplay] currentModifierFlags]; } @@ -662,6 +705,34 @@ index d0bad986..d0b1b4aa 100644 - (instancetype) initWithType: (NSEventType) type location: (NSPoint) location modifierFlags: (NSEventModifierFlags) modifierFlags +@@ -350,14 +386,23 @@ static NSTimer *_periodicTimer = nil; + } + + + (id) addLocalMonitorForEventsMatchingMask: (NSEventMask) mask +- handler: (NSEvent * (^)(NSEvent *event)) block ++ handler: (NSEvent * (^)(NSEvent *event)) block + { +- NSUnimplementedMethod(); +- return nil; ++ if (block == nil) ++ return nil; ++ if (_NSLocalEventMonitors == nil) ++ _NSLocalEventMonitors = [NSMutableArray new]; ++ _NSLocalEventMonitor *monitor = [[_NSLocalEventMonitor alloc] init]; ++ monitor->_mask = mask; ++ monitor->_handler = [block copy]; ++ [_NSLocalEventMonitors addObject: monitor]; ++ return [monitor autorelease]; + } + + + (void) removeMonitor: (id) eventMonitor { +- NSUnimplementedMethod(); ++ if ([_NSLocalEventMonitors indexOfObjectIdenticalTo: eventMonitor] != NSNotFound) { ++ [_NSLocalEventMonitors removeObjectIdenticalTo: eventMonitor]; ++ } + } + + @end diff --git a/AppKit/NSEvent.subproj/NSEvent_mouse.m b/AppKit/NSEvent.subproj/NSEvent_mouse.m index 31c65aac..f7d55ec0 100644 --- a/AppKit/NSEvent.subproj/NSEvent_mouse.m @@ -6184,7 +6255,7 @@ index 8860a087..a897e2ce 100644 - (NSArray *) orderedWindowNumbers; diff --git a/AppKit/include/AppKit/NSEvent.h b/AppKit/include/AppKit/NSEvent.h -index 0677f2e7..d16080f4 100644 +index 0677f2e7..b6daef18 100644 --- a/AppKit/include/AppKit/NSEvent.h +++ b/AppKit/include/AppKit/NSEvent.h @@ -83,6 +83,8 @@ typedef NS_ENUM(NSUInteger, NSEventType) { @@ -6196,11 +6267,14 @@ index 0677f2e7..d16080f4 100644 NSAppKitSystem = 100, -@@ -212,6 +214,7 @@ enum { NSApplicationActivated = 0, NSApplicationDeactivated = 1 }; +@@ -212,6 +214,10 @@ enum { NSApplicationActivated = 0, NSApplicationDeactivated = 1 }; + (NSPoint) mouseLocation; + (NSEventModifierFlags) modifierFlags; ++ (NSUInteger) pressedMouseButtons; +++ (id) addLocalMonitorForEventsMatchingMask: (NSEventMask) mask ++ handler: (NSEvent * (^)(NSEvent *event)) block; +++ (void) removeMonitor: (id) eventMonitor; - (instancetype) initWithType: (NSEventType) type location: (NSPoint) location diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3bef722..192d9f3 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -59,3 +59,12 @@ subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit', '-framework', 'Foundation', '-framework', 'CoreGraphics', '-o', str(project / 'build/riot-window-decorations-check')], check=True) + +# Match native AppKit's local event monitor dispatch semantics. +probe = project / 'build/riot-event-monitors-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', + str(project / 'tools/riot-event-monitors-check.m'), '-o', str(probe)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', + '-nostdlib', '-isysroot', sdk, str(probe), '-lSystem', '-lobjc', '-framework', 'AppKit', + '-framework', 'Foundation', '-o', str(project / 'build/riot-event-monitors-check')], check=True) diff --git a/tools/riot-event-monitors-check.m b/tools/riot-event-monitors-check.m new file mode 100644 index 0000000..2b21195 --- /dev/null +++ b/tools/riot-event-monitors-check.m @@ -0,0 +1,98 @@ +/* Local event dispatch regression; also runs unchanged against native AppKit. + * No global input, client hooks, account access, or mapped windows. */ +#import + +static NSMutableString *calls; +static int failures; + +@interface MonitorTestWindow : NSWindow +@end +@implementation MonitorTestWindow +- (void) sendEvent: (NSEvent *) event { + [calls appendFormat: @"W%ld", (long)[event type]]; +} +@end + +static void expectCalls(NSString *name, NSString *expected) { + BOOL ok = [calls isEqual: expected]; + printf("%s %s got=%s expected=%s\n", ok ? "PASS" : "FAIL", + [name UTF8String], [calls UTF8String], [expected UTF8String]); + failures += !ok; + [calls setString: @""]; +} + +/* AppKit does not promise an order between independent registrations. Native + * ordering can change with block addresses, even on the same OS build. */ +static void expectBoth(NSString *name) { + NSString *expected = [calls isEqual: @"ABW1"] ? @"ABW1" : @"BAW1"; + expectCalls(name, expected); +} + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + calls = [NSMutableString new]; + NSWindow *window = [[MonitorTestWindow alloc] + initWithContentRect: NSMakeRect(100, 100, 300, 200) + styleMask: 0 backing: NSBackingStoreBuffered defer: NO]; + NSEvent *down = [NSEvent mouseEventWithType: NSLeftMouseDown + location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0 + windowNumber: [window windowNumber] context: nil + eventNumber: 1 clickCount: 1 pressure: 1]; + NSEvent *up = [NSEvent mouseEventWithType: NSLeftMouseUp + location: NSMakePoint(10, 10) modifierFlags: 0 timestamp: 0 + windowNumber: [window windowNumber] context: nil + eventNumber: 2 clickCount: 1 pressure: 0]; + id a = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"A"]; return event; + }]; + id b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return event; + }]; + [NSApp sendEvent: down]; expectBoth(@"multiple-monitors"); + [NSApp sendEvent: up]; expectCalls(@"mask-filter", @"W2"); + [NSEvent removeMonitor: b]; + [NSApp sendEvent: down]; expectCalls(@"remove", @"AW1"); + [NSEvent removeMonitor: a]; + + b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return nil; + }]; + [NSApp sendEvent: down]; expectCalls(@"consume", @"B"); + [NSEvent removeMonitor: b]; + b = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; return up; + }]; + [NSApp sendEvent: down]; expectCalls(@"replace-event", @"BW2"); + [NSEvent removeMonitor: b]; + + __block id selfRemoving; + selfRemoving = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"S"]; + [NSEvent removeMonitor: selfRemoving]; return event; + }]; + [NSApp sendEvent: down]; expectCalls(@"self-remove", @"SW1"); + [NSApp sendEvent: down]; expectCalls(@"self-remove-next", @"W1"); + __block id pendingA, pendingB; + pendingA = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"A"]; + [NSEvent removeMonitor: pendingB]; return event; + }]; + pendingB = [NSEvent addLocalMonitorForEventsMatchingMask: NSLeftMouseDownMask + handler: ^NSEvent *(NSEvent *event) { + [calls appendString: @"B"]; + [NSEvent removeMonitor: pendingA]; return event; + }]; + [NSApp sendEvent: down]; expectBoth(@"remove-pending-snapshot"); + [NSApp sendEvent: down]; expectCalls(@"remove-pending-next", @"W1"); + [window release]; + [calls release]; + } + return failures ? 1 : 0; +} From b6c70354b3177c4dbbffbdfd0107f12c67405f3b Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 01:15:32 -0300 Subject: [PATCH 12/54] Recompile NSEvent in incremental AppKit runtime builds --- tools/build-riot-appkit-runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/build-riot-appkit-runtime.py b/tools/build-riot-appkit-runtime.py index c4a677d..6f64261 100644 --- a/tools/build-riot-appkit-runtime.py +++ b/tools/build-riot-appkit-runtime.py @@ -12,7 +12,7 @@ for line in (framework / 'CMakeFiles/AppKit.dir/flags.make').read_text().splitli name, value = line.split(' = ', 1) flags[name] = shlex.split(value) link = shlex.split((framework / 'CMakeFiles/AppKit.dir/link.txt').read_text()) -for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent_mouse']: +for name in ['NSResponder', 'NSApplication', 'NSWindow', 'NSDisplay', 'NSView', 'NSThemeFrame', 'NSTextInput', 'NSBitmapImageRep', 'NSEvent.subproj/NSEvent', 'NSEvent.subproj/NSEvent_mouse']: obj = project / f'build/riot-{Path(name).name}.o' subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', From 041591352e4d03f21701ca2b1fb4af456f18138f Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:35:16 +0200 Subject: [PATCH 13/54] Present full-window Metal layers on the managed X11 surface for VRR --- docs/TESTING.md | 34 ++++ docs/TROUBLESHOOTING.md | 22 ++ patches/cocotron.patch | 249 ++++++++++++++++++++--- tests/test_x11_vrr.py | 164 +++++++++++++++ tools/build-riot-coregraphics-runtime.py | 13 +- tools/build-riot-x11-runtime.py | 2 +- tools/riot-metal-presentation-check.mm | 40 +++- upstream/manifest.json | 2 +- 8 files changed, 482 insertions(+), 44 deletions(-) create mode 100644 tests/test_x11_vrr.py diff --git a/docs/TESTING.md b/docs/TESTING.md index b1d7df4..86be75d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -533,3 +533,37 @@ launch, window placement, click alignment and edge panning before rollout. After installing the tested X11 and CoreGraphics binaries locally, the user reported that everything was working (2026-10-16). Fullscreen policy was unchanged. + +## VRR presentation regression (#46) + +```sh +python3 -m unittest discover -s tests -p 'test_x11_vrr.py' +``` + +The host fixtures exercise the patched eligibility, X11 property updates and +EGL surface ownership transitions. They cover hidden/titled/partial views, +multiple views, parent recreation, child fallback and current-context handling. + +Build `build-riot-coregraphics-runtime.py`, `build-riot-x11-runtime.py` and +`build-riot-metal-presentation.py` together. Stage all three updated frameworks +(CoreGraphics, AppKit's X11 backend, QuartzCore) when testing isolated binaries. +Run `riot-metal-presentation-check` with `RIOT_PRESENTATION_PARENT=1` to create +an owned borderless game-like window. It verifies the parent drawable is +borrowed. Add `RIOT_PRESENTATION_RESIZE=1` to verify fallback to a child at +half width and borrowing the parent again when restored. Add +`RIOT_PRESENTATION_NESTED=1` for nested layers. +The default probe still exercises the independent child-window path. + +For physical VRR validation, enable adaptive sync in the monitor and desktop, +then manually test fullscreen and borderless League with a frame cap inside +the monitor's VRR range (for example 157 FPS on a 165 Hz display), with VSync +off. Check the monitor's live refresh display as well as the game FPS counter. +Also test leaving fullscreen, restoring it, resizing, and alt-tab. Report GPU, +driver, compositor and session type. A stable FPS counter or the X11 property +alone does not prove that the monitor is using variable refresh. + +Local native validation on 2026-10-19 passed all three modes (child, parent +with resize, nested parent with resize), including Vulkan-to-GL pixel readback +and ten presentations per mode, using staged frameworks in a private prefix. +Physical VRR and in-game performance on the reporter's Mutter setup remain +unverified. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 3574291..033ca2d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -541,3 +541,25 @@ Recovery requires every matching client to be at least 60 seconds old and a complete Hyprland window record (`class`, `title`, `mapped`, `hidden`). A second process scan after window inspection must find the same client identities and no game. Missing information or shutdown failure leaves normal launch in charge. + +## Variable refresh rate (VRR) + +Full-window borderless game layers now use the opaque managed X11 parent +surface for presentation. Previously they swapped an independent child while +the managed parent remained static. Using one surface lets the compositor +track the game's updates for VRR and avoids scanning out an empty parent. +Partial views, titled windows and other applications retain child drawables. +The child fallback retains the compositor-bypass prohibition that prevents +black screens. The fullscreen parent path advertises `_VARIABLE_REFRESH=1`; +leaving fullscreen clears that request. Desktop/driver policy still decides +whether to enable adaptive sync. + +With the game and both clients closed, rebuild with `./scripts/update.sh`; +CoreGraphics, QuartzCore and the X11 backend must be updated together. Enable +VRR in the desktop and monitor, and compare the monitor's live refresh reading +to a frame cap below its maximum refresh, initially with VSync off. VRR and +VSync are separate: this change does not implement an in-game frame limiter. +See [the VRR probe and manual checks](TESTING.md#vrr-presentation-regression-46). + +Standalone child, parent and nested-parent presentation probes passed locally; +monitor-level VRR and the reporter's in-game behavior still need confirmation. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 22a1fa3..167ddb9 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -4941,7 +4941,7 @@ index 0000000..31fa646 + +#endif diff --git a/AppKit/X11.backend/X11Window.h b/AppKit/X11.backend/X11Window.h -index bfe3839a..48cfac4c 100644 +index bfe3839a..bc592711 100644 --- a/AppKit/X11.backend/X11Window.h +++ b/AppKit/X11.backend/X11Window.h @@ -26,7 +26,7 @@ @@ -4953,7 +4953,7 @@ index bfe3839a..48cfac4c 100644 @interface X11Window : CGWindow { int _level; // TODO: care about this value -@@ -47,8 +47,32 @@ +@@ -47,8 +47,33 @@ BOOL _mapped; CGPoint _lastMotionPos; BOOL _isModal; @@ -4963,6 +4963,7 @@ index bfe3839a..48cfac4c 100644 + BOOL _hasAlpha; // created with the 32-bit ARGB visual + BOOL _wasMapped; // the visual is settled at the first map BOOL _hasShadow; ++ id _layerDrawableOwner; + NSTimer *_remoteFrameTimer; + Window _remoteFrameWindow; + GC _remoteFrameGC; @@ -4986,19 +4987,22 @@ index bfe3839a..48cfac4c 100644 @public XIC _xic; -@@ -60,9 +84,9 @@ +@@ -60,9 +85,12 @@ - (Visual *) visual; - (Drawable) drawable; - (NSPoint) transformPoint: (NSPoint) pos; -- (O2Rect) transformFrame: (O2Rect) frame; - (Window) windowHandle; ++- (void *) layerDrawableFor: (id) owner; ++- (void) acquireLayerDrawable: (id) owner; ++- (void) releaseLayerDrawable: (id) owner; +- (BOOL) handleRemoteFrameExpose: (Window) window; - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..730d4692 100644 +index f4925b66..55be7854 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5362,7 +5366,7 @@ index f4925b66..730d4692 100644 } - (void) dealloc { -@@ -279,24 +502,30 @@ static NSData *makeWindowIcon() { +@@ -279,24 +502,59 @@ static NSData *makeWindowIcon() { return _styleMask; } @@ -5399,7 +5403,36 @@ index f4925b66..730d4692 100644 } + return NO; +} ++ ++/* Keep the game on one opaque managed surface. Mutter's VRR frame clock ++ * follows that surface's repaint events, not an independently swapped child. ++ * _VARIABLE_REFRESH also advertises eligibility to X11 drivers that use it. */ ++- (void) updateLayerPresentationHints: (BOOL) fullscreen { ++ if (!X11IsLeagueGameWindow(_display, _window, _delegate)) return; ++ unsigned long adaptive = fullscreen && _layerDrawableOwner != nil; ++ unsigned long bypass = fullscreen && !_layerDrawableOwner ? 2 : 0; ++ XChangeProperty(_display, _window, XInternAtom(_display, "_VARIABLE_REFRESH", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&adaptive, 1); ++ XChangeProperty(_display, _window, XInternAtom(_display, "_NET_WM_BYPASS_COMPOSITOR", False), ++ XA_CARDINAL, 32, PropModeReplace, (unsigned char *)&bypass, 1); ++} ++ ++- (void *) layerDrawableFor: (id) owner { ++ return (!_layerDrawableOwner || _layerDrawableOwner == owner) && !_hasAlpha && X11IsLeagueGameWindow(_display, _window, _delegate) ? _cglWindow : NULL; ++} ++- (void) acquireLayerDrawable: (id) owner { ++ if (_layerDrawableOwner && _layerDrawableOwner != owner) return; ++ _layerDrawableOwner = owner; ++ [self updateLayerPresentationHints: _fullscreenRequested]; ++} ++ ++- (void) releaseLayerDrawable: (id) owner { ++ if (_layerDrawableOwner != owner) return; ++ _layerDrawableOwner = nil; ++ [self updateLayerPresentationHints: _fullscreenRequested]; ++} ++ +- (void) requestFullscreen: (BOOL) fullscreen force: (BOOL) force { + if (fullscreen) { + unsigned long bypass = 2; @@ -5409,7 +5442,7 @@ index f4925b66..730d4692 100644 if (!_mapped) { long states[2]; int states_cnt = 0; -@@ -304,7 +533,7 @@ static NSData *makeWindowIcon() { +@@ -304,7 +562,7 @@ static NSData *makeWindowIcon() { states[states_cnt++] = (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); } @@ -5418,7 +5451,7 @@ index f4925b66..730d4692 100644 states[states_cnt++] = (long) XInternAtom( _display, "_NET_WM_STATE_FULLSCREEN", False); } -@@ -312,14 +541,13 @@ static NSData *makeWindowIcon() { +@@ -312,14 +570,13 @@ static NSData *makeWindowIcon() { XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, 32, PropModeReplace, (const unsigned char *) states, states_cnt); @@ -5435,11 +5468,12 @@ index f4925b66..730d4692 100644 event.data.l[1] = (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); event.data.l[3] = 1; -@@ -327,6 +555,35 @@ static NSData *makeWindowIcon() { +@@ -327,6 +584,36 @@ static NSData *makeWindowIcon() { SubstructureNotifyMask | SubstructureRedirectMask, (XEvent *) &event); } + _fullscreenRequested = fullscreen; ++ [self updateLayerPresentationHints: fullscreen]; + XFlush(_display); +} + @@ -5471,7 +5505,7 @@ index f4925b66..730d4692 100644 } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +595,6 @@ static NSData *makeWindowIcon() { +@@ -338,7 +625,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5479,7 +5513,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +612,14 @@ static NSData *makeWindowIcon() { +@@ -356,8 +642,14 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5494,7 +5528,7 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +639,27 @@ static NSData *makeWindowIcon() { +@@ -377,15 +669,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5527,7 +5561,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +667,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +697,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -5556,7 +5590,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +717,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +747,392 @@ static NSData *makeWindowIcon() { } } @@ -5949,7 +5983,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1152,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1182,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -5983,7 +6017,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1188,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1218,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -6009,7 +6043,7 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1262,21 @@ static NSData *makeWindowIcon() { +@@ -543,6 +1292,21 @@ static NSData *makeWindowIcon() { - (void) makeKey { [self ensureMapped]; XRaiseWindow(_display, _window); @@ -6031,7 +6065,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1293,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1323,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6061,7 +6095,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1349,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1379,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6074,10 +6108,12 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1373,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1403,14 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { ++ // QuartzCore owns presentation while a full-window game layer borrows it. ++ if (_layerDrawableOwner) return; + /* A remote compositor surface owns this drawable while it is present. + * Swapping the local Onyx surface would erase it until the next timer + * tick. Keep local presentation for windows without a usable remote frame. */ @@ -6087,7 +6123,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1416,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1448,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6096,7 +6132,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1430,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1462,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6104,7 +6140,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1475,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1507,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } @@ -9489,7 +9525,7 @@ index 5dcac115..5de4f03f 100644 + @end diff --git a/QuartzCore/CALayerContext.m b/QuartzCore/CALayerContext.m -index 5dce8c5..3a7f937 100644 +index 5dce8c56..77b729c7 100644 --- a/QuartzCore/CALayerContext.m +++ b/QuartzCore/CALayerContext.m @@ -1,47 +1,77 @@ @@ -9582,8 +9618,9 @@ index 5dce8c5..3a7f937 100644 + [_deleteTextureIds release]; [_renderer release]; CGLReleaseContext(_glContext); +- CGLDestroyWindow(_cglWindow); + CGLReleasePixelFormat(_pixelFormat); - CGLDestroyWindow(_cglWindow); ++ if (_ownsCglWindow) CGLDestroyWindow(_cglWindow); [_subwindow release]; - [_layer release]; [super dealloc]; @@ -9612,12 +9649,18 @@ index 5dce8c5..3a7f937 100644 } - (void) setSubwindow: (CGSubWindow*) subwindow -@@ -68,16 +108,50 @@ +@@ -64,20 +104,55 @@ + CGSubWindow* oldSubwindow = _subwindow; + + if (_cglWindow) { +- CGLDestroyWindow(_cglWindow); ++ if (_ownsCglWindow) CGLDestroyWindow(_cglWindow); } _subwindow = [subwindow retain]; - _cglWindow = CGLGetWindow([_subwindow nativeWindow]); + _cglWindow = NULL; ++ _ownsCglWindow = NO; + if (_subwindow != nil) { + if (_glContext == NULL) { + CGLPixelFormatAttribute attributes[] = { 0 }; @@ -9636,7 +9679,7 @@ index 5dce8c5..3a7f937 100644 + } + } + if (_glContext != NULL) -+ _cglWindow = CGLGetWindow([_subwindow nativeWindow]); ++ [self updateDrawable]; + } [_subwindow show]; @@ -9664,7 +9707,7 @@ index 5dce8c5..3a7f937 100644 } - (void) assignTextureIdsToLayerTree: (CALayer *) layer { -@@ -93,8 +167,27 @@ +@@ -93,8 +168,43 @@ [self assignTextureIdsToLayerTree: child]; } @@ -9681,6 +9724,22 @@ index 5dce8c5..3a7f937 100644 + for (CALayer *child in layer.sublayers) + [self prepareMetalLayers: child]; +} ++ ++- (void) updateDrawable { ++ if (!_subwindow || !_glContext) return; ++ [_subwindow setAllowsParentDrawable: hasMetalLayer(_layer)]; ++ void *shared = [_subwindow sharedCGLWindow]; ++ if (shared && shared == _cglWindow && !_ownsCglWindow) return; ++ if (!shared && _cglWindow && _ownsCglWindow) return; ++ CGLContextObj previous = CGLGetCurrentContext(); ++ if (previous == _glContext) CGLSetCurrentContext(NULL); ++ if (_ownsCglWindow) CGLDestroyWindow(_cglWindow); ++ _cglWindow = shared ? shared : CGLGetWindow([_subwindow nativeWindow]); ++ _ownsCglWindow = !shared && _cglWindow != NULL; ++ // Update CGL's surface binding even when another context was current. ++ CGLContextMakeCurrentAndAttachToWindow(_glContext, _cglWindow); ++ if (previous != _glContext) CGLSetCurrentContext(previous); ++} + - (void) renderLayer: (CALayer *) layer { + CFTimeInterval renderLayerStart = CACurrentMediaTime(); @@ -9692,7 +9751,7 @@ index 5dce8c5..3a7f937 100644 glEnable(GL_DEPTH_TEST); glShadeModel(GL_SMOOTH); -@@ -118,27 +211,62 @@ +@@ -118,27 +228,63 @@ [_deleteTextureIds removeAllObjects]; @@ -9714,6 +9773,7 @@ index 5dce8c5..3a7f937 100644 } - (void) render { ++ [self updateDrawable]; + if (_cglWindow == NULL || _layer == nil) + return; + if(skipLocalRender(_layer, _frame.size))return; @@ -9760,7 +9820,7 @@ index 5dce8c5..3a7f937 100644 } - (void) startTimerIfNeeded { -@@ -147,7 +275,7 @@ +@@ -147,7 +293,7 @@ target: self selector: @selector(timer:) userInfo: nil @@ -9769,7 +9829,7 @@ index 5dce8c5..3a7f937 100644 } - (void) deleteTextureId: (NSNumber *) textureId { -@@ -155,7 +283,9 @@ +@@ -155,7 +301,9 @@ } - (void) flush { @@ -11102,10 +11162,14 @@ index 49b03483..5ebbf535 100644 - (void) display; - (void) displayIfNeeded; diff --git a/QuartzCore/include/QuartzCore/CALayerContext.h b/QuartzCore/include/QuartzCore/CALayerContext.h -index e4b0c342..d61c37ba 100644 +index e4b0c342..69b39250 100644 --- a/QuartzCore/include/QuartzCore/CALayerContext.h +++ b/QuartzCore/include/QuartzCore/CALayerContext.h -@@ -20,6 +20,7 @@ +@@ -17,9 +17,11 @@ + NSTimer *_timer; + CGSubWindow* _subwindow; + void* _cglWindow; ++ BOOL _ownsCglWindow; } @property(readonly) CGLContextObj glContext; @@ -11252,3 +11316,124 @@ index 53bacc46..3c6adfb7 100644 - (void) beginSheetForDirectory: (NSString *) path file: (NSString *) name modalForWindow: (NSWindow *) docWindow +diff --git a/CoreGraphics/CGSubWindow.m b/CoreGraphics/CGSubWindow.m +index 008ccd74..6692deb5 100644 +--- a/CoreGraphics/CGSubWindow.m ++++ b/CoreGraphics/CGSubWindow.m +@@ -8,6 +8,10 @@ + return NULL; + } + ++// Backends may lend an existing EGL surface for full-window layer rendering. ++- (void *) sharedCGLWindow { return NULL; } ++- (void) setAllowsParentDrawable: (BOOL) allowed {} ++ + - (void) show { + O2InvalidAbstractInvocation(); + } +diff --git a/CoreGraphics/include/CoreGraphics/CGSubWindow.h b/CoreGraphics/include/CoreGraphics/CGSubWindow.h +index de7a3496..79ee0d31 100644 +--- a/CoreGraphics/include/CoreGraphics/CGSubWindow.h ++++ b/CoreGraphics/include/CoreGraphics/CGSubWindow.h +@@ -6,6 +6,9 @@ + @interface CGSubWindow : NSObject + + - (void *) nativeWindow; ++// Borrowed surface; the subwindow/backend retains ownership. ++- (void *) sharedCGLWindow; ++- (void) setAllowsParentDrawable: (BOOL) allowed; + + - (void) show; + - (void) hide; +diff --git a/AppKit/X11.backend/X11SubWindow.m b/AppKit/X11.backend/X11SubWindow.m +index 738ab457..6596dab5 100644 +--- a/AppKit/X11.backend/X11SubWindow.m ++++ b/AppKit/X11.backend/X11SubWindow.m +@@ -1,6 +1,7 @@ + #import "X11SubWindow.h" + #import "X11Display.h" + #import "X11Window.h" ++#import + + @implementation X11SubWindow + +@@ -15,8 +16,9 @@ + } + + - (id) initWithParentWindow: (X11Window *) parent frame: (CGRect) frame { +- _parent = parent; ++ _parent = [parent retain]; + frame = [self convertFrame: frame]; ++ _drawableFrame = frame; + + _display = [(X11Display *) [NSDisplay currentDisplay] display]; + +@@ -31,7 +33,9 @@ + } + + - (void) dealloc { ++ if (_usingParentDrawable) [_parent releaseLayerDrawable: self]; + XDestroyWindow(_display, _window); ++ [_parent release]; + [super dealloc]; + } + +@@ -39,16 +43,41 @@ + return (void *) _window; + } + ++- (void) setAllowsParentDrawable: (BOOL) allowed { ++ _allowsParentDrawable = allowed; ++} ++ ++- (void *) sharedCGLWindow { ++ // Only a full-window borderless game view may draw into the managed parent. ++ // Other views keep independent child drawables and their own coordinates. ++ CGRect parentFrame = [_parent frame]; ++ BOOL coversParent = _allowsParentDrawable && _visible && !([_parent styleMask] & NSTitledWindowMask) && ++ CGRectEqualToRect(_drawableFrame, CGRectMake(0, 0, parentFrame.size.width, parentFrame.size.height)); ++ void *surface = coversParent ? [_parent layerDrawableFor: self] : NULL; ++ BOOL usingParent = surface != NULL; ++ if (usingParent != _usingParentDrawable) { ++ if (usingParent) [_parent acquireLayerDrawable: self]; ++ else [_parent releaseLayerDrawable: self]; ++ _usingParentDrawable = usingParent; ++ } ++ if (_visible && !usingParent) XMapWindow(_display, _window); ++ else XUnmapWindow(_display, _window); ++ return surface; ++} ++ + - (void) show { +- XMapWindow(_display, _window); ++ _visible = YES; ++ [self sharedCGLWindow]; + } + + - (void) hide { +- XUnmapWindow(_display, _window); ++ _visible = NO; ++ [self sharedCGLWindow]; + } + + - (void) setFrame: (CGRect) frame { + frame = [self convertFrame: frame]; ++ _drawableFrame = frame; + + XMoveResizeWindow(_display, _window, frame.origin.x, frame.origin.y, + frame.size.width, frame.size.height); +diff --git a/AppKit/X11.backend/X11SubWindow.h b/AppKit/X11.backend/X11SubWindow.h +index 65b63a3e..e5b913b1 100644 +--- a/AppKit/X11.backend/X11SubWindow.h ++++ b/AppKit/X11.backend/X11SubWindow.h +@@ -7,6 +7,10 @@ + X11Window *_parent; + Display *_display; + Window _window; ++ CGRect _drawableFrame; ++ BOOL _visible; ++ BOOL _allowsParentDrawable; ++ BOOL _usingParentDrawable; + } + + - initWithParentWindow: (X11Window *) parent frame: (CGRect) frame; diff --git a/tests/test_x11_vrr.py b/tests/test_x11_vrr.py new file mode 100644 index 0000000..267a995 --- /dev/null +++ b/tests/test_x11_vrr.py @@ -0,0 +1,164 @@ +"""Compile the patched VRR eligibility and drawable ownership methods with inert fixtures.""" +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class VrrChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def run_cpp(self, source): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) + (path/'check.cpp').write_text('#include \n' + source) + result = subprocess.run(['c++', '-std=c++17', str(path/'check.cpp'), '-o', str(path/'check')], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + subprocess.run([str(path/'check')], check=True, timeout=10) + + def test_hints_only_allow_vrr_for_fullscreen_parent_presentation(self): + source = self.patched('cocotron', 'AppKit/X11.backend/X11Window.m') + method = body(source, '- (void) updateLayerPresentationHints: (BOOL) fullscreen') + getter = body(source, '- (void *) layerDrawableFor: (id) owner') + acquire = body(source, '- (void) acquireLayerDrawable: (id) owner').replace('[self updateLayerPresentationHints: _fullscreenRequested]', 'hints(_fullscreenRequested)') + release = body(source, '- (void) releaseLayerDrawable: (id) owner').replace('[self updateLayerPresentationHints: _fullscreenRequested]', 'hints(_fullscreenRequested)') + self.run_cpp(r''' +#include +#include +using BOOL=bool; +constexpr auto nil=nullptr; +constexpr int False=0, XA_CARDINAL=6, PropModeReplace=0; +static bool game=true; +static int adaptive=-1,bypass=-1,calls=0; +static bool X11IsLeagueGameWindow(int,int,int) { return game; } +static const char* XInternAtom(int,const char* name,int) { return name; } +static int XChangeProperty(int,int,const char* name,int type,int bits,int mode,unsigned char* data,int count) { + assert(type==XA_CARDINAL && bits==32 && mode==PropModeReplace && count==1); + int value=*(unsigned long*)data; ++calls; + if(!strcmp(name,"_VARIABLE_REFRESH")) adaptive=value; + else { assert(!strcmp(name,"_NET_WM_BYPASS_COMPOSITOR")); bypass=value; } + return 0; +} +struct Window { + int _display=1,_window=1,_delegate=1; + void* _layerDrawableOwner=nullptr; + void* _cglWindow=(void*)3; bool _hasAlpha=false,_fullscreenRequested=true; + void hints(BOOL fullscreen) { +''' + method + '\n }\n void* drawable(void* owner) {' + getter + '\n}\n void acquire(void* owner) {' + acquire + '\n}\n void release(void* owner) {' + release + r''' + } +}; +int main() { + Window w; + w.hints(true); assert(adaptive==0 && bypass==2); + w._layerDrawableOwner=&w; + w.hints(true); assert(adaptive==1 && bypass==0); + w.hints(false); assert(adaptive==0 && bypass==0); + w._layerDrawableOwner=nullptr; + w.hints(true); assert(adaptive==0 && bypass==2); + int before=calls; game=false; w.hints(true); assert(calls==before); + assert(!w.drawable(&w)); game=true; + int a,b; + assert(w.drawable(&a)==(void*)3); + w.acquire(&a); assert(w.drawable(&a)==(void*)3 && !w.drawable(&b)); + w.acquire(&b); assert(w._layerDrawableOwner==&a); + w.release(&b); assert(w._layerDrawableOwner==&a); + w.release(&a); assert(!w._layerDrawableOwner && w.drawable(&b)==(void*)3); + w._hasAlpha=true; assert(!w.drawable(&a)); +} +''') + + def test_subwindows_borrow_only_when_visible_borderless_and_covering_parent(self): + source = self.patched('cocotron', 'AppKit/X11.backend/X11SubWindow.m') + method = body(source, '- (void *) sharedCGLWindow') + replacements = {'[_parent frame]':'_parent->frame', '[_parent styleMask]':'_parent->style', + '[_parent layerDrawableFor: self]':'_parent->drawable(this)', + '[_parent acquireLayerDrawable: self]':'_parent->acquire(this)', + '[_parent releaseLayerDrawable: self]':'_parent->release(this)'} + for old,new in replacements.items(): + method=method.replace(old,new) + self.run_cpp(r''' +#include +using BOOL=bool; +struct CGRect { struct {double x,y;} origin; struct {double width,height;} size; }; +static CGRect CGRectMake(double x,double y,double w,double h) {return {{x,y},{w,h}};} +static bool CGRectEqualToRect(CGRect a,CGRect b) {return a.origin.x==b.origin.x && a.origin.y==b.origin.y && a.size.width==b.size.width && a.size.height==b.size.height;} +constexpr int NSTitledWindowMask=1; +static bool mapped; +static void XMapWindow(int,int) {mapped=true;} +static void XUnmapWindow(int,int) {mapped=false;} +struct Parent { + CGRect frame=CGRectMake(0,0,800,600); int style=0; bool eligible=true; + void* owner=nullptr; void* surface=this; + void* drawable(void* requester) {return eligible && (!owner || owner==requester) ? surface : nullptr;} + void acquire(void* requester) {assert(!owner); owner=requester;} + void release(void* requester) {assert(owner==requester); owner=nullptr;} +}; +struct Child { + Parent* _parent; CGRect _drawableFrame=CGRectMake(0,0,800,600); + BOOL _visible=true,_usingParentDrawable=false,_allowsParentDrawable=true; int _display=1,_window=2; + void* shared() { +''' + method + r''' + } +}; +int main() { + Parent p; Child a{&p}; Child b{&p}; + assert(a.shared()==p.surface && !mapped && p.owner==&a); + assert(a.shared()==p.surface); // Repeated lookup does not reacquire. + assert(b.shared()==nullptr && mapped); // A second view cannot share ownership. + a._visible=false; assert(!a.shared() && !mapped && !p.owner); + a._visible=true; assert(a.shared()==p.surface); + a._drawableFrame.size.width=400; + assert(!a.shared() && mapped && !p.owner); + a._drawableFrame=CGRectMake(0,0,800,600); p.style=NSTitledWindowMask; + assert(!a.shared() && mapped); + p.style=0; p.eligible=false; assert(!a.shared() && mapped); + p.eligible=true; a._allowsParentDrawable=false; assert(!a.shared() && mapped); + a._allowsParentDrawable=true; a._drawableFrame.origin.x=1; assert(!a.shared() && mapped); + a._drawableFrame.origin.x=0; assert(a.shared()==p.surface && !mapped); +} +''') + + def test_switching_surfaces_destroys_only_owned_child_drawables(self): + source = self.patched('cocotron', 'QuartzCore/CALayerContext.m') + method = body(source, '- (void) updateDrawable') + method=method.replace('[_subwindow setAllowsParentDrawable: hasMetalLayer(_layer)]', '_subwindow->allow(hasMetalLayer(_layer))') + method=method.replace('[_subwindow sharedCGLWindow]', '_subwindow->shared').replace('[_subwindow nativeWindow]', '_subwindow->native') + self.run_cpp(r''' +#include +using BOOL=bool; +using CGLContextObj=int; +static int current=0,destroyed=0,created=0; +static void* destroyedSurface; +static int CGLGetCurrentContext() {return current;} +static void CGLSetCurrentContext(int c) {current=c;} +static void CGLDestroyWindow(void* surface) {assert(surface==(void*)10); destroyedSurface=surface; ++destroyed;} +static void* CGLGetWindow(void*) {++created; return (void*)10;} +static void CGLContextMakeCurrentAndAttachToWindow(int c,void*) {current=c;} +static bool hasMetalLayer(int layer) {return layer!=0;} +struct Subwindow {void* shared=nullptr; void* native=(void*)2; void allow(bool enabled) {assert(enabled);} }; +struct Context { + Subwindow* _subwindow; int _layer=1; int _glContext=1; void* _cglWindow=nullptr; bool _ownsCglWindow=false; + void update() { +''' + method.replace('CGLSetCurrentContext(NULL)', 'CGLSetCurrentContext(0)') + r''' + } +}; +int main() { + Subwindow sub; Context c{&sub}; + c.update(); assert(created==1 && c._ownsCglWindow); // Ordinary clients still get a child. + c.update(); assert(created==1 && destroyed==0); + current=1; sub.shared=(void*)20; + c.update(); assert(destroyed==1 && destroyedSurface==(void*)10 && !c._ownsCglWindow && current==1); + c.update(); assert(destroyed==1); + sub.shared=(void*)30; c.update(); assert(destroyed==1 && c._cglWindow==(void*)30); + sub.shared=nullptr; c.update(); assert(created==2 && destroyed==1 && c._ownsCglWindow); + current=2; sub.shared=(void*)20; c.update(); assert(current==2 && destroyed==2); + c._subwindow=nullptr; c.update(); assert(destroyed==2); +} +''') diff --git a/tools/build-riot-coregraphics-runtime.py b/tools/build-riot-coregraphics-runtime.py index 8c5e72d..e00ff20 100644 --- a/tools/build-riot-coregraphics-runtime.py +++ b/tools/build-riot-coregraphics-runtime.py @@ -10,12 +10,13 @@ for line in (folder / 'CMakeFiles/CoreGraphics.dir/flags.make').read_text().spli if line.startswith('C_') and ' = ' in line: key, value = line.split(' = ', 1) flags[key] = shlex.split(value) -obj = root / 'build/riot-CGDirectDisplay.o' -subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], - '-O2', '-Wno-macro-redefined', '-c', - str(root / 'build/darling-src/src/external/cocotron/CoreGraphics/CGDirectDisplay.m'), - '-o', str(obj)], check=True) link = shlex.split((folder / 'CMakeFiles/CoreGraphics.dir/link.txt').read_text()) -link[link.index('CMakeFiles/CoreGraphics.dir/CGDirectDisplay.m.o')] = str(obj) +for name in ('CGDirectDisplay', 'CGSubWindow'): + obj = root / f'build/riot-{name}.o' + subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-Wno-macro-redefined', '-c', + str(root / f'build/darling-src/src/external/cocotron/CoreGraphics/{name}.m'), + '-o', str(obj)], check=True) + link[link.index(f'CMakeFiles/CoreGraphics.dir/{name}.m.o')] = str(obj) link[link.index('-o') + 1] = str(root / 'build/riot-CoreGraphics') subprocess.run(link, cwd=folder, check=True) diff --git a/tools/build-riot-x11-runtime.py b/tools/build-riot-x11-runtime.py index 3068865..eb8ef6a 100644 --- a/tools/build-riot-x11-runtime.py +++ b/tools/build-riot-x11-runtime.py @@ -14,7 +14,7 @@ for line in (framework / 'CMakeFiles/X11_backend.dir/flags.make').read_text().sp key, value = line.split(' = ', 1) flags[key] = shlex.split(value) link = shlex.split((framework / 'CMakeFiles/X11_backend.dir/link.txt').read_text()) -for name in ('X11Display', 'X11Window'): +for name in ('X11Display', 'X11Window', 'X11SubWindow'): obj = project / f'build/riot-{name}.o' subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], '-O3', '-march=native', '-mtune=generic', '-Wno-macro-redefined', '-c', str(project / f'build/darling-src/src/external/cocotron/AppKit/X11.backend/{name}.m'), '-o', str(obj)], check=True) link[link.index(f'CMakeFiles/X11_backend.dir/X11.backend/{name}.m.o')] = str(obj) diff --git a/tools/riot-metal-presentation-check.mm b/tools/riot-metal-presentation-check.mm index 5266d8d..0bae3b1 100644 --- a/tools/riot-metal-presentation-check.mm +++ b/tools/riot-metal-presentation-check.mm @@ -8,6 +8,14 @@ #import #include #include +#include +extern "C" void _dispatch_main_queue_callback_4CF(void *); + +@interface PresentationCheckWindow : NSWindow +@end +@implementation PresentationCheckWindow +- (BOOL)canBecomeKeyWindow { return YES; } +@end static void verifyPresentation(bool value, const char* message) { if (!value) { fprintf(stderr, "FAIL: %s\n", message); exit(1); } @@ -18,7 +26,12 @@ static void pump(NSApplication* app) { NSEvent* event = [app nextEventMatchingMask:NSAnyEventMask untilDate:[NSDate distantPast] inMode:NSDefaultRunLoopMode dequeue:YES]; if (event) [app sendEvent:event]; - [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.005]]; + // A timed NSRunLoop wait can miss its deadline on Darling when the + // X connection is quiet. Parent presentation no longer supplies child + // Expose events to wake it; service sources without a blocking wait. + CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.0, true); + _dispatch_main_queue_callback_4CF(nullptr); + usleep(1000); } } int main() { @@ -52,12 +65,17 @@ int main() { } else { view.layer = layer; } - NSWindow* window = [[NSWindow alloc] initWithContentRect:NSMakeRect(50,50,160,96) - styleMask:NSTitledWindowMask backing:NSBackingStoreBuffered defer:NO]; - window.title = @"Metal presentation check"; + const bool parentDrawable = getenv("RIOT_PRESENTATION_PARENT") != nullptr; + NSWindow* window = [[PresentationCheckWindow alloc] initWithContentRect:NSMakeRect(50,50,160,96) + styleMask:(parentDrawable ? NSBorderlessWindowMask : NSTitledWindowMask) backing:NSBackingStoreBuffered defer:parentDrawable]; + window.title = parentDrawable ? @"Presentation (TM) Client" : @"Metal presentation check"; window.contentView = view; [window makeKeyAndOrderFront:nil]; pump(app); + if (parentDrawable) { + void *borrowed = [[(CALayerContext*)[layer _context] subwindow] sharedCGLWindow]; + verifyPresentation(borrowed != nullptr, "full-window game layer borrows managed parent drawable"); + } id queue = [device newCommandQueue]; const unsigned frameCount = captureTest ? 130u : getenv("RIOT_PRESENTATION_STRESS") ? 120u : 10u; for (unsigned frame = 0; frame < frameCount; ++frame) { @@ -82,6 +100,20 @@ int main() { [commands commit]; [commands waitUntilCompleted]; pump(app); + if (parentDrawable) { + void *borrowed = [[(CALayerContext*)[layer _context] subwindow] sharedCGLWindow]; + verifyPresentation(borrowed != nullptr, "parent drawable remains borrowed"); + if (frame == 3 && getenv("RIOT_PRESENTATION_RESIZE")) { + CALayerContext* context = (CALayerContext*)[layer _context]; + CGRect original = [view frame]; + [context setFrame:CGRectMake(0,0,original.size.width / 2,original.size.height)]; + [context render]; [context flush]; + verifyPresentation([[context subwindow] sharedCGLWindow] == nullptr, "partial view falls back to child drawable"); + [context setFrame:original]; + [context render]; [context flush]; + verifyPresentation([[context subwindow] sharedCGLWindow] != nullptr, "restored view borrows parent drawable again"); + } + } } } CGLContextObj previous = CGLGetCurrentContext(); diff --git a/upstream/manifest.json b/upstream/manifest.json index 1dd1cac..9bb8346 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -57,7 +57,7 @@ "revision": "c8d38d16a9f613d300157bebbab2b9501bc0c274", "repository": "https://github.com/darlinghq/darling-cocotron.git", "patch": "patches/cocotron.patch", - "modified_files": 104, + "modified_files": 114, "new_files": [ "QuartzCore/include/QuartzCore/CATransformLayer.h", "AppKit/NSGeometryTrace.h" From 84e39e3b48c424cab8e9d64bb6af12aa15adcce8 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:00:41 +0200 Subject: [PATCH 14/54] Keep hidden X11 windows unmapped Restack and makeKey no longer map a window AppKit considers hidden, and setIsVisible: NO always unmaps, so the client's splash stays hidden. Co-Authored-By: Claude Sonnet 5.5 --- patches/cocotron.patch | 87 +++++++++++++++++++++++++-------- tests/test_x11_hidden_window.py | 36 ++++++++++++++ 2 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 tests/test_x11_hidden_window.py diff --git a/patches/cocotron.patch b/patches/cocotron.patch index f506180..b8f2125 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -1690,7 +1690,7 @@ index 8ac2bbdc..a0fb4f37 100644 - (BOOL) performDragOperation: (id) sender { diff --git a/AppKit/NSWindow.m b/AppKit/NSWindow.m -index d545120a..79760f47 100644 +index d545120a..1920e594 100644 --- a/AppKit/NSWindow.m +++ b/AppKit/NSWindow.m @@ -27,6 +27,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ @@ -2462,15 +2462,28 @@ index d545120a..79760f47 100644 [[NSDraggingManager draggingManager] dragImage: image at: location offset: offset -@@ -2536,6 +3116,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -2527,15 +3107,19 @@ static BOOL _allowsAutomaticWindowTabbing; + } + + - (void) setIsVisible: (BOOL) visible { +- if (_isVisible == visible) { ++ /* darling-lol: hiding always reaches the platform window, which may be mapped while _isVisible is NO. */ ++ if (_isVisible == visible && visible) { + return; + } ++ BOOL changed = _isVisible != visible; + _isVisible = visible; + if (visible) { + [[self platformWindow] showWindowWithoutActivation]; } else { [[self platformWindow] hideWindow]; } -+ [self postNotificationName: NSWindowDidChangeOcclusionStateNotification]; ++ if (changed) ++ [self postNotificationName: NSWindowDidChangeOcclusionStateNotification]; } - (void) orderFront: (id) sender { -@@ -3064,6 +3645,13 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3064,6 +3648,13 @@ static BOOL _allowsAutomaticWindowTabbing; if (didSize) { // Don't redraw everything unless we really have to. [_backgroundView setFrameSize: _frame.size]; @@ -2484,7 +2497,7 @@ index d545120a..79760f47 100644 [_backgroundView setNeedsDisplay: YES]; [self resetCursorRects]; [self saveFrameUsingName: _autosaveFrameName]; -@@ -3260,8 +3848,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3260,8 +3851,7 @@ static BOOL _allowsAutomaticWindowTabbing; mouseWasInside && !mouseIsInside) { [exited addObject: area]; } @@ -2494,7 +2507,7 @@ index d545120a..79760f47 100644 [moved addObject: area]; } if (options & NSTrackingCursorUpdate && !mouseWasInside && -@@ -3459,7 +4046,7 @@ static BOOL _allowsAutomaticWindowTabbing; +@@ -3459,7 +4049,7 @@ static BOOL _allowsAutomaticWindowTabbing; } - (CGSubWindow *) _createSubWindowWithFrame: (CGRect) frame { @@ -5075,7 +5088,7 @@ index bfe3839a..48cfac4c 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m -index f4925b66..730d4692 100644 +index f4925b66..e458d96f 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5571,7 +5584,7 @@ index f4925b66..730d4692 100644 struct { unsigned long flags; unsigned long functions; -@@ -356,8 +620,14 @@ static NSData *makeWindowIcon() { +@@ -356,11 +620,25 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5586,7 +5599,18 @@ index f4925b66..730d4692 100644 } } -@@ -377,15 +647,27 @@ static NSData *makeWindowIcon() { ++/* darling-lol: only map for a window AppKit considers visible. NSWindow sets _isVisible before ++ * ordering, so ordered windows still map; a hidden one (like the client's splash) must not be ++ * revived by a later restack or makeKey. */ ++- (void) ensureMappedIfVisible { ++ if (_delegate == nil || [_delegate isVisible]) ++ [self ensureMapped]; ++} ++ + - (void) showWindowWithoutActivation { + [self ensureMapped]; + } +@@ -377,15 +655,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5619,7 +5643,7 @@ index f4925b66..730d4692 100644 // TODO: background color } -@@ -393,6 +675,28 @@ static NSData *makeWindowIcon() { +@@ -393,6 +683,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -5648,7 +5672,7 @@ index f4925b66..730d4692 100644 [_context release]; _context = nil; -@@ -421,6 +725,392 @@ static NSData *makeWindowIcon() { +@@ -421,6 +733,392 @@ static NSData *makeWindowIcon() { } } @@ -6041,7 +6065,7 @@ index f4925b66..730d4692 100644 - (Window) windowHandle { return _window; } -@@ -470,14 +1160,32 @@ static NSData *makeWindowIcon() { +@@ -470,14 +1168,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -6075,7 +6099,7 @@ index f4925b66..730d4692 100644 } - (void) setHasShadow: (BOOL) value { -@@ -488,6 +1196,25 @@ static NSData *makeWindowIcon() { +@@ -488,6 +1204,25 @@ static NSData *makeWindowIcon() { _level = value; } @@ -6101,9 +6125,30 @@ index f4925b66..730d4692 100644 - (void) setOpaque: (BOOL) value { _isOpaque = value; } -@@ -543,6 +1270,21 @@ static NSData *makeWindowIcon() { +@@ -518,7 +1253,7 @@ static NSData *makeWindowIcon() { + + - (void) placeAboveWindow: (NSInteger) otherNumber { + X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; + + if (!other) { + XRaiseWindow(_display, _window); +@@ -530,7 +1265,7 @@ static NSData *makeWindowIcon() { + + - (void) placeBelowWindow: (NSInteger) otherNumber { + X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; + + if (!other) { + XLowerWindow(_display, _window); +@@ -541,8 +1276,23 @@ static NSData *makeWindowIcon() { + } + - (void) makeKey { - [self ensureMapped]; +- [self ensureMapped]; ++ [self ensureMappedIfVisible]; XRaiseWindow(_display, _window); + /* darling-lol: League's game window is managed (not override-redirect), so keyboard focus is + * the WM's to give. Ask for activation once when the game makes it key; the WM's focus-stealing @@ -6123,7 +6168,7 @@ index f4925b66..730d4692 100644 } - (void) makeMain { -@@ -559,7 +1301,29 @@ static NSData *makeWindowIcon() { +@@ -559,7 +1309,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6153,7 +6198,7 @@ index f4925b66..730d4692 100644 Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1357,10 @@ static NSData *makeWindowIcon() { +@@ -593,8 +1365,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6166,7 +6211,7 @@ index f4925b66..730d4692 100644 NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1381,12 @@ static NSData *makeWindowIcon() { +@@ -615,6 +1389,12 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6179,7 +6224,7 @@ index f4925b66..730d4692 100644 CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1424,7 @@ static NSData *makeWindowIcon() { +@@ -652,7 +1432,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6188,7 +6233,7 @@ index f4925b66..730d4692 100644 } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1438,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -666,6 +1446,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6196,7 +6241,7 @@ index f4925b66..730d4692 100644 XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1483,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { +@@ -710,19 +1491,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } diff --git a/tests/test_x11_hidden_window.py b/tests/test_x11_hidden_window.py new file mode 100644 index 0000000..b0397c4 --- /dev/null +++ b/tests/test_x11_hidden_window.py @@ -0,0 +1,36 @@ +"""Check that the patched X11 backend cannot re-map a window AppKit has hidden.""" +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +class HiddenWindowChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def test_restack_and_make_key_map_only_visible_windows(self): + source = self.patched('cocotron', 'AppKit/X11.backend/X11Window.m') + self.assertIn('[_delegate isVisible]', body(source, '- (void) ensureMappedIfVisible')) + for signature in ('- (void) placeAboveWindow: (NSInteger) otherNumber', + '- (void) placeBelowWindow: (NSInteger) otherNumber', '- (void) makeKey'): + method = body(source, signature) + self.assertIn('[self ensureMappedIfVisible]', method, signature) + self.assertNotIn('[self ensureMapped]', method, signature) + + def test_ordering_marks_window_visible_before_placing_it(self): + source = self.patched('cocotron', 'AppKit/NSWindow.m') + ordering = source.split('relativeTo: (NSInteger) relativeTo\n{', 1)[1].split('\n}', 1)[0] + for call in ('placeAboveWindow', 'placeBelowWindow'): + self.assertLess(ordering.index('_isVisible = YES'), ordering.index(call)) + + def test_hiding_reaches_the_platform_window_when_already_not_visible(self): + source = self.patched('cocotron', 'AppKit/NSWindow.m') + method = body(source, '- (void) setIsVisible: (BOOL) visible') + self.assertIn('_isVisible == visible && visible', method) + self.assertIn('hideWindow', method) + + +if __name__ == '__main__': + unittest.main() From ee322b43e1ea8aee6a2b5e34bc31da38e2e091ef Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:49:04 +0200 Subject: [PATCH 15/54] Make NSOpenPanel Open/Cancel always end the modal session Open with no row selected passed nil to -_setFilename:, and NSOpenPanel boxed it in an array literal, so the action threw before the modal session was stopped and the panel stayed up. Both buttons now go through one helper that stops the session only when this panel is the modal window and always orders the panel out; Open accepts the selection (filtered by canChooseFiles/canChooseDirectories and multiple-selection), or the shown directory for directory choosers. Escape cancels. Co-Authored-By: Claude Sonnet 5.5 --- patches/cocotron.patch | 124 +++++++++++++++++++++++++++++++- tests/test_nsopenpanel_close.py | 46 ++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 tests/test_nsopenpanel_close.py diff --git a/patches/cocotron.patch b/patches/cocotron.patch index b8f2125..85aa08f 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -11316,13 +11316,80 @@ index 00000000..19cd9ea9 + +@end diff --git a/AppKit/NSSavePanel.subproj/NSSavePanel.m b/AppKit/NSSavePanel.subproj/NSSavePanel.m -index 50c3d59e..5872e594 100644 +index 50c3d59e..be5a8463 100644 --- a/AppKit/NSSavePanel.subproj/NSSavePanel.m +++ b/AppKit/NSSavePanel.subproj/NSSavePanel.m -@@ -124,8 +124,48 @@ static NSSavePanel *_newPanel = nil; - [NSApp stopModalWithCode: NSCancelButton]; +@@ -113,19 +113,108 @@ + } } ++/* Paths of the rows selected in the file list. Safe with no selection. */ ++- (NSArray *) _selectedPaths { ++ NSMutableArray *paths = [NSMutableArray array]; ++ NSIndexSet *rows = [_outlineView selectedRowIndexes]; ++ for (NSUInteger row = [rows firstIndex]; row != NSNotFound; ++ row = [rows indexGreaterThanIndex: row]) { ++ id item = [_outlineView itemAtRow: row]; ++ if ([item isKindOfClass: [NSURL class]] && [[item path] length] > 0) ++ [paths addObject: [item path]]; ++ } ++ return paths; ++} ++ ++/* What the Open/Save button accepts: the selected rows, or with nothing ++ * selected the directory being shown. An empty result keeps the panel up. */ ++- (NSArray *) _acceptablePaths { ++ NSArray *selected = [self _selectedPaths]; ++ if ([selected count] > 0) ++ return selected; ++ if ([_directory length] > 0) ++ return @[ _directory ]; ++ return @[]; ++} ++ ++- (void) _setAcceptedPaths: (NSArray *) paths { ++ [self _setFilename: [paths firstObject]]; ++} ++ ++/* End the modal session this panel is running and take the panel off screen. ++ * The session is stopped only when this panel is the modal window, never ++ * another session that happens to be on top. The panel is hidden here as well ++ * so a dialog whose session cannot be stopped still goes away. */ ++- (void) _dismissWithCode: (NSModalResponse) code { ++ if ([NSApp modalWindow] == self) ++ [NSApp stopModalWithCode: code]; ++ [self orderOut: nil]; ++} ++ + - (IBAction) _selectFile: (id) sender { +- NSURL *url = [_outlineView itemAtRow: [_outlineView selectedRow]]; +- [self _setFilename: [url path]]; +- +- [NSApp stopModalWithCode: NSOKButton]; ++ NSArray *paths = [self _acceptablePaths]; ++ if ([paths count] == 0) ++ return; ++ @try { ++ [self _setAcceptedPaths: paths]; ++ } @catch (NSException *exception) { ++ NSLog(@"NSSavePanel: could not record the chosen file: %@", exception); ++ [self _dismissWithCode: NSCancelButton]; ++ return; ++ } ++ [self _dismissWithCode: NSOKButton]; + } + + - (IBAction) _cancel: (id) sender { +- [NSApp stopModalWithCode: NSCancelButton]; +-} +- ++ [self _dismissWithCode: NSCancelButton]; ++} ++ ++- (void) cancelOperation: (id) sender { ++ [self _cancel: sender]; ++} ++ +// Electron (Riot Client UI) opens its file dialogs through these and waits +// for the handler; run the panel modally and report the result. - (void) beginWithCompletionHandler: (void (^)(NSModalResponse result)) handler { @@ -11368,6 +11435,57 @@ index 50c3d59e..5872e594 100644 + return NO; } + - (NSInteger) runModalForDirectory: (NSString *) directory +diff --git a/AppKit/NSSavePanel.subproj/NSOpenPanel.m b/AppKit/NSSavePanel.subproj/NSOpenPanel.m +index dfd33637..9388b186 100644 +--- a/AppKit/NSSavePanel.subproj/NSOpenPanel.m ++++ b/AppKit/NSSavePanel.subproj/NSOpenPanel.m +@@ -23,6 +23,8 @@ + + @interface NSSavePanel (Private) + - (void) _setFilename: (NSString *) filename; ++- (NSArray *) _selectedPaths; ++- (void) _setAcceptedPaths: (NSArray *) paths; + @end + + @implementation NSOpenPanel +@@ -92,7 +94,36 @@ + + - (void) _setFilename: (NSString *) filename { + [super _setFilename: filename]; +- _filenames = [@[ filename ] retain]; ++ [self _setAcceptedPaths: [filename length] > 0 ? @[ filename ] : @[]]; ++} ++ ++- (void) _setAcceptedPaths: (NSArray *) paths { ++ if (!_allowsMultipleSelection && [paths count] > 1) ++ paths = @[ [paths firstObject] ]; ++ @synchronized(self) { ++ NSArray *copy = [paths copy]; ++ [_filenames release]; ++ _filenames = copy; ++ } ++ [super _setFilename: [paths firstObject]]; ++} ++ ++/* Open only accepts what the panel was configured to choose. With nothing ++ * selected, a directory chooser accepts the directory being shown. */ ++- (NSArray *) _acceptablePaths { ++ NSFileManager *manager = [NSFileManager defaultManager]; ++ NSMutableArray *result = [NSMutableArray array]; ++ for (NSString *path in [self _selectedPaths]) { ++ BOOL isDirectory = NO; ++ if (![manager fileExistsAtPath: path isDirectory: &isDirectory]) ++ continue; ++ if (isDirectory ? _canChooseDirectories : _canChooseFiles) ++ [result addObject: path]; ++ } ++ if ([result count] == 0 && [[self _selectedPaths] count] == 0 && ++ _canChooseDirectories && [[self directory] length] > 0) ++ [result addObject: [self directory]]; ++ return result; + } + - (NSInteger) runModalForDirectory: (NSString *) directory diff --git a/AppKit/include/AppKit/NSSavePanel.h b/AppKit/include/AppKit/NSSavePanel.h index 53bacc46..3c6adfb7 100644 diff --git a/tests/test_nsopenpanel_close.py b/tests/test_nsopenpanel_close.py new file mode 100644 index 0000000..68faed1 --- /dev/null +++ b/tests/test_nsopenpanel_close.py @@ -0,0 +1,46 @@ +"""Check that the patched Cocotron file panels always end their modal session and never throw on Open.""" +import unittest +import test_runtime_compatibility as sources + + +def body(source, signature): + return source.split(signature + ' {', 1)[1].split('\n}', 1)[0] + + +class FilePanelCloseChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def save_panel(self): + return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSSavePanel.m') + + def open_panel(self): + return self.patched('cocotron', 'AppKit/NSSavePanel.subproj/NSOpenPanel.m') + + def test_both_buttons_dismiss_through_one_helper(self): + source = self.save_panel() + dismiss = body(source, '- (void) _dismissWithCode: (NSModalResponse) code') + self.assertIn('[NSApp modalWindow] == self', dismiss) + self.assertIn('stopModalWithCode: code', dismiss) + self.assertIn('orderOut', dismiss) + self.assertIn('_dismissWithCode: NSCancelButton', body(source, '- (IBAction) _cancel: (id) sender')) + self.assertIn('_dismissWithCode: NSOKButton', body(source, '- (IBAction) _selectFile: (id) sender')) + + def test_open_without_a_selection_cannot_throw(self): + # Nothing selected used to hand nil to -_setFilename:, and NSOpenPanel boxed it in @[ ... ]. + self.assertNotIn('@[ filename ]];', self.open_panel().replace('[filename length] > 0 ? @[ filename ] : @[]', '')) + select = body(self.save_panel(), '- (IBAction) _selectFile: (id) sender') + self.assertNotIn('itemAtRow: [_outlineView selectedRow]', select) + self.assertIn('@catch', select) + self.assertIn('NSCancelButton', select.split('@catch', 1)[1]) + + def test_open_panel_honours_what_it_may_choose(self): + accept = body(self.open_panel(), '- (NSArray *) _acceptablePaths') + self.assertIn('_canChooseDirectories', accept) + self.assertIn('_canChooseFiles', accept) + + def test_escape_cancels(self): + self.assertIn('[self _cancel: sender]', body(self.save_panel(), '- (void) cancelOperation: (id) sender')) + + +if __name__ == '__main__': + unittest.main() From 70a2feec42fce8f5da7b61b38cf9b01beaa47125 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:19:42 +0200 Subject: [PATCH 16/54] Add standalone NSOpenPanel Open/Cancel probe for issue #85 Co-Authored-By: Claude Sonnet 5.5 --- docs/TESTING.md | 31 ++++++ tools/build-riot-openpanel-check.py | 24 ++++ tools/riot-openpanel-check.m | 166 ++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100755 tools/build-riot-openpanel-check.py create mode 100644 tools/riot-openpanel-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index 7f8c1b9..3249b96 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -178,6 +178,37 @@ Riot Client also confirmed the no-decoration hint and identical compositor frame and client dimensions (1536×864), instead of the previous 28-pixel titlebar. This validates the Riot Client window, not every LeagueClientUx or game mode. +### NSOpenPanel Open/Cancel (issue #85) + +`python3 tools/build-riot-openpanel-check.py` builds +`build/riot-openpanel-check`. Install the patched AppKit first, with clients and +the game closed. The probe opens its own directory chooser in a fresh `/tmp` +folder containing one subfolder and needs no Riot/League client: + +```sh +P=/Volumes/SystemRoot/path/to/darling-lol/build/riot-openpanel-check +E="env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks" +for m in cancel open-selected open-none; do darling shell $E $P --auto $m; done +darling shell $E $P --interactive +``` + +Each `--auto` mode waits 1.5 s, then clicks the panel's own Cancel or Open +button (selecting the subfolder first for `open-selected`), and checks that +`runModal` returned, the result code and URL, and that the panel is gone. A +15 s watchdog on a separate thread reports a hang. Expected, one line per mode: + +``` +OPENPANEL_CHECK mode=cancel result=PASS code=0 ... +OPENPANEL_CHECK mode=open-selected result=PASS code=1 url=/tmp/openpanel-check.XXXXXX/subfolder ... +OPENPANEL_CHECK mode=open-none result=PASS code=1 url=/tmp/openpanel-check.XXXXXX ... +``` + +A failure prints `result=FAIL ... problems=...` (`HANG` when `runModal` never +returned) and exits non-zero. `--interactive` shows the panel for manual clicks +and prints `OPENPANEL_CHECK mode=interactive result=RETURNED code=... url=...` +once it closes. As with the other probes, check the printed line rather than +only the launcher exit status. + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/tools/build-riot-openpanel-check.py b/tools/build-riot-openpanel-check.py new file mode 100755 index 0000000..2bab44b --- /dev/null +++ b/tools/build-riot-openpanel-check.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the owned NSOpenPanel modal-session check (issue #85).""" +from pathlib import Path +import json +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/AppKit' +config = root / 'build/config.json' +sdk = json.loads(config.read_text())['sdk'] if config.exists() else '/usr/local/libexec/darling' +flags = {} +for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-openpanel-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-fblocks', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-openpanel-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lobjc', + '-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation', + '-o', str(root / 'build/riot-openpanel-check')], check=True) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m new file mode 100644 index 0000000..73b4cba --- /dev/null +++ b/tools/riot-openpanel-check.m @@ -0,0 +1,166 @@ +/* Standalone AppKit regression for issue #85: the panel's own Open/Cancel + * buttons must end runModal. Drives only its own NSOpenPanel; no Riot/League + * client is launched or contacted and no external input is generated. + * + * riot-openpanel-check --auto cancel|open-selected|open-none + * riot-openpanel-check --interactive + */ +#import +#include +#include +#include +#include +#include + +static NSOpenPanel *panel; +static NSString *tempDir, *subDir; +static const char *mode; +static BOOL interactive; +static BOOL panelVisibleAtTrigger; +static NSString *triggerNote = @""; +static const double kTriggerDelay = 1.5, kWatchdog = 15.0; + +static NSButton *findButton(NSView *view, SEL action) { + if ([view isKindOfClass:[NSButton class]] && [(NSButton *)view action] == action) + return (NSButton *)view; + for (NSView *sub in [view subviews]) { + NSButton *found = findButton(sub, action); + if (found) return found; + } + return nil; +} + +static NSOutlineView *findOutline(NSView *view) { + if ([view isKindOfClass:[NSOutlineView class]]) return (NSOutlineView *)view; + for (NSView *sub in [view subviews]) { + NSOutlineView *found = findOutline(sub); + if (found) return found; + } + return nil; +} + +static void press(SEL action, NSString *name) { + NSButton *button = findButton([panel contentView], action); + if (button) { + triggerNote = [NSString stringWithFormat:@"%@ via button", name]; + [button performClick:nil]; + } else { + triggerNote = [NSString stringWithFormat:@"%@ via direct action (button not found)", name]; + [panel performSelector:action withObject:nil]; + } +} + +static void selectSubfolder(void) { + NSOutlineView *outline = findOutline([panel contentView]); + if (!outline) { triggerNote = @"no outline view, subfolder row not found"; return; } + [outline reloadData]; + for (NSInteger row = 0; row < [outline numberOfRows]; row++) { + id item = [outline itemAtRow:row]; + NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : [item description]; + if ([path isEqualToString:subDir]) { + [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; + return; + } + } + triggerNote = @"subfolder row not found"; +} + +@interface Trigger : NSObject +- (void)fire:(NSTimer *)timer; +@end +@implementation Trigger +- (void)fire:(NSTimer *)timer { + static BOOL done; + if (done) return; + done = YES; + panelVisibleAtTrigger = [panel isVisible]; + if (!strcmp(mode, "cancel")) { + press(@selector(_cancel:), @"cancel"); + } else { + if (!strcmp(mode, "open-selected")) selectSubfolder(); + press(@selector(_selectFile:), @"open"); + } +} +@end + +int main(int argc, char **argv) { + setbuf(stdout, NULL); + if (argc == 2 && !strcmp(argv[1], "--interactive")) { + interactive = YES; + mode = "interactive"; + } else if (argc == 3 && !strcmp(argv[1], "--auto") && + (!strcmp(argv[2], "cancel") || !strcmp(argv[2], "open-selected") || + !strcmp(argv[2], "open-none"))) { + mode = argv[2]; + } else { + fprintf(stderr, "usage: %s --auto cancel|open-selected|open-none | --interactive\n", argv[0]); + return 2; + } + @autoreleasepool { + [NSApplication sharedApplication]; + char templ[] = "/tmp/openpanel-check.XXXXXX"; + tempDir = [[NSString stringWithUTF8String:mkdtemp(templ)] retain]; + subDir = [[tempDir stringByAppendingPathComponent:@"subfolder"] retain]; + [[NSFileManager defaultManager] createDirectoryAtPath:subDir + withIntermediateDirectories:YES attributes:nil error:NULL]; + + panel = [NSOpenPanel openPanel]; + [panel setCanChooseDirectories:YES]; + [panel setCanChooseFiles:NO]; + [panel setAllowsMultipleSelection:NO]; + [panel setDirectory:tempDir]; + + if (!interactive) { + /* Independent of the run loop: a hung runModal still reports. */ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kWatchdog * NSEC_PER_SEC)), + dispatch_get_global_queue(0, 0), ^{ + printf("OPENPANEL_CHECK mode=%s result=FAIL problems=HANG " + "trigger=\"%s\" visible_at_trigger=%d\n", + mode, [triggerNote UTF8String], panelVisibleAtTrigger); + fflush(stdout); + _exit(3); + }); + Trigger *trigger = [[Trigger alloc] init]; + NSTimer *timer = [NSTimer timerWithTimeInterval:kTriggerDelay target:trigger + selector:@selector(fire:) userInfo:nil repeats:NO]; + [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSDefaultRunLoopMode]; + [[NSRunLoop currentRunLoop] addTimer:timer forMode:NSModalPanelRunLoopMode]; + } else { + printf("Interactive: pick the folder, or press Open/Cancel on the panel.\n"); + } + + NSInteger code = [panel runModal]; + NSString *path = [[panel URL] path]; + NSArray *urls = [panel URLs]; + BOOL visible = [panel isVisible]; + NSString *shown = path ? path : @"(none)"; + + if (interactive) { + printf("OPENPANEL_CHECK mode=interactive result=RETURNED code=%ld url=%s urls=%lu visible=%d\n", + (long)code, [shown UTF8String], (unsigned long)[urls count], visible); + return 0; + } + + NSMutableArray *problems = [NSMutableArray array]; + BOOL wantOpen = strcmp(mode, "cancel") != 0; + NSString *wantPath = !strcmp(mode, "open-selected") ? subDir : tempDir; + if (!panelVisibleAtTrigger) [problems addObject:@"panel_not_visible_at_trigger"]; + if (wantOpen) { + if (code != NSOKButton) [problems addObject:@"code_not_ok"]; + if (![path isEqualToString:wantPath]) [problems addObject:@"wrong_url"]; + if ([urls count] != 1) [problems addObject:@"wrong_url_count"]; + } else if (code != NSCancelButton) { + [problems addObject:@"code_not_cancel"]; + } + if (visible) [problems addObject:@"still_visible"]; + if ([triggerNote hasSuffix:@"not found"]) [problems addObject:triggerNote]; + BOOL pass = [problems count] == 0; + printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", + mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], + wantOpen ? [wantPath UTF8String] : "-", visible, [triggerNote UTF8String], + pass ? "" : " problems=", + pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); + fflush(stdout); + return pass ? 0 : 1; + } +} From 66474a3c76efa9d530fcc8f5aedde0f55574c396 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:28:34 +0200 Subject: [PATCH 17/54] Fix openpanel probe: keep trigger note and panel alive across modal pools Co-Authored-By: Claude Sonnet 5.5 --- tools/riot-openpanel-check.m | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m index 73b4cba..7143619 100644 --- a/tools/riot-openpanel-check.m +++ b/tools/riot-openpanel-check.m @@ -17,7 +17,10 @@ static NSString *tempDir, *subDir; static const char *mode; static BOOL interactive; static BOOL panelVisibleAtTrigger; -static NSString *triggerNote = @""; +/* Plain C storage: an autoreleased NSString kept here would be freed by the + * modal loop's pools before the result is reported (and the watchdog thread + * reads it too). */ +static char triggerNote[160] = ""; static const double kTriggerDelay = 1.5, kWatchdog = 15.0; static NSButton *findButton(NSView *view, SEL action) { @@ -39,20 +42,20 @@ static NSOutlineView *findOutline(NSView *view) { return nil; } -static void press(SEL action, NSString *name) { +static void press(SEL action, const char *name) { NSButton *button = findButton([panel contentView], action); if (button) { - triggerNote = [NSString stringWithFormat:@"%@ via button", name]; + snprintf(triggerNote, sizeof triggerNote, "%s via button", name); [button performClick:nil]; } else { - triggerNote = [NSString stringWithFormat:@"%@ via direct action (button not found)", name]; + snprintf(triggerNote, sizeof triggerNote, "%s via direct action (button not found)", name); [panel performSelector:action withObject:nil]; } } static void selectSubfolder(void) { NSOutlineView *outline = findOutline([panel contentView]); - if (!outline) { triggerNote = @"no outline view, subfolder row not found"; return; } + if (!outline) { snprintf(triggerNote, sizeof triggerNote, "no outline view, subfolder row not found"); return; } [outline reloadData]; for (NSInteger row = 0; row < [outline numberOfRows]; row++) { id item = [outline itemAtRow:row]; @@ -62,7 +65,7 @@ static void selectSubfolder(void) { return; } } - triggerNote = @"subfolder row not found"; + snprintf(triggerNote, sizeof triggerNote, "subfolder row not found"); } @interface Trigger : NSObject @@ -75,10 +78,10 @@ static void selectSubfolder(void) { done = YES; panelVisibleAtTrigger = [panel isVisible]; if (!strcmp(mode, "cancel")) { - press(@selector(_cancel:), @"cancel"); + press(@selector(_cancel:), "cancel"); } else { if (!strcmp(mode, "open-selected")) selectSubfolder(); - press(@selector(_selectFile:), @"open"); + press(@selector(_selectFile:), "open"); } } @end @@ -104,7 +107,7 @@ int main(int argc, char **argv) { [[NSFileManager defaultManager] createDirectoryAtPath:subDir withIntermediateDirectories:YES attributes:nil error:NULL]; - panel = [NSOpenPanel openPanel]; + panel = [[NSOpenPanel openPanel] retain]; [panel setCanChooseDirectories:YES]; [panel setCanChooseFiles:NO]; [panel setAllowsMultipleSelection:NO]; @@ -116,7 +119,7 @@ int main(int argc, char **argv) { dispatch_get_global_queue(0, 0), ^{ printf("OPENPANEL_CHECK mode=%s result=FAIL problems=HANG " "trigger=\"%s\" visible_at_trigger=%d\n", - mode, [triggerNote UTF8String], panelVisibleAtTrigger); + mode, triggerNote, panelVisibleAtTrigger); fflush(stdout); _exit(3); }); @@ -153,11 +156,12 @@ int main(int argc, char **argv) { [problems addObject:@"code_not_cancel"]; } if (visible) [problems addObject:@"still_visible"]; - if ([triggerNote hasSuffix:@"not found"]) [problems addObject:triggerNote]; + if (strstr(triggerNote, "not found") && !strstr(triggerNote, "button not found")) + [problems addObject:@"subfolder_row_not_found"]; BOOL pass = [problems count] == 0; printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], - wantOpen ? [wantPath UTF8String] : "-", visible, [triggerNote UTF8String], + wantOpen ? [wantPath UTF8String] : "-", visible, triggerNote, pass ? "" : " problems=", pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); fflush(stdout); From d3adda86fa7276da3d0bd4a66219a8ab969d0406 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:33:30 +0200 Subject: [PATCH 18/54] Openpanel probe: expand the tree to the subfolder before selecting, print selection diagnostics Co-Authored-By: Claude Sonnet 5.5 --- tools/riot-openpanel-check.m | 57 ++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 13 deletions(-) diff --git a/tools/riot-openpanel-check.m b/tools/riot-openpanel-check.m index 7143619..8aac17e 100644 --- a/tools/riot-openpanel-check.m +++ b/tools/riot-openpanel-check.m @@ -7,6 +7,7 @@ */ #import #include +#include #include #include #include @@ -53,19 +54,49 @@ static void press(SEL action, const char *name) { } } -static void selectSubfolder(void) { +static char selectNote[320] = ""; +static BOOL selectionOK = YES; + +static NSString *urlPath(id item) { + NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : nil; + while ([path length] > 1 && [path hasSuffix:@"/"]) + path = [path substringToIndex:[path length] - 1]; + return path; +} + +/* The panel's tree is rooted at "/" and is not navigated to the directory + * being shown, so expand each component of subDir from the root, then select + * its row. Records diagnostics in selectNote. */ +static BOOL selectSubfolder(void) { NSOutlineView *outline = findOutline([panel contentView]); - if (!outline) { snprintf(triggerNote, sizeof triggerNote, "no outline view, subfolder row not found"); return; } + if (!outline) { snprintf(selectNote, sizeof selectNote, "no_outline_view"); return NO; } [outline reloadData]; - for (NSInteger row = 0; row < [outline numberOfRows]; row++) { - id item = [outline itemAtRow:row]; - NSString *path = [item isKindOfClass:[NSURL class]] ? [item path] : [item description]; - if ([path isEqualToString:subDir]) { - [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; - return; + NSString *prefix = @""; + NSInteger found = -1; + for (NSString *part in [subDir pathComponents]) { + if ([part isEqualToString:@"/"]) continue; + prefix = [prefix stringByAppendingFormat:@"/%@", part]; + found = -1; + for (NSInteger row = 0; row < [outline numberOfRows]; row++) { + if ([urlPath([outline itemAtRow:row]) isEqualToString:prefix]) { found = row; break; } } + if (found < 0) { + snprintf(selectNote, sizeof selectNote, "row_not_found at=%s rows=%ld", + [prefix UTF8String], (long)[outline numberOfRows]); + return NO; + } + if (![prefix isEqualToString:subDir]) [outline expandItem:[outline itemAtRow:found]]; } - snprintf(triggerNote, sizeof triggerNote, "subfolder row not found"); + [outline selectRowIndexes:[NSIndexSet indexSetWithIndex:found] byExtendingSelection:NO]; + NSIndexSet *sel = [outline selectedRowIndexes]; + NSInteger selRow = [sel firstIndex] == NSNotFound ? -1 : (NSInteger)[sel firstIndex]; + id item = selRow >= 0 ? [outline itemAtRow:selRow] : nil; + NSString *itemPath = urlPath(item); + snprintf(selectNote, sizeof selectNote, + "target_row=%ld selected_count=%lu selected_row=%ld item_class=%s item_path=%s", + (long)found, (unsigned long)[sel count], (long)selRow, + item ? class_getName([item class]) : "nil", itemPath ? [itemPath UTF8String] : "(nil)"); + return selRow == found; } @interface Trigger : NSObject @@ -80,7 +111,7 @@ static void selectSubfolder(void) { if (!strcmp(mode, "cancel")) { press(@selector(_cancel:), "cancel"); } else { - if (!strcmp(mode, "open-selected")) selectSubfolder(); + if (!strcmp(mode, "open-selected")) selectionOK = selectSubfolder(); press(@selector(_selectFile:), "open"); } } @@ -156,12 +187,12 @@ int main(int argc, char **argv) { [problems addObject:@"code_not_cancel"]; } if (visible) [problems addObject:@"still_visible"]; - if (strstr(triggerNote, "not found") && !strstr(triggerNote, "button not found")) - [problems addObject:@"subfolder_row_not_found"]; + if (!selectionOK) [problems addObject:@"selection_failed"]; BOOL pass = [problems count] == 0; - printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s\n", + printf("OPENPANEL_CHECK mode=%s result=%s code=%ld url=%s expected_url=%s visible=%d trigger=\"%s\"%s%s%s%s%s\n", mode, pass ? "PASS" : "FAIL", (long)code, [shown UTF8String], wantOpen ? [wantPath UTF8String] : "-", visible, triggerNote, + selectNote[0] ? " selection=\"" : "", selectNote, selectNote[0] ? "\"" : "", pass ? "" : " problems=", pass ? "" : [[problems componentsJoinedByString:@","] UTF8String]); fflush(stdout); From 791d4ad74e57f36c8d3203d1a1b03c1d5e75da82 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:00:24 +0200 Subject: [PATCH 19/54] Keep the merged X11 patch in canonical diff format --- patches/cocotron.patch | 552 ++++++++++++----------------------------- 1 file changed, 161 insertions(+), 391 deletions(-) diff --git a/patches/cocotron.patch b/patches/cocotron.patch index 231899a..cc6452b 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -5092,6 +5092,7 @@ index bfe3839a..bc592711 100644 - (void) frameChanged; - (void) setLastKnownCursorPosition: (CGPoint) point; diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m +index f4925b6..b565b91 100644 --- a/AppKit/X11.backend/X11Window.m +++ b/AppKit/X11.backend/X11Window.m @@ -18,11 +18,14 @@ @@ -5109,134 +5110,10 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m #import #import #import -@@ -38,130 +41,40 @@ +@@ -38,6 +41,110 @@ #import "X11Window.h" #import #import -- --@implementation X11Window -- --+ (Visual *) visual { -- static Visual *ret = NULL; -- -- if (!ret) { -- int visuals_matched, i; -- XVisualInfo match = {0}; -- Display *dpy = [(X11Display *) [NSDisplay currentDisplay] display]; -- -- XVisualInfo *info = XGetVisualInfo(dpy, 0, &match, &visuals_matched); -- -- for (i = 0; i < visuals_matched; i++) { -- if (info[i].depth == 32 && (info[i].red_mask == 0xff0000 && -- info[i].green_mask == 0x00ff00 && -- info[i].blue_mask == 0x0000ff)) { -- ret = info[i].visual; -- } -- } -- XFree(info); -- if (!ret) -- ret = DefaultVisual(dpy, DefaultScreen(dpy)); -- } -- -- return ret; --} -- --static NSData *makeWindowIcon() { -- static NSMutableData *res; -- -- if (res != nil) -- return res; -- -- // Figure out the path. -- NSBundle *bundle = [NSBundle mainBundle]; -- NSString *name = [bundle objectForInfoDictionaryKey: @"CFBundleIconFile"]; -- if (name == nil || [name length] == 0) -- return nil; -- NSString *path = [bundle pathForImageResource: name]; -- if (path == nil) { -- NSLog(@"Cannot find an icon file named %@", name); -- return nil; -- } -- NSURL *url = [NSURL fileURLWithPath: path]; -- -- O2ImageSource *imageSource = [O2ImageSource newImageSourceWithURL: url -- options: nil]; -- if (imageSource == nil) { -- NSLog(@"Cannot parse the icon"); -- return nil; -- } -- -- res = [[NSMutableData alloc] initWithCapacity: 32 * 32 * 4 + 2]; -- O2ColorSpace *colorSpace = O2ColorSpaceCreateDeviceRGB(); -- -- // Go over the images, turning them into the required format -- // and appending them to res. -- for (NSUInteger i = 0; i < [imageSource count]; i++) { -- -- O2Image *image = [imageSource createImageAtIndex: i options: nil]; -- NSUInteger width = O2ImageGetWidth(image); -- NSUInteger height = O2ImageGetHeight(image); -- -- // Render the image in ARGB. -- O2BitmapContext *context = (O2BitmapContext *) O2BitmapContextCreate( -- NULL, width, height, 8, width * 4, colorSpace, -- kO2ImageAlphaPremultipliedFirst | kO2BitmapByteOrder32Host); -- -- [context drawImage: image inRect: O2RectMake(0, 0, width, height)]; -- int *imageData = O2BitmapContextGetData(context); -- -- // Convert to the format accepted by Xlib. -- NSMutableData *data = -- [NSMutableData dataWithLength: width * height * sizeof(long)]; -- long *dataPtr = (long *) [data mutableBytes]; -- int count = width * height; -- for (int i = 0; i < count; i++) { -- dataPtr[i] = imageData[i]; -- } -- -- struct { -- long w, h; -- } size = {width, height}; -- [res appendBytes: &size length: sizeof(size)]; -- [res appendData: data]; -- -- [context release]; -- [image release]; -- } -- -- [colorSpace release]; -- [imageSource release]; -- -- return res; --} -- --- (void) setWindowIcon { -- -- NSData *data = makeWindowIcon(); -- -- if (data == nil) -- return; -- -- XChangeProperty( -- _display, _window, XInternAtom(_display, "_NET_WM_ICON", False), -- XInternAtom(_display, "CARDINAL", False), 32, PropModeReplace, -- [data bytes], [data length] / sizeof(long)); --} -- --- (instancetype) initWithDelegate: (NSWindow *) delegate { -- _delegate = delegate; -- _level = [delegate level]; -- _styleMask = [delegate styleMask]; -- _backingType = (CGSBackingStoreType)[delegate backingType]; -- _deviceDictionary = [NSMutableDictionary new]; -- -- X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; -- _display = [x11disp display]; -- -- _frame = [self transformFrame: [delegate frame]]; -- BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; -- if (isPanel && _styleMask & NSDocModalWindowMask) -- _styleMask = NSBorderlessWindowMask; +#import +#include +#include @@ -5271,16 +5148,19 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + if (visualInfo != NULL) + return visualInfo; + - // TODO: get rid of these glX calls - GLint att[] = {GLX_RGBA, - GLX_DOUBLEBUFFER, -@@ -175,36 +88,252 @@ - 4, - None}; - -- int screen = DefaultScreen(_display); -- -- if ((_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { ++ // TODO: get rid of these glX calls ++ GLint att[] = {GLX_RGBA, ++ GLX_DOUBLEBUFFER, ++ GLX_RED_SIZE, ++ 4, ++ GLX_GREEN_SIZE, ++ 4, ++ GLX_BLUE_SIZE, ++ 4, ++ GLX_DEPTH_SIZE, ++ 4, ++ None}; ++ + int screen = DefaultScreen(display); + + // Rendering goes through EGL. NVIDIA's glXChooseVisual can return a GLX-only visual that every @@ -5295,16 +5175,8 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + visualInfo = NULL; + } + if (visualInfo == NULL && (visualInfo = glXChooseVisual(display, screen, att)) == NULL) { - NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); - } -- -- Colormap cmap = -- XCreateColormap(_display, RootWindow(_display, _visualInfo->screen), -- _visualInfo->visual, AllocNone); -- -- if (cmap < 0) { -- NSLog(@"XCreateColormap failed"); -- [self release]; ++ NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); ++ } + return visualInfo; +} + @@ -5346,145 +5218,56 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + } + return found; +} -+ -+@implementation X11Window -+ -++ (Visual *) visual { -+ static Visual *ret = NULL; -+ -+ if (!ret) { -+ int visuals_matched, i; -+ XVisualInfo match = {0}; -+ Display *dpy = [(X11Display *) [NSDisplay currentDisplay] display]; -+ -+ XVisualInfo *info = XGetVisualInfo(dpy, 0, &match, &visuals_matched); -+ -+ for (i = 0; i < visuals_matched; i++) { -+ if (info[i].depth == 32 && (info[i].red_mask == 0xff0000 && -+ info[i].green_mask == 0x00ff00 && -+ info[i].blue_mask == 0x0000ff)) { -+ ret = info[i].visual; -+ } -+ } -+ XFree(info); -+ if (!ret) -+ ret = DefaultVisual(dpy, DefaultScreen(dpy)); -+ } -+ -+ return ret; -+} -+ -+static NSData *makeWindowIcon() { -+ static NSMutableData *res; -+ -+ if (res != nil) -+ return res; -+ -+ // Figure out the path. -+ NSBundle *bundle = [NSBundle mainBundle]; -+ NSString *name = [bundle objectForInfoDictionaryKey: @"CFBundleIconFile"]; -+ if (name == nil || [name length] == 0) - return nil; -- } -+ NSString *path = [bundle pathForImageResource: name]; -+ if (path == nil) { -+ NSLog(@"Cannot find an icon file named %@", name); -+ return nil; -+ } -+ NSURL *url = [NSURL fileURLWithPath: path]; -+ -+ O2ImageSource *imageSource = [O2ImageSource newImageSourceWithURL: url -+ options: nil]; -+ if (imageSource == nil) { -+ NSLog(@"Cannot parse the icon"); -+ return nil; -+ } -+ -+ res = [[NSMutableData alloc] initWithCapacity: 32 * 32 * 4 + 2]; -+ O2ColorSpace *colorSpace = O2ColorSpaceCreateDeviceRGB(); -+ -+ // Go over the images, turning them into the required format -+ // and appending them to res. -+ for (NSUInteger i = 0; i < [imageSource count]; i++) { -+ -+ O2Image *image = [imageSource createImageAtIndex: i options: nil]; -+ NSUInteger width = O2ImageGetWidth(image); -+ NSUInteger height = O2ImageGetHeight(image); -+ -+ // Render the image in ARGB. -+ O2BitmapContext *context = (O2BitmapContext *) O2BitmapContextCreate( -+ NULL, width, height, 8, width * 4, colorSpace, -+ kO2ImageAlphaPremultipliedFirst | kO2BitmapByteOrder32Host); -+ -+ [context drawImage: image inRect: O2RectMake(0, 0, width, height)]; -+ int *imageData = O2BitmapContextGetData(context); -+ -+ // Convert to the format accepted by Xlib. -+ NSMutableData *data = -+ [NSMutableData dataWithLength: width * height * sizeof(long)]; -+ long *dataPtr = (long *) [data mutableBytes]; -+ int count = width * height; -+ for (int i = 0; i < count; i++) { -+ dataPtr[i] = imageData[i]; -+ } -+ -+ struct { -+ long w, h; -+ } size = {width, height}; -+ [res appendBytes: &size length: sizeof(size)]; -+ [res appendData: data]; -+ -+ [context release]; -+ [image release]; -+ } -+ -+ [colorSpace release]; -+ [imageSource release]; -+ -+ return res; -+} -+ -+- (void) setWindowIcon { -+ -+ NSData *data = makeWindowIcon(); -+ -+ if (data == nil) -+ return; -+ -+ XChangeProperty( -+ _display, _window, XInternAtom(_display, "_NET_WM_ICON", False), -+ XInternAtom(_display, "CARDINAL", False), 32, PropModeReplace, -+ [data bytes], [data length] / sizeof(long)); -+} -+ -+- (instancetype) initWithDelegate: (NSWindow *) delegate { -+ _delegate = delegate; -+ _level = [delegate level]; -+ _styleMask = [delegate styleMask]; -+ _backingType = (CGSBackingStoreType)[delegate backingType]; -+ _deviceDictionary = [NSMutableDictionary new]; -+ -+ X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; -+ _display = [x11disp display]; -+ + + @implementation X11Window + +@@ -158,53 +265,75 @@ static NSData *makeWindowIcon() { + X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; + _display = [x11disp display]; + +- _frame = [self transformFrame: [delegate frame]]; + _frame = [x11disp rootRectForFrame: [delegate frame]]; -+ BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; -+ if (isPanel && _styleMask & NSDocModalWindowMask) -+ _styleMask = NSBorderlessWindowMask; -+ + BOOL isPanel = [delegate isKindOfClass: [NSPanel class]]; + if (isPanel && _styleMask & NSDocModalWindowMask) + _styleMask = NSBorderlessWindowMask; +- // TODO: get rid of these glX calls +- GLint att[] = {GLX_RGBA, +- GLX_DOUBLEBUFFER, +- GLX_RED_SIZE, +- 4, +- GLX_GREEN_SIZE, +- 4, +- GLX_BLUE_SIZE, +- 4, +- GLX_DEPTH_SIZE, +- 4, +- None}; + +- int screen = DefaultScreen(_display); + _hasAlpha = [self wantsAlphaVisual]; + [self createXWindow]; -+ + +- if ((_visualInfo = glXChooseVisual(_display, screen, att)) == NULL) { +- NSLog(@"glXChooseVisual failed at %s %d", __FILE__, __LINE__); +- } + /* Chromium windows consume cross-process CA frames. League's native Metal window owns its + * drawable; a same-size client surface must never be raised above that drawable. */ + if (!X11IsLeagueGameWindow(_display, None, delegate)) + _remoteFrameTimer = [[NSTimer scheduledTimerWithTimeInterval: 1.0 / 60.0 + target: self selector: @selector(presentRemoteCAFrame:) + userInfo: nil repeats: YES] retain]; -+ + +- Colormap cmap = +- XCreateColormap(_display, RootWindow(_display, _visualInfo->screen), +- _visualInfo->visual, AllocNone); + return self; +} -+ + +- if (cmap < 0) { +- NSLog(@"XCreateColormap failed"); +- [self release]; +- return nil; +- } +- (void) createXWindow { + NSWindow *delegate = _delegate; + X11Display* x11disp = (X11Display *) [NSDisplay currentDisplay]; @@ -5551,7 +5334,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m [self syncDelegateProperties]; -@@ -217,7 +346,20 @@ +@@ -217,7 +346,20 @@ static NSData *makeWindowIcon() { .res_class = (char *) name}; XSetClassHint(_display, _window, &classHint); @@ -5573,7 +5356,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m _isModal = [delegate isSheet] || [NSApp modalWindow] == delegate; // FIXME: There should be no need for this. -@@ -254,19 +396,96 @@ +@@ -254,19 +396,96 @@ static NSData *makeWindowIcon() { XNFocusWindow, _window, NULL); @@ -5590,10 +5373,9 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m [self setStyleMaskInternal: _styleMask force: YES]; [self setWindowIcon]; -- -- return self; +} -+ + +- return self; +// Only when the background can show through (a pattern image may): League's game window is +// non-opaque on an opaque background, and a 32-bit parent costs Xwayland a copy of its GL child. +- (BOOL) isTranslucent { @@ -5677,10 +5459,26 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } - (void) dealloc { -@@ -279,7 +498,106 @@ +@@ -279,24 +498,59 @@ static NSData *makeWindowIcon() { return _styleMask; } +-- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { +- if (force || (mask & NSWindowStyleMaskResizable) != +- (_styleMask & NSWindowStyleMaskResizable)) { +- XSizeHints *sh = XAllocSizeHints(); +- if (mask & NSWindowStyleMaskResizable) { +- // Make resizable +- sh->flags = 0; +- } else { +- // Make non-resizable +- sh->flags = PMinSize | PMaxSize; +- sh->min_width = sh->max_width = _frame.size.width; +- sh->min_height = sh->max_height = _frame.size.height; +- } +- +- XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); +- XFree(sh); +/* A borderless application covering a monitor needs EWMH fullscreen so the + * compositor places it above desktop panels. Compare full monitor bounds, + * never the work area, and leave unmanaged menus/popups alone. */ @@ -5695,7 +5493,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + if (!NSIsEmptyRect([screen frame]) && + NSEqualRects([display rootRectForFrame: [screen frame]], frame)) + return YES; -+ } + } + return NO; +} + @@ -5727,48 +5525,52 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + _layerDrawableOwner = nil; + [self updateLayerPresentationHints: _fullscreenRequested]; +} -+ + +- (void) requestFullscreen: (BOOL) fullscreen force: (BOOL) force { + if (fullscreen) { + unsigned long bypass = 2; + XChangeProperty(_display, _window, XInternAtom(_display,"_NET_WM_BYPASS_COMPOSITOR",False), + XA_CARDINAL,32,PropModeReplace,(unsigned char *)&bypass,1); + } -+ if (!_mapped) { -+ long states[2]; -+ int states_cnt = 0; -+ if (_isModal) { -+ states[states_cnt++] = -+ (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); -+ } + if (!_mapped) { + long states[2]; + int states_cnt = 0; +@@ -304,7 +558,7 @@ static NSData *makeWindowIcon() { + states[states_cnt++] = + (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); + } +- if (mask & NSWindowStyleMaskFullScreen) { + if (fullscreen) { -+ states[states_cnt++] = (long) XInternAtom( -+ _display, "_NET_WM_STATE_FULLSCREEN", False); -+ } -+ XChangeProperty(_display, _window, -+ XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, -+ 32, PropModeReplace, (const unsigned char *) states, -+ states_cnt); + states[states_cnt++] = (long) XInternAtom( + _display, "_NET_WM_STATE_FULLSCREEN", False); + } +@@ -312,14 +566,13 @@ static NSData *makeWindowIcon() { + XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, + 32, PropModeReplace, (const unsigned char *) states, + states_cnt); +- } else if (force || ((mask & NSWindowStyleMaskFullScreen) != +- (_styleMask & NSWindowStyleMaskFullScreen))) { + } else if (force || fullscreen != _fullscreenRequested) { -+ XClientMessageEvent event = {0}; -+ event.type = ClientMessage; -+ event.window = _window; -+ event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); -+ event.format = 32; + XClientMessageEvent event = {0}; + event.type = ClientMessage; + event.window = _window; + event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); + event.format = 32; +- event.data.l[0] = (mask & NSWindowStyleMaskFullScreen) ? 1 : 2; + event.data.l[0] = fullscreen ? 1 : 0; /* EWMH ADD / REMOVE, not TOGGLE */ -+ event.data.l[1] = -+ (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); -+ event.data.l[3] = 1; -+ XSendEvent(_display, DefaultRootWindow(_display), False, -+ SubstructureNotifyMask | SubstructureRedirectMask, -+ (XEvent *) &event); -+ } + event.data.l[1] = + (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); + event.data.l[3] = 1; +@@ -327,6 +580,48 @@ static NSData *makeWindowIcon() { + SubstructureNotifyMask | SubstructureRedirectMask, + (XEvent *) &event); + } + _fullscreenRequested = fullscreen; + [self updateLayerPresentationHints: fullscreen]; + XFlush(_display); +} + - - (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { ++- (void) setStyleMaskInternal: (NSUInteger) mask force: (BOOL) force { + // Cocoa chrome may change after the native window already exists. A hidden, + // transparent titlebar is client-drawn chrome, even with the titled bit set. + // NSWindowTitleHidden is 1; this SDK exposes titleVisibility as NSInteger. @@ -5781,17 +5583,13 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + XDeleteProperty(_display, _window, + XInternAtom(_display, "_MOTIF_WM_HINTS", False)); + } - if (force || (mask & NSWindowStyleMaskResizable) != - (_styleMask & NSWindowStyleMaskResizable)) { - XSizeHints *sh = XAllocSizeHints(); -@@ -287,46 +605,23 @@ - // Make resizable - sh->flags = 0; - } else { -- // Make non-resizable -- sh->flags = PMinSize | PMaxSize; -- sh->min_width = sh->max_width = _frame.size.width; -- sh->min_height = sh->max_height = _frame.size.height; ++ if (force || (mask & NSWindowStyleMaskResizable) != ++ (_styleMask & NSWindowStyleMaskResizable)) { ++ XSizeHints *sh = XAllocSizeHints(); ++ if (mask & NSWindowStyleMaskResizable) { ++ // Make resizable ++ sh->flags = 0; ++ } else { + if (X11IsLeagueGameWindow(_display, _window, _delegate)) { + // A fixed-size window cannot be made EWMH fullscreen by Mutter, + // and a stale minimum would block switching to a smaller mode. @@ -5802,47 +5600,17 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + sh->min_width = sh->max_width = _frame.size.width; + sh->min_height = sh->max_height = _frame.size.height; + } - } - - XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); - XFree(sh); - } - -- if (!_mapped) { -- long states[2]; -- int states_cnt = 0; -- if (_isModal) { -- states[states_cnt++] = -- (long) XInternAtom(_display, "_NET_WM_STATE_MODAL", False); -- } -- if (mask & NSWindowStyleMaskFullScreen) { -- states[states_cnt++] = (long) XInternAtom( -- _display, "_NET_WM_STATE_FULLSCREEN", False); -- } -- XChangeProperty(_display, _window, -- XInternAtom(_display, "_NET_WM_STATE", False), XA_ATOM, -- 32, PropModeReplace, (const unsigned char *) states, -- states_cnt); -- } else if (force || ((mask & NSWindowStyleMaskFullScreen) != -- (_styleMask & NSWindowStyleMaskFullScreen))) { -- XClientMessageEvent event = {0}; -- event.type = ClientMessage; -- event.window = _window; -- event.message_type = XInternAtom(_display, "_NET_WM_STATE", False); -- event.format = 32; -- event.data.l[0] = (mask & NSWindowStyleMaskFullScreen) ? 1 : 2; -- event.data.l[1] = -- (long) XInternAtom(_display, "_NET_WM_STATE_FULLSCREEN", False); -- event.data.l[3] = 1; -- XSendEvent(_display, DefaultRootWindow(_display), False, -- SubstructureNotifyMask | SubstructureRedirectMask, -- (XEvent *) &event); -- } ++ } ++ ++ XSetWMSizeHints(_display, _window, sh, XA_WM_NORMAL_HINTS); ++ XFree(sh); ++ } ++ + [self requestFullscreen:[self wantsFullscreenWithStyleMask:mask rootFrame:_frame] force:force]; } - (void) setStyleMask: (NSUInteger) mask { -@@ -338,7 +633,6 @@ +@@ -338,7 +633,6 @@ static NSData *makeWindowIcon() { } + (void) removeDecorationForWindow: (Window) w onDisplay: (Display *) dpy { @@ -5850,7 +5618,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m struct { unsigned long flags; unsigned long functions; -@@ -356,9 +650,23 @@ +@@ -356,11 +650,25 @@ static NSData *makeWindowIcon() { - (void) ensureMapped { if (!_mapped) { @@ -5861,21 +5629,22 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m XMapWindow(_display, _window); + _wasMapped = YES; _mapped = YES; -- } + _remoteFrameTargetRefresh = 0; -+ } -+} -+ + } + } + +/* darling-lol: only map for a window AppKit considers visible. NSWindow sets _isVisible before + * ordering, so ordered windows still map; a hidden one (like the client's splash) must not be + * revived by a later restack or makeKey. */ +- (void) ensureMappedIfVisible { + if (_delegate == nil || [_delegate isVisible]) + [self ensureMapped]; - } - ++} ++ - (void) showWindowWithoutActivation { -@@ -377,14 +685,26 @@ + [self ensureMapped]; + } +@@ -377,15 +685,27 @@ static NSData *makeWindowIcon() { - (void) syncDelegateProperties { long mask = KeyPressMask | KeyReleaseMask | ExposureMask | StructureNotifyMask | EnterWindowMask | LeaveWindowMask | @@ -5891,7 +5660,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + /* Motion also restores a cursor hidden while typing. App-level motion + * delivery is still filtered by acceptsMouseMovedEvents in X11Display. */ XSelectInput(_display, _window, mask); -+ + + /* Mouse-transparent windows must not steal crossing events from the + * view below them. Filtering dispatched events leaves X11 hit testing + * unchanged and can repeatedly hide/recreate tooltip windows. */ @@ -5904,10 +5673,11 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + 0, 0, None, ShapeSet); + XFlush(_display); + } - ++ // TODO: background color } -@@ -393,6 +713,28 @@ + +@@ -393,6 +713,28 @@ static NSData *makeWindowIcon() { // This is essentially dealloc; we release our contexts // and windows, but unlike dealloc, this method can be called // several times, so set everything to nil/NULL/0. @@ -5936,12 +5706,10 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m [_context release]; _context = nil; -@@ -419,6 +761,392 @@ - XDestroyWindow(_display, _window); - _window = 0; +@@ -421,6 +763,392 @@ static NSData *makeWindowIcon() { } -+} -+ + } + +- (void) setRemoteFrameTimerSlow: (BOOL) slow { + if (_remoteFrameTimerSlow == slow) return; + _remoteFrameTimerSlow = slow; @@ -6326,10 +6094,12 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + _remoteFrameValid = NO; + [self presentRemoteCAFrame: nil]; + return YES; - } - ++} ++ - (Window) windowHandle { -@@ -470,14 +1198,32 @@ + return _window; + } +@@ -470,14 +1198,32 @@ static NSData *makeWindowIcon() { const char *text = [title cString]; XStringListToTextProperty((char **) &text, 1, &prop); XSetWMName(_display, _window, &prop); @@ -6363,12 +6133,10 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } - (void) setHasShadow: (BOOL) value { -@@ -486,6 +1232,25 @@ - - - (void) setLevel: (int) value { +@@ -488,6 +1234,25 @@ static NSData *makeWindowIcon() { _level = value; -+} -+ + } + +/* NSApplication's attention timer calls this for requestUserAttention:. The + * CGWindow base class raises an abstract-invocation exception, which aborted + * the whole LeagueClientUx process. Ask the window manager to mark the window @@ -6386,10 +6154,12 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m + SubstructureNotifyMask | SubstructureRedirectMask, + (XEvent *) &event); + XFlush(_display); - } - ++} ++ - (void) setOpaque: (BOOL) value { -@@ -518,7 +1283,7 @@ + _isOpaque = value; + } +@@ -518,7 +1283,7 @@ static NSData *makeWindowIcon() { - (void) placeAboveWindow: (NSInteger) otherNumber { X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; @@ -6398,7 +6168,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m if (!other) { XRaiseWindow(_display, _window); -@@ -530,7 +1295,7 @@ +@@ -530,7 +1295,7 @@ static NSData *makeWindowIcon() { - (void) placeBelowWindow: (NSInteger) otherNumber { X11Window *other = [X11Window windowWithWindowNumber: otherNumber]; @@ -6407,7 +6177,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m if (!other) { XLowerWindow(_display, _window); -@@ -541,8 +1306,23 @@ +@@ -541,8 +1306,23 @@ static NSData *makeWindowIcon() { } - (void) makeKey { @@ -6432,7 +6202,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } - (void) makeMain { -@@ -559,7 +1339,29 @@ +@@ -559,7 +1339,29 @@ static NSData *makeWindowIcon() { XRaiseWindow(_display, _window); } @@ -6462,7 +6232,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m Atom wmState = XInternAtom(_display, "_NET_WM_STATE", FALSE); Atom hiddenState = XInternAtom(_display, "_NET_WM_STATE_HIDDEN", FALSE); -@@ -593,8 +1395,10 @@ +@@ -593,8 +1395,10 @@ static NSData *makeWindowIcon() { if (_cglContext == NULL) { CGLError error; @@ -6475,7 +6245,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m NSLog(@"CGLCreateContext failed at %s %d with error %d", __FILE__, __LINE__, error); -@@ -615,6 +1419,14 @@ +@@ -615,6 +1419,14 @@ static NSData *makeWindowIcon() { } - (void) openGLFlushBuffer { @@ -6490,7 +6260,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m CGLError error; CGLContextObj prevContext = CGLGetCurrentContext(); -@@ -652,7 +1464,7 @@ +@@ -652,7 +1464,7 @@ static NSData *makeWindowIcon() { } - (O2Rect) frame { @@ -6499,7 +6269,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m } static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { -@@ -666,6 +1478,7 @@ +@@ -666,6 +1478,7 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { } - (void) frameChanged { @@ -6507,7 +6277,7 @@ diff --git a/AppKit/X11.backend/X11Window.m b/AppKit/X11.backend/X11Window.m XErrorHandler previousHandler = XSetErrorHandler(ignoreBadWindow); @try { Window root, parent; -@@ -710,19 +1523,13 @@ +@@ -710,19 +1523,13 @@ static int ignoreBadWindow(Display *display, XErrorEvent *errorEvent) { [_deviceDictionary addEntriesFromDictionary: entries]; } From e47f05f424436e5ce369b189082ba07e848b8e11 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:49 +0200 Subject: [PATCH 20/54] Use the host DNS resolver in Darling --- .gitattributes | 6 +- docs/TESTING.md | 13 +++ docs/TROUBLESHOOTING.md | 25 ++++++ patches/darling.patch | 15 +++- patches/xnu.patch | 20 +++++ tests/test_host_dns.py | 133 ++++++++++++++++++++++++++++ tests/test_runtime_compatibility.py | 2 +- upstream/manifest.json | 2 +- 8 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 tests/test_host_dns.py diff --git a/.gitattributes b/.gitattributes index 00c1a6e..0cd6baf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ -# Unified-diff context uses a single space for an empty source line. -# Do not report that patch syntax as trailing source whitespace. -patches/*.patch whitespace=-blank-at-eol +# Unified-diff context adds a space before source tabs and empty lines. +# Do not report that patch syntax as source whitespace errors. +patches/*.patch whitespace=-blank-at-eol,-space-before-tab diff --git a/docs/TESTING.md b/docs/TESTING.md index 17a711a..ca9bd88 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -90,6 +90,19 @@ append-only output and tightening an existing public log to mode 0600. The runner test uses a fake `darling` executable to verify loopback fixtures and private-prefix-only shutdown/cleanup. +With the pinned Darling sources available, run the host resolver regression: + +```sh +python3 -m unittest discover -s tests -p 'test_host_dns.py' -v +``` + +It compiles the actual patched vchroot implementation in its Linux test mode +and reads temporary resolver files through absolute and relative host symlinks. +It checks atomic file replacement, symlink retargeting, guest DNS overrides and +guest absolute-link scope. A CMake fixture verifies that both install modes +replace an older Google resolver file with the host link. No Darling session or +DNS traffic is started. + ## CoreText font-list and AppKit resize regressions After preparing the source and configuring the runtime tools: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a655648..9b96bfe 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,6 +192,31 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` +### Riot reports NO INTERNET FOUND while the host is online + +Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which +some mobile and managed networks block. The base resolver now links to the +host's `/etc/resolv.conf` through `/Volumes/SystemRoot`, including when the host +file is a symlink into `/run` for systemd-resolved or NetworkManager. File and +symlink replacements remain visible without copying the resolver on launch. + +Exit Riot and League, then run `./scripts/update.sh` to rebuild and install both +the base link and the host symlink handling correction. Existing prefix DNS +overrides in `$DPREFIX/private/etc/resolv.conf` (normally +`~/.darling/private/etc/resolv.conf`) remain in effect; remove a previous manual +override with Darling stopped if you want to follow the host instead. A custom +prefix `/etc` directory can also shadow the base `/etc -> private/etc` link. + +Check resolution inside the guest with: + +```sh +darling shell curl https://clientconfig.rpg.riotgames.com/ +``` + +An HTTP response establishes that resolution succeeded even if that endpoint +rejects the request. This change follows the host resolver configuration; +already-running clients may cache DNS settings until restarted. + ### Downloads repeatedly fail with an IPv6 CONNECT_TO error The bundled downloader was observed retrying CDN chunks with: diff --git a/patches/darling.patch b/patches/darling.patch index a4d2178..ade6461 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -11230,10 +11230,21 @@ index 072a6e2..c804100 100755 echo "No instances running now" diff --git a/CMakeLists.txt b/CMakeLists.txt -index 8077b7e..0556080 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt -@@ -236,7 +236,12 @@ InstallSymlink(/Volumes/SystemRoot/usr/share/zoneinfo ${CMAKE_INSTALL_PREFIX}/li +@@ -204,8 +204,9 @@ + InstallSymlink(private/etc ${CMAKE_INSTALL_PREFIX}/libexec/darling/etc) + InstallSymlink(private/var ${CMAKE_INSTALL_PREFIX}/libexec/darling/var) + +-install(FILES etc/resolv.conf +- DESTINATION libexec/darling/private/etc) ++# Follow the host resolver, including local DNS proxies and network changes. ++# Prefix-local resolver overrides still take precedence over this base symlink. ++InstallSymlink(/Volumes/SystemRoot/etc/resolv.conf ${CMAKE_INSTALL_PREFIX}/libexec/darling/private/etc/resolv.conf) + InstallSymlink(/Volumes/SystemRoot/etc/machine-id ${CMAKE_INSTALL_PREFIX}/libexec/darling/private/etc/machine-id) + InstallSymlink(/Volumes/SystemRoot/etc/nsswitch.conf ${CMAKE_INSTALL_PREFIX}/libexec/darling/private/etc/nsswitch.conf) + +@@ -236,7 +237,12 @@ install(DIRECTORY DESTINATION libexec/darling/Library/Preferences) if(NOT DEBIAN_PACKAGING) diff --git a/patches/xnu.patch b/patches/xnu.patch index efbf7e3..a02f9cd 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -6180,3 +6180,23 @@ index 0000000..eb90f43 + return true; +} +#endif +diff --git a/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c b/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c +--- a/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c ++++ b/darling/src/libsystem_kernel/emulation/src/linux_premigration/vchroot_userspace.c +@@ -421,12 +421,14 @@ + ctxt->follow = true; + ctxt->symlink_depth++; + +- // resolve absolute symlinks within the prefix ++ // Keep absolute host symlinks outside the prefix after crossing ++ // /Volumes/SystemRoot (e.g. /etc/resolv.conf -> /run/...). ++ // Resolve guest symlinks within the prefix + // + // however, DON'T resolve `/proc` symlinks within the prefix. + // anything under `/proc` is from Linux's `/proc` since we bind-mount it, so we can special-case them because we want those to be Linux-root relative. + // see https://github.com/darlinghq/darling/issues/1052 for the issue this works around. +- if (link[0] == '/' && !(strncmp(link, "/proc/", 6) == 0 || (rv == 5 && strncmp(link, "/proc", 5) == 0))) { ++ if (link[0] == '/' && ctxt->current_root_len > 0 && !(strncmp(link, "/proc/", 6) == 0 || (rv == 5 && strncmp(link, "/proc", 5) == 0))) { + ctxt->current_root = prefix_path; + ctxt->current_root_len = prefix_path_len; + } diff --git a/tests/test_host_dns.py b/tests/test_host_dns.py new file mode 100644 index 0000000..9a26d07 --- /dev/null +++ b/tests/test_host_dns.py @@ -0,0 +1,133 @@ +"""Exercise the base resolver install and actual vchroot code without DNS traffic.""" +from pathlib import Path +import os +import shutil +import subprocess +import tempfile +import unittest + +import test_runtime_compatibility as sources + + +@unittest.skipUnless(shutil.which('cc') and shutil.which('patch'), 'C compiler and patch required') +class HostDNSChecks(unittest.TestCase): + patched = sources.CompatibilityChecks.patched + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix='host-dns-') + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.prefix = self.root / 'guest' + (self.prefix / 'private/etc').mkdir(parents=True) + (self.prefix / 'Volumes/SystemRoot').mkdir(parents=True) + (self.prefix / 'etc').symlink_to('private/etc') + self.resolver = self.prefix / 'private/etc/resolv.conf' + + def build_vchroot(self): + path = 'darling/src/libsystem_kernel/emulation/' + source = self.patched('xnu', path + 'src/linux_premigration/vchroot_userspace.c') + header = self.root / 'darling/emulation/linux_premigration/vchroot_expand.h' + header.parent.mkdir(parents=True) + header.write_text(self.patched('xnu', path + 'include/linux_premigration/vchroot_expand.h')) + # Use upstream's Linux TEST mode, replacing only its CLI and RPC setup. + fixture = r''' +#define _GNU_SOURCE +#define TEST +#include +#include +#include +#include +#define LC_C_LOCALE 0 +#define strncasecmp_l(a,b,n,l) strncasecmp(a,b,n) +#define __simple_printf printf +#define __simple_abort abort +static int dserver_rpc_vchroot_path(char *p, unsigned long n, uint64_t *len) { abort(); } +#define main upstream_main +''' + fixture += source + r''' +#undef main +int main(int argc, const char **argv) { + if (argc != 3) return 2; + int fd = open(argv[1], O_RDONLY | O_DIRECTORY); + if (fd < 0 || __darling_vchroot(fd)) return 3; + close(fd); + struct vchroot_expand_args args = {.dfd = -100, .flags = VCHROOT_FOLLOW}; + strcpy(args.path, argv[2]); + int result = vchroot_expand(&args); + if (result) return 4; + FILE *file = fopen(args.path, "r"); + if (!file) return 5; + char buf[4096]; + while (fgets(buf, sizeof(buf), file)) fputs(buf, stdout); + return fclose(file) != 0; +} +''' + file = self.root / 'check.c' + file.write_text(fixture) + self.binary = self.root / 'check' + result = subprocess.run(['cc', '-std=gnu11', '-I', str(self.root), str(file), + '-o', str(self.binary)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + + def read_guest(self, path='/etc/resolv.conf'): + result = subprocess.run([str(self.binary), str(self.prefix), path], + capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def test_host_symlink_chains_updates_and_guest_override(self): + self.build_vchroot() + host = self.root / 'host' + host.mkdir() + data = host / 'stub-resolv.conf' + data.write_text('nameserver 127.0.0.53\nsearch tether.example\n') + self.resolver.symlink_to('/Volumes/SystemRoot' + str(data)) + self.assertEqual(self.read_guest(), data.read_text()) + self.resolver.unlink() + # An absolute host link followed by a relative one models resolved and + # NetworkManager layouts. Never write to the real host resolver. + (host / 'relative').symlink_to(data.name) + (host / 'resolv.conf').symlink_to(host / 'relative') + self.resolver.symlink_to('/Volumes/SystemRoot' + str(host / 'resolv.conf')) + self.assertEqual(self.read_guest(), data.read_text()) + # Network managers replace files atomically; a bind mount or snapshot + # would keep reading the old resolver inode. + replacement = host / 'next' + replacement.write_text('nameserver 192.0.2.53\n') + os.replace(replacement, data) + self.assertEqual(self.read_guest(), data.read_text()) + other = host / 'other' + other.write_text('nameserver 192.0.2.54\n') + (host / 'resolv.conf').unlink() + (host / 'resolv.conf').symlink_to(other) + self.assertEqual(self.read_guest(), other.read_text()) + self.resolver.unlink() + self.resolver.write_text('nameserver 127.0.0.1\n') + self.assertEqual(self.read_guest(), self.resolver.read_text()) + # Absolute links originating inside the guest must retain guest scope. + (self.prefix / 'guest-link').symlink_to('/private/etc/resolv.conf') + self.assertEqual(self.read_guest('/guest-link'), self.resolver.read_text()) + + @unittest.skipUnless(shutil.which('cmake'), 'CMake required') + def test_base_install_replaces_google_file_with_host_link(self): + cmake = self.patched('darling', 'CMakeLists.txt') + start = cmake.index('# Follow the host resolver') + end = cmake.index('\n', cmake.index('InstallSymlink(', start)) + macro = self.patched('darling', 'cmake/InstallSymlink.cmake') + project = self.root / 'project' + project.mkdir() + (project / 'CMakeLists.txt').write_text( + 'cmake_minimum_required(VERSION 3.16)\nproject(dns NONE)\n' + macro + '\n' + cmake[start:end] + '\n') + base = self.root / 'base' + target = base / 'libexec/darling/private/etc/resolv.conf' + target.parent.mkdir(parents=True) + for packaging in ('OFF', 'ON'): + target.unlink(missing_ok=True) + target.write_text('nameserver 8.8.8.8\nnameserver 8.8.4.4\n') + build = self.root / ('build-' + packaging) + subprocess.run(['cmake', '-S', str(project), '-B', str(build), + '-DCMAKE_INSTALL_PREFIX=' + str(base), + '-DBINARY_PACKAGING_MODE=' + packaging], check=True, capture_output=True) + subprocess.run(['cmake', '--install', str(build)], check=True, capture_output=True) + self.assertTrue(target.is_symlink()) + self.assertEqual(os.readlink(target), '/Volumes/SystemRoot/etc/resolv.conf') diff --git a/tests/test_runtime_compatibility.py b/tests/test_runtime_compatibility.py index 741917e..1539369 100644 --- a/tests/test_runtime_compatibility.py +++ b/tests/test_runtime_compatibility.py @@ -38,7 +38,7 @@ class CompatibilityChecks(unittest.TestCase): patch = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0] with tempfile.TemporaryDirectory() as folder: target = Path(folder) / path - target.parent.mkdir(parents=True) + target.parent.mkdir(parents=True, exist_ok=True) if original is not None: target.write_bytes(original) subprocess.run(['patch', '--silent', '-p1', '-d', folder], input=marker + patch + ("" if patch.endswith("\n") else "\n"), diff --git a/upstream/manifest.json b/upstream/manifest.json index 60fbbb8..331ce8f 100644 --- a/upstream/manifest.json +++ b/upstream/manifest.json @@ -274,7 +274,7 @@ "revision": "fa29287aa2f0115271e091f1031f53c9e024005d", "repository": "https://github.com/darlinghq/darling-xnu.git", "patch": "patches/xnu.patch", - "modified_files": 64, + "modified_files": 65, "new_files": [ "darling/src/libsystem_kernel/emulation/include/common/cpu_topology.h", "darling/src/libsystem_kernel/emulation/include/common/private_mutex.h", From bf02f7abee0bf5ab32e701d11dcdd199b6567b48 Mon Sep 17 00:00:00 2001 From: Polar <343746874+PolarSeeker77@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:21:26 +0000 Subject: [PATCH 21/54] Document NVIDIA reports instead of "NVIDIA is not validated" --- README.md | 2 +- docs/TROUBLESHOOTING.md | 29 ++++++++++++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d997fb1..5be348c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Game files, Riot installers, account databases, tokens, local prefixes, captures ## Requirements -- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV has been exercised; other drivers are not validated. Hybrid laptops usually render on the integrated GPU unless started with PRIME offload; NVIDIA is not validated. See [hybrid graphics](docs/TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). +- x86-64 Linux **5.14 or newer**, an X11 session or Xwayland, and a working hardware Vulkan/OpenGL driver. AMD RADV and the NVIDIA driver (proprietary and open kernel modules) have been exercised, see [NVIDIA](docs/TROUBLESHOOTING.md#nvidia); other drivers are not validated. Hybrid laptops usually render on the integrated GPU unless started with PRIME offload. See [hybrid graphics](docs/TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia). - Git + Git LFS, Bash, curl, unzip, CMake, Make, Clang/LLD, LLVM development files, a C/C++ compiler, Python 3.12+ and the Darling build dependencies. The patch includes LLVM 22/23 branch-opcode compatibility; the new PC used LLVM/Clang 23. Node.js is needed only for the offline JavaScript checks. - X11/XCB/SHM development files for the current runtime helpers. - Enough free disk for Darling sources/build, Git LFS assets and the game: budget at least 50 GB. Building is substantially larger than this source-only repository. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index a655648..9a08353 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -349,11 +349,25 @@ On Hyprland, also check that direct scanout is off (`hyprctl getoption render:di On the NVIDIA proprietary driver, check the submit worker's kernel time: a thread of the game process spending most of its time in the kernel (`/proc//task//stat` system time, or `perf trace -s -p `) points at driver calls. Each `vkQueueSubmit2` and each CPU-side `vkSignalSemaphore` is an RM control ioctl (command `0xc020462a`) that queues on a driver-wide lock. Before 2026-10-18 every command buffer signalled the event loop's wake-up semaphore, ~25 times per frame; with lazy wake-ups (default) and a 5 ms batch window (`/tmp/metal-submit-batch-us` = `5000`, `/tmp/metal-submit-batch-max` = `64`) an RTX 4090 ran at 206–552 FPS against 91–147 FPS with `/tmp/metal-no-lazy-wakeup`, in alternating 20 s phases of one match (the later phases with wake-ups on were capped at 240 FPS) (see [setup](SETUP.md#metal-and-indium-overlays)). The NVIDIA performance figures above were reported on an earlier runtime base; -they have not been remeasured after integration with current `main`. +they have not been remeasured after integration with current `main`. On `bcb3245` an RTX 5060 Laptop reported ~200–215 FPS in Practice Tool ([#53](https://github.com/lucannez64/darling-lol/issues/53)). + +## NVIDIA + +With the window visual and compositor draw buffer fixes (#22) and the RPC socket fix (#25), the game renders and runs on these NVIDIA GPUs: + +| GPU | Driver | Report | Result | +|---|---|---|---| +| GTX 1060 6GB | 580.178.04 | [below](#nvidia-580-pascal-and-older-black-game-window) | client and tutorial | +| RTX 2070 SUPER | 615.78, proprietary | [#55](https://github.com/lucannez64/darling-lol/issues/55) | tutorials, Co-op vs AI, Swiftplay and Draft games; no crashes | +| RTX 3060 | 615.71 | [below](#nvidia-resize-signals-cpu-readback-and-completion-latency) | custom matches through CPU readback | +| RTX 5060 Laptop | 615.71.09, open modules | [#53](https://github.com/lucannez64/darling-lol/issues/53) | Practice Tool at ~200–215 FPS on `bcb3245`; MUX set to discrete, PRIME variables set | +| RTX 5070 | 615.78.08, open modules | [#69](https://github.com/lucannez64/darling-lol/issues/69) | tutorial | + +If the game window stays black on NVIDIA, check `metal-device.log` as described in [Check which GPU rendered](#check-which-gpu-rendered). ## Hybrid graphics laptops (AMD/Intel iGPU + NVIDIA) -Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen. **Running on an NVIDIA GPU is not validated.** +Metal renders with Vulkan, and each finished frame is handed to an OpenGL (EGL) context that presents it. The hand-off uses opaque file descriptors, which only work when Vulkan and OpenGL run on the same GPU. Indium takes the first Vulkan device the loader lists, and on a hybrid laptop both that device and the EGL context are normally the integrated GPU, so the discrete GPU stays idle. A report in [issue #3](https://github.com/lucannez64/darling-lol/issues/3) (Ryzen 9 5900HS, RTX 3060 Laptop) measured 60-70 FPS in ARAM on the integrated GPU; on the NVIDIA GPU the game showed a black screen, since fixed (see [NVIDIA](#nvidia)). ### Run on the discrete GPU @@ -363,7 +377,12 @@ Start the launcher with PRIME render offload so that both Vulkan and OpenGL move prime-run sh scripts/run.sh ``` -`prime-run` sets `__NV_PRIME_RENDER_OFFLOAD=1`, `__VK_LAYER_NV_optimus=NVIDIA_only` and `__GLX_VENDOR_LIBRARY_NAME=nvidia`. `darling` copies the caller's environment into the guest, and guest processes inherit it; whether the Riot client passes all of it to the game has not been checked; the log below shows the result. Darling's OpenGL uses EGL, so if the log below still shows the integrated GPU as the presenting renderer, also set `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`. On two Mesa GPUs, `DRI_PRIME=1` does the same. +`prime-run` sets `__NV_PRIME_RENDER_OFFLOAD=1`, `__VK_LAYER_NV_optimus=NVIDIA_only` and `__GLX_VENDOR_LIBRARY_NAME=nvidia`. `darling` copies the caller's environment into the guest, and guest processes inherit it; whether the Riot client passes all of it to the game has not been checked; the log below shows the result. Darling's OpenGL uses EGL, so if the log below still shows the integrated GPU as the presenting renderer, also set `__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json`. On two Mesa GPUs, `DRI_PRIME=1` does the same. The RTX 5060 Laptop in [#53](https://github.com/lucannez64/darling-lol/issues/53) rendered with all four variables set: + +```sh +__NV_PRIME_RENDER_OFFLOAD=1 __VK_LAYER_NV_optimus=NVIDIA_only __GLX_VENDOR_LIBRARY_NAME=nvidia \ +__EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/10_nvidia.json sh scripts/run.sh +``` The guest file `/tmp/metal-gpu` (a device index or part of its name, for example `nvidia`) moves only the Vulkan side. Use it for diagnosis: on its own it splits the two sides across GPUs, and frames cannot be presented. @@ -397,9 +416,9 @@ The first import of a drawable into the presenting OpenGL context, and the first [MetalDevice] pid=… drawable GL semaphore (first) import_error=0x0 wait_error=0x0 ok (checked for the first 600 imports) ``` -A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it, and it is the suspected cause of the NVIDIA black screen (not yet confirmed). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file. +A non-zero error or `FAILED` means the frames cannot reach the window. Before 2026-10-16 the import did not mark the memory as dedicated, which the extension requires; Mesa ignores it. It was suspected of causing the NVIDIA black screen; the causes found later were the window visual and the draw buffer (#22). An NVIDIA log of 2026-10-16 showed `glTiling=optimal` with `setup_error=0x501 storage_error=0x501 FAILED`: NVIDIA's GL rejects `GL_LINEAR_TILING_EXT`, so the linear Vulkan image could not be imported. Since then the internal image and the GL texture use optimal tiling when `GL_VENDOR` contains NVIDIA (`vkTiling=optimal glTiling=optimal ok` is the expected line); everything else keeps linear. If the game shows a black screen on NVIDIA, report it with this file. -An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Confirmation on the reporter's NVIDIA system is still needed. +An additional NVIDIA report on `68f816d` showed successful optimal-tiling import but repeated `Metal Device timed out giving us a Drawable to present` messages. Its [presentation trace](https://github.com/lucannez64/darling-lol/issues/3#issuecomment-6214732591) shows all three drawables queued and all three main-thread presentation callbacks running, followed by timeouts with three frames still queued. The compositor only prepared a Metal layer when it was the root layer, skipping nested Metal layers. It now prepares Metal layers throughout the tree. A standalone nested-layer reproduction exhausts the pool before this correction and passes ten presentations with pixel readback after it; the root-layer probe also passes. Issue #3 has since been closed, and the game renders on NVIDIA (see [NVIDIA](#nvidia)). ### NVIDIA resize signals, CPU readback and completion latency From 5f71c82dc5b00c0f16bed0c9fc444ab76c651746 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:22:17 +0200 Subject: [PATCH 22/54] Bypass proxies when checking guest DNS --- docs/TROUBLESHOOTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 9b96bfe..9ecb58e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -210,7 +210,7 @@ prefix `/etc` directory can also shadow the base `/etc -> private/etc` link. Check resolution inside the guest with: ```sh -darling shell curl https://clientconfig.rpg.riotgames.com/ +darling shell curl --noproxy '*' https://clientconfig.rpg.riotgames.com/ ``` An HTTP response establishes that resolution succeeded even if that endpoint From 468009b7847aac6cf38aa251cbf9c49b73fded22 Mon Sep 17 00:00:00 2001 From: Benjamin <18471851+WolfBlack879@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:10:06 -0300 Subject: [PATCH 23/54] security: keep trustd running from boot The Riot Client UI intermittently stayed black for 30s to 2+ minutes (sometimes indefinitely). Electron 22 / Chrome 108 verifies the local Riot backend's TLS certificate through SecTrust, which calls trustd over XPC. trustd is launched on demand and has EnablePressuredExit, so it is often not running; under Darling the on-demand launch can take minutes, and every cert verification (serialized behind Chrome's global lock) waits for it. The UI's websocket handshake stalls after ServerHello until then. Start trustd at boot and keep it alive. Measured on one machine: before, time to "renderer socket connected to Foundation" ranged 0.8s-136s or never; after, 0.83s / 0.85s / 0.97s on three launches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014cgmCWUB2zNH4uiMEioafC --- patches/security.patch | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/patches/security.patch b/patches/security.patch index d12af04..c975769 100644 --- a/patches/security.patch +++ b/patches/security.patch @@ -205,6 +205,21 @@ index 06c5d4aa..9f24ea91 100644 if (context.numTasks >= MAX_TASKS) { secnotice("http", "Too many fetch %@ requests for this cert", [self class]); return true; +diff --git a/trust/trustd/macOS/com.apple.trustd.plist b/trust/trustd/macOS/com.apple.trustd.plist +index b3780e3c..71e089a4 100644 +--- a/trust/trustd/macOS/com.apple.trustd.plist ++++ b/trust/trustd/macOS/com.apple.trustd.plist +@@ -2,7 +2,9 @@ + + + +- EnablePressuredExit ++ KeepAlive ++ ++ RunAtLoad + + EnableTransactions + diff --git a/trust/trustd/trustdFileHelper/main.m b/trust/trustd/trustdFileHelper/main.m index f6d166e2..fb7ae819 100644 --- a/trust/trustd/trustdFileHelper/main.m From c4135ab79fa087e69a95d026c42a4b51ea22c7c1 Mon Sep 17 00:00:00 2001 From: David Gheghea <71325082+DavidutzDev@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:45:23 +0200 Subject: [PATCH 24/54] Raise darlingserver's soft fd limit when the hard limit is fixed Under the rootless NixOS launcher darlingserver cannot raise its hard RLIMIT_NOFILE to nr_open, so it kept the soft limit it inherited. Every named memory entry holds a memfd in darlingserver, and its descriptors peak at about 1100 when the League lobby opens. With an inherited soft limit of 1024, memfd_create failed with EMFILE, mach_make_memory_entry_64 returned KERN_RESOURCE_SHORTAGE, and Chromium's CHECK in PlatformSharedMemoryRegion::Create killed the League UX and the Riot Client renderer after clicking Play. When raising the hard limit fails, raise the soft limit to the inherited hard limit instead. Log errno, size and fd limits when named-entry creation fails, and log the live memfd count every 1024 creations so a leak shows up in dserver.log. Co-Authored-By: Claude Opus 5.5 --- docs/TROUBLESHOOTING.md | 21 ++++++++ patches/darlingserver.patch | 98 ++++++++++++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 54ec36b..15ffb7b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -251,6 +251,27 @@ reference before callback delivery. This establishes the cancellation fix; a lon Riot session still needs confirmation. With both clients and the game closed, run `./scripts/update.sh` to install it, then `sh scripts/run.sh`. +## League and Riot UIs crash after clicking Play + +The League UX processes and then the Riot Client renderer stop with `SIGTRAP` +a few seconds after the lobby opens. The renderer prints +`Check failed: kr == KERN_SUCCESS. mach_make_memory_entry_64: (os/kern) resource shortage (6)` +from `platform_shared_memory_region_mac.cc`. darlingserver backs every named +memory entry with a memfd, and its open descriptors peak at about 1100 when the +lobby opens. Under the rootless NixOS launcher darlingserver cannot raise its +hard `RLIMIT_NOFILE` to `nr_open`, so it used to keep the soft limit it +inherited. When that limit was 1024 (several Electron apps and desktop launchers +pass it on), `memfd_create` failed with `EMFILE`. + +darlingserver now raises its soft limit to the inherited hard limit when it +cannot raise the hard one. A failed named-entry creation also logs +`MEMDIAG … failed errno=… nofile=soft/hard` to `dserver.log`, and every 1024th +memfd logs `MEMDIAG descriptors live=…` so a leak would show as a growing `live` +count. With a 1024 soft limit the old server reproduced the crash and logged +`errno=24 nofile=1024/524288`. With the fix, the server reports a 524288 soft +limit under the same launch. To check a running server, read the `Max open files` +line of `/proc/$(pgrep -x darlingserver)/limits`. + ## UI crashes after a button opens an external link LaunchServices dereferenced a nullable error-output pointer in six paths. The patch checks the pointer itself and populates supplied outputs; the standalone regression covers missing handlers and null outputs. Electron opens LaunchServices through an absolute CoreServices subframework path, so the paired installer also replaces that binary in the stopped guest prefix. Updating only `RuntimeFrameworks/LaunchServices.framework` can leave the crash unfixed. diff --git a/patches/darlingserver.patch b/patches/darlingserver.patch index accf119..a2cc225 100644 --- a/patches/darlingserver.patch +++ b/patches/darlingserver.patch @@ -445,7 +445,7 @@ index 458e659..2e33f20 100644 // wait until it's read if (!dtape_semaphore_down_simple(kqchan->waiter_read_semaphore)) { diff --git a/duct-tape/src/memory.c b/duct-tape/src/memory.c -index 3114947..e2a8d5e 100644 +index 3114947..20cd162 100644 --- a/duct-tape/src/memory.c +++ b/duct-tape/src/memory.c @@ -11,6 +11,7 @@ @@ -456,7 +456,55 @@ index 3114947..e2a8d5e 100644 #include struct zone { -@@ -341,7 +342,14 @@ kern_return_t copyoutmap(vm_map_t map, void* fromdata, vm_map_address_t toaddr, +@@ -52,6 +53,26 @@ int ftruncate(int fd, off_t length); + + #define MFD_CLOEXEC 0x1 + ++/* darling-lol diagnostics: why shared-memory creation fails. */ ++int* __errno_location(void); ++struct dtape_diag_rlimit { unsigned long cur, max; }; ++int prlimit(int pid, int resource, const struct dtape_diag_rlimit* new_limit, struct dtape_diag_rlimit* old_limit); ++#define DTAPE_DIAG_RLIMIT_NOFILE 7 ++ ++static long dtape_diag_live_descriptors; ++static unsigned long dtape_diag_created_descriptors; ++ ++static void dtape_diag_log_shortage(const char* what, int fd, uint64_t size) { ++ int err = *__errno_location(); ++ struct dtape_diag_rlimit limit = {0, 0}; ++ prlimit(0, DTAPE_DIAG_RLIMIT_NOFILE, NULL, &limit); ++ dtape_log_error("MEMDIAG %s failed errno=%d size=%#llx fd=%d live=%ld created=%lu nofile=%lu/%lu", ++ what, err, (unsigned long long)size, fd, ++ __atomic_load_n(&dtape_diag_live_descriptors, __ATOMIC_RELAXED), ++ __atomic_load_n(&dtape_diag_created_descriptors, __ATOMIC_RELAXED), ++ limit.cur, limit.max); ++} ++ + void dtape_memory_init(void) { + + }; +@@ -107,6 +128,12 @@ static dtape_map_shared_descriptor_t* dtape_map_shared_descriptor_create(int mem + desc->memfd = memfd; + desc->size = size; + ++ long live = __atomic_add_fetch(&dtape_diag_live_descriptors, 1, __ATOMIC_RELAXED); ++ unsigned long created = __atomic_add_fetch(&dtape_diag_created_descriptors, 1, __ATOMIC_RELAXED); ++ if (created % 1024 == 0) { ++ dtape_log_error("MEMDIAG descriptors live=%ld created=%lu last_fd=%d", live, created, memfd); ++ } ++ + return desc; + }; + +@@ -121,6 +148,7 @@ static void dtape_map_shared_descriptor_release(dtape_map_shared_descriptor_t* d + + close(desc->memfd); + free(desc); ++ __atomic_sub_fetch(&dtape_diag_live_descriptors, 1, __ATOMIC_RELAXED); + }; + + static dtape_map_shared_entry_t* dtape_map_shared_entry_create(uint64_t address, uint64_t size, uint64_t page_offset, dtape_map_shared_descriptor_t* descriptor) { +@@ -341,7 +369,14 @@ kern_return_t copyoutmap(vm_map_t map, void* fromdata, vm_map_address_t toaddr, memmove((void*)toaddr, fromdata, length); return KERN_SUCCESS; } else { @@ -472,7 +520,7 @@ index 3114947..e2a8d5e 100644 } }; -@@ -526,20 +534,212 @@ kern_return_t mach_vm_allocate_kernel(vm_map_t map, mach_vm_offset_t* addr, mach +@@ -526,20 +561,218 @@ kern_return_t mach_vm_allocate_kernel(vm_map_t map, mach_vm_offset_t* addr, mach } }; @@ -570,8 +618,13 @@ index 3114947..e2a8d5e 100644 + dtape_map_shared_descriptor_t* desc = NULL; + if (permission & MAP_MEM_NAMED_CREATE) { + memfd = memfd_create("darling-mementry", MFD_CLOEXEC); -+ if (memfd < 0 || ftruncate(memfd, map_size) < 0) { -+ if (memfd >= 0) close(memfd); ++ if (memfd < 0) { ++ dtape_diag_log_shortage("named-create memfd_create", memfd, map_size); ++ return KERN_RESOURCE_SHORTAGE; ++ } ++ if (ftruncate(memfd, map_size) < 0) { ++ dtape_diag_log_shortage("named-create ftruncate", memfd, map_size); ++ close(memfd); + return KERN_RESOURCE_SHORTAGE; + } + } else { @@ -627,6 +680,7 @@ index 3114947..e2a8d5e 100644 + + desc = dtape_map_shared_descriptor_create(memfd, map_size); + if (desc == NULL) { ++ dtape_diag_log_shortage("named-entry descriptor malloc", memfd, map_size); + close(memfd); + return KERN_RESOURCE_SHORTAGE; + } @@ -689,7 +743,7 @@ index 3114947..e2a8d5e 100644 }; kern_return_t mach_memory_info(host_priv_t host, mach_zone_name_array_t* namesp, mach_msg_type_number_t* namesCntp, mach_zone_info_array_t* infop, mach_msg_type_number_t* infoCntp, mach_memory_info_array_t* memoryInfop, mach_msg_type_number_t* memoryInfoCntp) { -@@ -591,8 +791,115 @@ kern_return_t mach_vm_machine_attribute(vm_map_t map, mach_vm_address_t addr, ma +@@ -591,8 +824,115 @@ kern_return_t mach_vm_machine_attribute(vm_map_t map, mach_vm_address_t addr, ma }; kern_return_t mach_vm_map_external(vm_map_t target_map, mach_vm_offset_t* address, mach_vm_size_t initial_size, mach_vm_offset_t mask, int flags, ipc_port_t port, vm_object_offset_t offset, boolean_t copy, vm_prot_t cur_protection, vm_prot_t max_protection, vm_inherit_t inheritance) { @@ -807,7 +861,7 @@ index 3114947..e2a8d5e 100644 }; kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size_t size, vm_sync_t sync_flags) { -@@ -620,15 +927,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size +@@ -620,15 +960,22 @@ kern_return_t mach_vm_msync(vm_map_t map, mach_vm_offset_t address, mach_vm_size }; kern_return_t mach_vm_page_info(vm_map_t map, mach_vm_address_t address, vm_page_info_flavor_t flavor, vm_page_info_t info, mach_msg_type_number_t* count) { @@ -833,7 +887,7 @@ index 3114947..e2a8d5e 100644 }; kern_return_t mach_vm_protect(vm_map_t map, mach_vm_offset_t start, mach_vm_size_t size, boolean_t set_maximum, vm_prot_t new_protection) { -@@ -756,7 +1070,8 @@ region_info_out: +@@ -756,7 +1103,8 @@ region_info_out: return kr; }; default: @@ -843,7 +897,7 @@ index 3114947..e2a8d5e 100644 } }; -@@ -856,7 +1171,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ +@@ -856,7 +1204,7 @@ static kern_return_t mach_vm_remap_external_shared(vm_map_t target_map, mach_vm_ src_existing_entry_count = dtape_map_find_shared_entries_locked(src_map, start_memaddr, map_size, src_existing_entries, sizeof(src_existing_entries) / sizeof(*src_existing_entries)); @@ -852,7 +906,7 @@ index 3114947..e2a8d5e 100644 // special case for what LLDB does with dyld info dtape_map_shared_descriptor_retain(src_existing_entries[0]->descriptor); descriptor = src_existing_entries[0]->descriptor; -@@ -1441,6 +1756,10 @@ mach_destroy_memory_entry( +@@ -1441,6 +1789,10 @@ mach_destroy_memory_entry( named_entry->ref_count -= 1; if (named_entry->ref_count == 0) { @@ -863,7 +917,7 @@ index 3114947..e2a8d5e 100644 if (named_entry->is_sub_map) { vm_map_deallocate(named_entry->backing.map); } else if (named_entry->is_copy) { -@@ -1456,6 +1775,9 @@ mach_destroy_memory_entry( +@@ -1456,6 +1808,9 @@ mach_destroy_memory_entry( named_entry_unlock(named_entry); named_entry_lock_destroy(named_entry); @@ -1802,7 +1856,7 @@ index cc10f1d..7df0056 100644 try { thread->processSignal(_body.bsd_signal_number, _body.linux_signal_number, _body.code, _body.signal_address, _body.thread_state, _body.float_state); diff --git a/src/darlingserver.cpp b/src/darlingserver.cpp -index f79f057..2bf9cfb 100644 +index f79f057..54c42aa 100644 --- a/src/darlingserver.cpp +++ b/src/darlingserver.cpp @@ -497,6 +497,45 @@ static void handle_sigusr1(int signum) { @@ -1882,7 +1936,25 @@ index f79f057..2bf9cfb 100644 if (argc < 6) { fprintf(stderr, "darlingserver is not meant to be started manually\n"); exit(1); -@@ -745,6 +808,13 @@ int main(int argc, char** argv) { +@@ -577,8 +640,15 @@ int main(int argc, char** argv) { + + // now set our increased rlimit + if (setrlimit(RLIMIT_NOFILE, &increased_limit) != 0) { +- fprintf(stderr, "Warning: failed to increase FD rlimit: %s\n", strerror(errno)); +- //exit(1); ++ // darling-lol: a rootless user namespace cannot raise the hard limit. ++ // Still raise the soft limit to it: every named memory entry holds a ++ // memfd here, and Chromium's UX restart pushes past a 1024 soft limit, ++ // failing memfd_create with EMFILE. ++ increased_limit.rlim_cur = increased_limit.rlim_max = default_limit.rlim_max; ++ if (setrlimit(RLIMIT_NOFILE, &increased_limit) != 0) { ++ fprintf(stderr, "Warning: failed to increase FD rlimit: %s\n", strerror(errno)); ++ //exit(1); ++ } + } + + #endif +@@ -745,6 +815,13 @@ int main(int argc, char** argv) { #endif // create the server From 7947164c2c5fad5cb285484cb9f4ebd641e3ac4e Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:35:19 -0600 Subject: [PATCH 25/54] darling-lol: use an original project mark instead of the game logo The bundled darling-lol.svg/png are the League of Legends logo, a Riot Games trademark, used as the project icon. The README already states that the project is not affiliated with or endorsed by Riot Games, so the icon should not be someone else's mark. Replace both files with an original D monogram. Same file names, so flake.nix and the desktop entry keep working. The mark is provisional and easy to swap for a final one. No functional changes. From 5cf12f4f0698f38ebe3ca7617a16940b2071c6e8 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:47:59 -0600 Subject: [PATCH 26/54] README, NOTICE: add trademark notice and remove the gameplay clip Consolidate the non-affiliation note into a dedicated Trademarks section, replace the embedded gameplay video with a pointer to the status document, and reword the takedown note as a continuity statement. Describe the compatibility tools as referring to public API names and constants. --- NOTICE.md | 8 +++++++- README.md | 13 ++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/NOTICE.md b/NOTICE.md index 0332431..f9cedba 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -4,4 +4,10 @@ The manifest points to public upstream source repositories. This repository includes source differences and authored integration/research tools, not a copy of the upstream runtime, Apple's SDK binaries or Riot's client/game assets. Build artifacts and local installs are excluded. -Some compatibility tools contain short instruction signatures or API names used to recognize the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly. +Some compatibility tools refer to public API names and constants of the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly. + +## Trademarks + +League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. +This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or +sponsored by Riot Games. diff --git a/README.md b/README.md index 5be348c..5f34130 100644 --- a/README.md +++ b/README.md @@ -16,14 +16,14 @@ > > Even with all of that, you will probably still get permabanned. > -> **3. Takedown.** Riot may well shut this project down. If that happens, I will publish a fork of Darling containing only the changes made here (none of them breach anything), and release the small script as a gist or similar. +> **3. Continuity.** If this repository is discontinued, the patches remain available under their upstream licenses, and the original integration tooling remains available under GPL-3.0. > > **4. To Riot.** If you are reading this, it means a lot of people want to play your game on Linux and are disappointed that they currently can't. As with macOS for your other games (TFT, Valorant), it feels like Riot doesn't care about a part of its players who have been waiting the longest. If you want to fix this, make LoL Next compatible with Linux, either natively or through a layer like Proton. Yes, that includes your kernel anticheat: Epic Games did it with Easy Anti-Cheat. Follow the trend. This is the year of Linux, and people shouldn't be locked out of a platform or forced to dual boot for a single game. > Since Riot have heard of the repo if you want to contact me you can at darling-lolriot@proton.me (especially if you want to do an official or supported version of league of legends on linux) If someone else wants to contact me know that i'm open to jobs, part-time remote only as a employee and full time remote as a freelancer for a mission -https://github.com/user-attachments/assets/140df3e0-3790-46b6-bab5-578139b84a7c +A short manual ARAM run and the current compatibility status are documented in [docs/STATUS.md](docs/STATUS.md). Experimental source patches and tools for running the **macOS League of Legends client on x86-64 Linux through Darling**. @@ -140,6 +140,13 @@ docs/ setup, testing, status and licensing notes build/ local sources, outputs and rendered tools (ignored) ``` -Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files. This project is not affiliated with Riot Games. +Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files. + +## Trademarks + +League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. +This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or +sponsored by Riot Games. No game client, game assets, or account data are distributed in this +repository. From 57660f168d3e3f8b90169a48380f5915949cbfc4 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:47:59 -0600 Subject: [PATCH 27/54] flake.nix: mark the desktop entry and package as unofficial Keep the executable names and StartupWMClass; only the user-facing name, comment and descriptions change. --- flake.nix | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/flake.nix b/flake.nix index cb1b11f..8d418d8 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; + description = "Unofficial Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; @@ -229,12 +229,12 @@ cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop [Desktop Entry] Categories=Game; -Comment=Play League of Legends macOS client on Linux via Darling +Comment=Unofficial League of Legends macOS client compatibility layer for Linux via Darling Exec=darling-lol GenericName=MOBA Game Icon=darling-lol Keywords=League;LoL;Riot;Darling; -Name=League of Legends +Name=League of Legends (unofficial) StartupWMClass=RiotClientServices Terminal=true Type=Application @@ -245,7 +245,7 @@ DESKTOP_EOF ''; meta = with pkgs.lib; { - description = "Tooling and patches to run macOS League of Legends on Linux with Darling"; + description = "Unofficial tooling and patches to run macOS League of Legends on Linux with Darling"; homepage = "https://github.com/lucannez64/darling-lol"; license = licenses.gpl3Only; platforms = [ "x86_64-linux" ]; From 54edf6fca1800d4691acbf838f87d891a2e23db1 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 22:47:59 -0600 Subject: [PATCH 28/54] patches, tools, docs: use neutral integrity-check wording and state the compatibility scope The patch comments and the Mach-O strings filter named an anti-cheat product. Use neutral wording; the patches still differ only in comments. The platform-identity document now says the overrides are for compatibility, not a bypass of anti-cheat, telemetry, licensing or platform controls. --- docs/PLATFORM_IDENTITY.md | 3 ++- patches/darling.patch | 4 ++-- patches/xnu.patch | 2 +- tools/analyze-macho.sh | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 5f25849..f8be827 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -6,7 +6,8 @@ A prefix without saved identity files keeps the host-derived UUID and `DARLING-` serial; launching never generates an override. Import mode uses values supplied by the user. `--provision` generates synthetic identifiers; it does not issue authentic Apple identities or change CPU, GPU, or security -metadata. +metadata. The overrides are for application compatibility; they are not intended +to bypass anti-cheat, telemetry, licensing, or platform security controls. Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing `darling-smbios.conf` without changing the selected prefix: diff --git a/patches/darling.patch b/patches/darling.patch index ade6461..e0d6b94 100644 --- a/patches/darling.patch +++ b/patches/darling.patch @@ -3326,8 +3326,8 @@ index 8b92b6c50..18a57e359 100644 + * (kAudioUnitErr_InvalidProperty) made it throw its audio graph away and build a new one in a loop, so it never played + * anything. Accepting it harmless: the unit plays on the default device anyway. + * -+ * Only the game process gets this. With the property accepted everywhere, the League client's Vanguard module went to its -+ * error state (MvgState 5, status -101) on every launch (2 of 2, 2026-10-11) and the client could not start a match; ++ * Only the game process gets this. With the property accepted everywhere, the League client's integrity module went to its ++ * error state (state 5, status -101) on every launch (2 of 2, 2026-10-11) and the client could not start a match; + * with the old behaviour it connected again (state 0). */ +static bool callerIsLeagueGame() +{ diff --git a/patches/xnu.patch b/patches/xnu.patch index a02f9cd..2c8f6e2 100644 --- a/patches/xnu.patch +++ b/patches/xnu.patch @@ -5051,7 +5051,7 @@ index b998456..c6281e6 100644 + * (/tmp/rclol-local-semaphores, the former opt-in switch, is now a no-op). While it was on by default in every + * process (2026-10-10 22:22-23:50) LeagueClient died ~85 s after start in 3 of 4 checked launches + * ("mach_msg_overwrite failed -9", then dserver_rpc_interrupt_enter -9: its RPC descriptor had become invalid) and -+ * the user saw Vanguard errors; that mechanism is not understood, so the clients keep the server path. With the ++ * the client logged integrity-check errors; that mechanism is not understood, so the clients keep the server path. With the + * lifetime fixes above the game ran two full matches with it (2026-10-14: the main thread's server waits fell from + * 1.36 to 0.10 ms per frame, 39 to 15 blocks per frame). /tmp/rclol-local-semaphores-all turns it on for every + * process (probe tests). Decided once per process and logged diff --git a/tools/analyze-macho.sh b/tools/analyze-macho.sh index 8f8dd1d..d9a8ed1 100755 --- a/tools/analyze-macho.sh +++ b/tools/analyze-macho.sh @@ -10,5 +10,5 @@ OBJDUMP="$(command -v llvm-objdump || true)" file "$BIN" | tee "$OUT/file.txt" [ -n "$OTOOL" ] && { "$OTOOL" -hv "$BIN" > "$OUT/headers.txt" 2>&1; "$OTOOL" -L "$BIN" > "$OUT/linked-libs.txt" 2>&1; echo "wrote headers + linked-libs"; } || echo "no otool/llvm-otool found" [ -n "$OBJDUMP" ] && "$OBJDUMP" --macho --dylibs-used "$BIN" > "$OUT/dylibs.txt" 2>&1 || true -strings -a "$BIN" | grep -iE "Metal|MTL|Vanguard|vgc|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" +strings -a "$BIN" | grep -iE "Metal|MTL|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" echo "done -> $OUT/" From 75da66c1ea2fceb70e9a4f75750bde449621fdde Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Mon, 19 Oct 2026 23:06:47 -0600 Subject: [PATCH 29/54] docs: add scope and terminology guidance Add docs/COMPLIANCE.md with the project's compatibility scope: no DRM/anti-cheat circumvention, no third-party game assets, and neutral terminology for issues and PRs. Link it from README and NOTICE. --- NOTICE.md | 2 ++ README.md | 3 +++ docs/COMPLIANCE.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 docs/COMPLIANCE.md diff --git a/NOTICE.md b/NOTICE.md index f9cedba..759c3aa 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -11,3 +11,5 @@ Some compatibility tools refer to public API names and constants of the user's i League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or sponsored by Riot Games. + +See [compliance and terminology guidance](docs/COMPLIANCE.md) for scope and review expectations. diff --git a/README.md b/README.md index 5f34130..9176f73 100644 --- a/README.md +++ b/README.md @@ -149,4 +149,7 @@ This project is an unofficial compatibility effort and is not affiliated with, e sponsored by Riot Games. No game client, game assets, or account data are distributed in this repository. +See [compliance and terminology guidance](docs/COMPLIANCE.md) for the project's scope and +review expectations. + diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md new file mode 100644 index 0000000..7fe57b1 --- /dev/null +++ b/docs/COMPLIANCE.md @@ -0,0 +1,49 @@ +# Scope and compliance guidance + +This project is an unofficial compatibility layer for running the macOS League of Legends +client on Linux through Darling. It is not affiliated with, endorsed by, or sponsored by Riot +Games, Inc. + +## Scope + +- The project is about compatibility: making the client run, render and behave correctly on + Linux. +- It does not aim to circumvent DRM, anti-cheat, licensing, telemetry, or other platform + security controls, and it does not distribute a game client, game assets, or account data. +- Do not describe or present the project as a tool for evading detection, preventing bans, + bypassing DRM, or defeating anti-cheat. Do not promise that an account will not be banned. + +## Terminology + +Prefer accurate compatibility language: + +| Prefer | Avoid | +|---|---| +| compatibility issue, client check, platform identity | detection vector, stealth, evasion, bypass | +| hardware/platform reporting | spoofing, telemetry leak | +| runtime/launcher fix | anti-cheat bypass | + +Technical facts can be described precisely without framing the work as evasion. If a change +touches a client check or a platform identity, say what it does and why it is needed for +compatibility. + +## Assets and third-party material + +- Do not add or link third-party game assets: logos, icons, fonts, screenshots, gameplay + audio/video, client files, game data, or account data. +- Project icons and artwork must be original. Forks must use their own name and icon; see the + trademark notice in `README.md` and `NOTICE.md`. +- Do not commit local prefixes, logs, tokens, captures, or compiled client/runtime binaries. + +## Pull requests and issues + +- Describe the compatibility problem and the fix. Avoid "stealth", "spoof", "evade", + "undetectable", or "unbannable" framing. +- Maintainers may ask for wording changes before merging. This is about keeping the project's + scope accurate, not about hiding technical details. +- Contributions that add third-party assets or present the project as a DRM/anti-cheat + circumvention tool are out of scope for this repository. + +## Note + +This document describes project scope and review expectations. It is not legal advice. From b4cdd19f1b430ebd6a66fa9930e707775abb2baf Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:52:23 +0200 Subject: [PATCH 30/54] Fix Mach-O analysis with no matching strings --- tools/analyze-macho.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/analyze-macho.sh b/tools/analyze-macho.sh index d9a8ed1..22932fd 100755 --- a/tools/analyze-macho.sh +++ b/tools/analyze-macho.sh @@ -10,5 +10,5 @@ OBJDUMP="$(command -v llvm-objdump || true)" file "$BIN" | tee "$OUT/file.txt" [ -n "$OTOOL" ] && { "$OTOOL" -hv "$BIN" > "$OUT/headers.txt" 2>&1; "$OTOOL" -L "$BIN" > "$OUT/linked-libs.txt" 2>&1; echo "wrote headers + linked-libs"; } || echo "no otool/llvm-otool found" [ -n "$OBJDUMP" ] && "$OBJDUMP" --macho --dylibs-used "$BIN" > "$OUT/dylibs.txt" 2>&1 || true -strings -a "$BIN" | grep -iE "Metal|MTL|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" +strings -a "$BIN" | { grep -iE "Metal|MTL|CEF|Chromium|RiotClient|NSApplication|LSOpen|CFBundle" || test "$?" -eq 1; } | sort -u | head -100 | tee "$OUT/strings-of-interest.txt" echo "done -> $OUT/" From e727049d67c5ef92ec6e3e398c814774ebfc6181 Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:14:32 +0200 Subject: [PATCH 31/54] Fix Nix FHS text fonts and fontconfig aliases (#113) --- .github/workflows/source-check.yml | 6 +++ docs/NIX.md | 21 +++++++++ flake.nix | 73 ++++++++++++++++++++++++------ 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 20552db..c068e01 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -3,6 +3,12 @@ on: [push, pull_request] permissions: contents: read jobs: + nix-fonts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: cachix/install-nix-action@3c6342b75005b9cc7c7d4069c3e9179247c89012 # v31 + - run: nix build .#checks.x86_64-linux.fonts --no-link -L validate: runs-on: ubuntu-latest steps: diff --git a/docs/NIX.md b/docs/NIX.md index fe6e9e4..c831f34 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -207,6 +207,27 @@ If you start Darling manually in this shell, run `darling shutdown` there before exiting. A separately launched shell cannot reconnect to an existing Darling namespace; `darling-lol --stop` can still stop it. +### Text fonts + +The FHS environment bundles Liberation and DejaVu text fonts and uses its own +fontconfig configuration and standard aliases. It does not depend on fonts in +the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your +user font directories remain available. This also prevents an icon-only user +font inventory from becoming the only fallback for ordinary text. + +To check font availability without starting Darling or Riot: + +```sh +nix run .#default -- fc-match sans-serif +nix run .#default -- fc-match Arial +``` + +These should select DejaVu Sans and Liberation Sans respectively. The standalone +`nix build .#checks.x86_64-linux.fonts` check verifies generic and common Windows +font aliases with both an empty home directory and icon-only user fonts. This +checks font availability; CAPTCHA text rendering still needs manual verification +in Riot's UI. + ## Files and operational limits - Workspace: `${XDG_DATA_HOME:-$HOME/.local/share}/darling-lol`. diff --git a/flake.nix b/flake.nix index 8d418d8..de2c1af 100644 --- a/flake.nix +++ b/flake.nix @@ -12,6 +12,14 @@ pkgs = nixpkgs.legacyPackages.${system}; llvmPackages = pkgs.llvmPackages_21; + # Host /etc/fonts survives buildFHSEnv, but host /usr/share/fonts does + # not. Use store paths for both text fonts and the standard alias rules. + darlingFonts = [ pkgs.liberation_ttf pkgs.dejavu_fonts ]; + darlingFontsConf = pkgs.makeFontsConf { + fontDirectories = darlingFonts; + includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ]; + }; + # Python environment with dependencies required by tooling pythonEnv = pkgs.python3.withPackages ( ps: with ps; [ @@ -155,6 +163,8 @@ export DPREFIX="''${DPREFIX:-$HOME/.darling}" export LC_ALL=C.UTF-8 export LANG=C.UTF-8 + export FONTCONFIG_FILE=${darlingFontsConf} + export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts export DARLING_RUNNER_DIR=${darlingRunner}/bin export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" @@ -253,20 +263,55 @@ DESKTOP_EOF }; in { - checks.${system}.packaging = pkgs.runCommand "darling-lol-packaging-tests" { - nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ]; - } '' - cp -R ${darlingLol}/share/darling-lol source - chmod -R u+w source - cd source - export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1 - mkdir -p "$HOME" - # Tests use private stub binaries/repositories, never Darling itself. - python3 -m unittest discover -s tests -v - python3 scripts/validate.py - grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop - touch "$out" - ''; + checks.${system} = { + fonts = pkgs.runCommand "darling-lol-font-tests" { + nativeBuildInputs = [ pkgs.fontconfig ]; + } '' + export HOME="$TMPDIR/home" XDG_CONFIG_HOME="$TMPDIR/config" + export XDG_DATA_HOME="$TMPDIR/data" XDG_CACHE_HOME="$TMPDIR/cache" + export FONTCONFIG_FILE=${darlingFontsConf} + export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts + mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" + # Check real font selection with no host or user text fonts available. + check_font() { + actual=$(fc-match --format '%{family}' "$1") + if [ "$actual" != "$2" ]; then + echo "Font match for $1: expected $2, got $actual" >&2 + exit 1 + fi + test -r "$(fc-match --format '%{file}' "$1")" + } + check_aliases() { + check_font sans-serif 'DejaVu Sans' + check_font serif 'DejaVu Serif' + check_font monospace 'DejaVu Sans Mono' + check_font Arial 'Liberation Sans' + check_font 'Times New Roman' 'Liberation Serif' + check_font 'Courier New' 'Liberation Mono' + } + check_aliases + # Reproduce an icon-only user inventory without host text fonts. + cp -R ${pkgs.font-awesome}/share/fonts "$XDG_DATA_HOME/fonts" + fc-list --format '%{file}\n' | grep -F "$XDG_DATA_HOME/fonts/" + check_aliases + touch "$out" + ''; + + packaging = pkgs.runCommand "darling-lol-packaging-tests" { + nativeBuildInputs = [ pythonEnv pkgs.bash pkgs.git pkgs.coreutils pkgs.util-linux pkgs.gawk ]; + } '' + cp -R ${darlingLol}/share/darling-lol source + chmod -R u+w source + cd source + export HOME="$TMPDIR/home" PYTHONDONTWRITEBYTECODE=1 + mkdir -p "$HOME" + # Tests use private stub binaries/repositories, never Darling itself. + python3 -m unittest discover -s tests -v + python3 scripts/validate.py + grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop + touch "$out" + ''; + }; packages.${system} = { darling-lol = darlingLol; From 0a9a75e820c1b6ab631e567f642c6355d5707658 Mon Sep 17 00:00:00 2001 From: nb <40745505+nicolai455@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:24:10 +0200 Subject: [PATCH 32/54] fix(appkit): add NSSavePanel tag-name compatibility (#112) Co-authored-by: Kalah <58551020+lucannez64@users.noreply.github.com> --- docs/TESTING.md | 25 ++++ patches/cocotron.patch | 14 +- tests/test_save_panel_tags.py | 169 +++++++++++++++++++++++ tools/build-riot-save-panel-tag-check.py | 24 ++++ tools/riot-save-panel-tag-check.m | 74 ++++++++++ 5 files changed, 304 insertions(+), 2 deletions(-) create mode 100644 tests/test_save_panel_tags.py create mode 100755 tools/build-riot-save-panel-tag-check.py create mode 100644 tools/riot-save-panel-tag-check.m diff --git a/docs/TESTING.md b/docs/TESTING.md index ca9bd88..63cdd2a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -222,6 +222,31 @@ and prints `OPENPANEL_CHECK mode=interactive result=RETURNED code=... url=...` once it closes. As with the other probes, check the printed line rather than only the launcher exit status. +### NSSavePanel Finder tags (`setTagNames:` / `tagNames`) + +`python3 tools/build-riot-save-panel-tag-check.py` builds +`build/riot-save-panel-tag-check`. Install the patched AppKit first, with clients +and the game closed. The probe checks selector availability, inheritance, and +safe no-op semantics without launching any client or creating window fixtures: + +```sh +P=/Volumes/SystemRoot/path/to/darling-lol/build/riot-save-panel-tag-check +E="env DYLD_LIBRARY_PATH=/Users/me/RuntimeLibraries DYLD_FRAMEWORK_PATH=/Users/me/RuntimeFrameworks" +darling shell $E $P +``` + +Expected output: + +``` +SAVE_PANEL_TAG class=NSSavePanel selectors=1 inherited=1 values=1 pass=1 +SAVE_PANEL_TAG class=NSOpenPanel selectors=1 inherited=1 values=1 pass=1 +SAVE_PANEL_TAG_CHECK pass=1 +``` + +Cocotron does not persist Finder tag metadata or provide a tag UI. `setTagNames:` +safely ignores input and `tagNames` returns an empty `NSArray` to prevent +unrecognized-selector crashes from callers (such as Electron file dialogs). + ### Presentation publisher handoff With clients closed, run `build/riot-client-resize-check --handoff` through Darling with the normal runtime overrides. This creates an owned window and two fixture frame slots. The live software owner first supplies a 1280×720 main frame, then switches to a 262×131 popup; another slot supplies the resized main frame without the software flag. The test samples its own window beyond the original frame extent and checks generation colors across growth and shrinkage. The old selector reports black at the 1600×900 sample; the correction passes all four sizes. Repeat with `RCLOL_XSHM=0` and with `DARLING_XSHM_LIBRARY` pointing to the normal transport to cover both paths. The fixtures remove their own frame slots on exit; they do not read client pixels or send input. diff --git a/patches/cocotron.patch b/patches/cocotron.patch index cc6452b..ce31983 100644 --- a/patches/cocotron.patch +++ b/patches/cocotron.patch @@ -11408,7 +11408,7 @@ diff --git a/AppKit/NSSavePanel.subproj/NSSavePanel.m b/AppKit/NSSavePanel.subpr index 50c3d59e..be5a8463 100644 --- a/AppKit/NSSavePanel.subproj/NSSavePanel.m +++ b/AppKit/NSSavePanel.subproj/NSSavePanel.m -@@ -113,19 +113,108 @@ +@@ -113,19 +113,116 @@ } } @@ -11510,6 +11510,14 @@ index 50c3d59e..be5a8463 100644 + return NO; +} + ++- (void) setTagNames: (NSArray *) names { ++ (void)names; ++} ++ ++- (NSArray *) tagNames { ++ return [NSArray array]; ++} ++ +- (void) setExtensionHidden: (BOOL) flag { +} + @@ -11580,7 +11588,7 @@ diff --git a/AppKit/include/AppKit/NSSavePanel.h b/AppKit/include/AppKit/NSSaveP index 53bacc46..3c6adfb7 100644 --- a/AppKit/include/AppKit/NSSavePanel.h +++ b/AppKit/include/AppKit/NSSavePanel.h -@@ -88,6 +88,18 @@ enum { +@@ -88,6 +88,20 @@ enum { - (void) setPrompt: (NSString *) message; - (NSString *) prompt; @@ -11588,6 +11596,8 @@ index 53bacc46..3c6adfb7 100644 +- (NSString *) nameFieldLabel; +- (void) setShowsTagField: (BOOL) flag; +- (BOOL) showsTagField; ++- (void) setTagNames: (NSArray *) names; ++- (NSArray *) tagNames; +- (void) setExtensionHidden: (BOOL) flag; +- (BOOL) isExtensionHidden; +- (void) setCanSelectHiddenExtension: (BOOL) flag; diff --git a/tests/test_save_panel_tags.py b/tests/test_save_panel_tags.py new file mode 100644 index 0000000..3b16347 --- /dev/null +++ b/tests/test_save_panel_tags.py @@ -0,0 +1,169 @@ +"""Offline contracts for NSSavePanel's unsupported Finder-tag compatibility.""" +import json +from pathlib import Path +import re +import runpy +import shutil +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(__file__).resolve().parents[1] +IMPLEMENTATION = 'AppKit/NSSavePanel.subproj/NSSavePanel.m' +HEADER = 'AppKit/include/AppKit/NSSavePanel.h' +PROBE_SOURCE = 'tools/riot-save-panel-tag-check.m' +BUILD_HELPER = 'tools/build-riot-save-panel-tag-check.py' + + +def added_source(path): + marker = f'diff --git a/{path} b/{path}\n' + section = (ROOT / 'patches/cocotron.patch').read_text().split(marker, 1)[1] + section = section.split('\ndiff --git ', 1)[0] + return '\n'.join(line[1:] for line in section.splitlines() + if line.startswith('+') and not line.startswith('+++')) + + +def extract_patch(path): + marker = f'diff --git a/{path} b/{path}\n' + patch_text = (ROOT / 'patches/cocotron.patch').read_text() + if marker not in patch_text: + raise AssertionError(f'Missing patch section for {path}') + section = patch_text.split(marker, 1)[1].split('\ndiff --git ', 1)[0] + return marker + section + '\n' + + +def method(source, signature): + match = re.search(re.escape(signature) + r'\s*\{([^}]+)\}', source) + if match is None: + raise AssertionError(f'Missing implementation: {signature}') + return match.group(1) + + +class SavePanelTagTests(unittest.TestCase): + def test_tag_names_setter_accepts_unsupported_metadata_without_storing_it(self): + body = method(added_source(IMPLEMENTATION), '- (void) setTagNames: (NSArray *) names') + self.assertIn('(void)names;', body) + self.assertNotIn('retain', body) + self.assertNotIn('copy', body) + + def test_tag_names_getter_returns_an_empty_array_not_nil(self): + body = method(added_source(IMPLEMENTATION), '- (NSArray *) tagNames') + self.assertIn('return [NSArray array];', body) + + def test_public_header_declares_both_tag_name_methods(self): + header = added_source(HEADER) + self.assertIn('- (void) setTagNames: (NSArray *) names;', header) + self.assertIn('- (NSArray *) tagNames;', header) + + def test_existing_tag_field_and_modal_fixes_are_retained(self): + source = added_source(IMPLEMENTATION) + self.assertIn('- (void) setShowsTagField: (BOOL) flag', source) + self.assertIn('- (BOOL) showsTagField', source) + self.assertIn('- (void) _dismissWithCode: (NSModalResponse) code', source) + self.assertNotIn('setShowsTags:', source) + + def test_patch_applies_cleanly_to_pinned_upstream_cocotron(self): + if not shutil.which('patch'): + self.skipTest('patch executable required') + manifest = json.loads((ROOT / 'upstream/manifest.json').read_text()) + component = next(c for c in manifest['components'] if c['patch'] == 'patches/cocotron.patch') + repo = ROOT / 'build/darling-src' / component['path'] + if not (repo / '.git').exists(): + self.skipTest('prepared Darling sources required') + + revision = component['revision'] + for relpath in [IMPLEMENTATION, HEADER]: + filename = Path(relpath).name + original = subprocess.check_output(['git', '-C', str(repo), 'show', f'{revision}:{relpath}']) + with tempfile.TemporaryDirectory() as td: + work_file = Path(td) / filename + work_file.write_bytes(original) + patch_file = Path(td) / f'{filename}.patch' + patch_file.write_text(extract_patch(relpath)) + res = subprocess.run( + ['patch', '--dry-run', '--fuzz=0', str(work_file), str(patch_file)], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, 0, f'Patch failed to apply to {relpath}: {res.stderr}\n{res.stdout}') + + def test_extracted_method_bodies_compile_with_clang(self): + clang = shutil.which('clang') + if not clang: + self.skipTest('clang not available') + impl = added_source(IMPLEMENTATION) + setter_body = method(impl, '- (void) setTagNames: (NSArray *) names') + getter_body = method(impl, '- (NSArray *) tagNames') + snippet = f''' +@interface NSObject ++ (instancetype)alloc; +- (instancetype)init; +@end + +@interface NSArray : NSObject ++ (instancetype)array; +@end + +@interface NSSavePanel : NSObject +- (void) setTagNames: (NSArray *) names; +- (NSArray *) tagNames; +@end + +@implementation NSSavePanel +- (void) setTagNames: (NSArray *) names {{{setter_body}}} +- (NSArray *) tagNames {{{getter_body}}} +@end +''' + with tempfile.NamedTemporaryFile(suffix='.m', mode='w') as f: + f.write(snippet) + f.flush() + res = subprocess.run( + [clang, '-Qunused-arguments', '-x', 'objective-c', '-fsyntax-only', '-Werror', f.name], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, 0, f'Clang syntax check failed: {res.stderr}') + + def test_builder_compiles_and_links_probe_without_embedding_shim(self): + source = ROOT / BUILD_HELPER + self.assertTrue(source.exists(), f'Missing build helper: {BUILD_HELPER}') + with tempfile.TemporaryDirectory() as temporary: + project = Path(temporary) + driver = project / 'tools' / source.name + driver.parent.mkdir() + driver.write_text(source.read_text()) + framework = project / 'build/darling-src/build/src/external/cocotron/AppKit' + cmake = framework / 'CMakeFiles/X11_backend.dir' + cmake.mkdir(parents=True) + (cmake / 'flags.make').write_text('C_DEFINES = -DTEST\nC_INCLUDES = -Itest\nC_FLAGS = -O0\n') + calls = [] + with patch('subprocess.run', side_effect=lambda argv, **kw: calls.append((argv, kw))): + runpy.run_path(str(driver), run_name='__main__') + probe_name = 'riot-save-panel-tag-check' + compiles = [argv for argv, _ in calls if str(project / f'tools/{probe_name}.m') in argv] + self.assertEqual(len(compiles), 1, 'probe must be compiled') + self.assertEqual(compiles[0][compiles[0].index('-o') + 1], str(project / f'build/{probe_name}.o')) + links = [argv for argv, _ in calls if str(project / f'build/{probe_name}') in argv] + self.assertEqual(len(links), 1, 'probe must be linked') + self.assertIn(str(project / f'build/{probe_name}.o'), links[0]) + self.assertIn('AppKit', links[0]) + for argv, _ in calls: + for arg in argv: + self.assertNotIn('compat', arg) + + def test_probe_declares_only_and_implements_no_shim(self): + source = (ROOT / PROBE_SOURCE).read_text() + self.assertIn('@interface NSSavePanel (RiotTagProbeDeclarations)', source) + self.assertIn('- (void)setTagNames:(NSArray *)names;', source) + self.assertIn('- (NSArray *)tagNames;', source) + self.assertIn('- (void)setShowsTagField:(BOOL)value;', source) + self.assertIn('- (BOOL)showsTagField;', source) + self.assertNotIn('@implementation NSSavePanel', source, 'probe must not contain compatibility shim') + self.assertIn('checkPanel([NSSavePanel class])', source) + self.assertIn('checkPanel([NSOpenPanel class])', source) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/build-riot-save-panel-tag-check.py b/tools/build-riot-save-panel-tag-check.py new file mode 100755 index 0000000..cc1c4d8 --- /dev/null +++ b/tools/build-riot-save-panel-tag-check.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the standalone NSSavePanel Finder-tag compatibility check.""" +from pathlib import Path +import json +import shlex +import subprocess + +root = Path(__file__).resolve().parent.parent +folder = root / 'build/darling-src/build/src/external/cocotron/AppKit' +config = root / 'build/config.json' +sdk = json.loads(config.read_text())['sdk'] if config.exists() else '/usr/local/libexec/darling' +flags = {} +for line in (folder / 'CMakeFiles/X11_backend.dir/flags.make').read_text().splitlines(): + if line.startswith('C_') and ' = ' in line: + key, value = line.split(' = ', 1) + flags[key] = shlex.split(value) +obj = root / 'build/riot-save-panel-tag-check.o' +subprocess.run(['clang', *flags['C_DEFINES'], *flags['C_INCLUDES'], *flags['C_FLAGS'], + '-O2', '-fblocks', '-UNDEBUG', '-Wno-macro-redefined', '-c', str(root / 'tools/riot-save-panel-tag-check.m'), + '-o', str(obj)], check=True) +subprocess.run(['clang', '-target', 'x86_64-apple-macosx11.0', '-fuse-ld=lld', '-nostdlib', + '-isysroot', sdk, str(obj), '-lSystem', '-lobjc', + '-framework', 'AppKit', '-framework', 'CoreFoundation', '-framework', 'Foundation', + '-o', str(root / 'build/riot-save-panel-tag-check')], check=True) diff --git a/tools/riot-save-panel-tag-check.m b/tools/riot-save-panel-tag-check.m new file mode 100644 index 0000000..ba313db --- /dev/null +++ b/tools/riot-save-panel-tag-check.m @@ -0,0 +1,74 @@ +#import +#import +#import +#import +#import +#include + +/* Declarations only: implementations must come from the loaded AppKit, not + * from linking a compatibility category directly into this probe. */ +@interface NSSavePanel (RiotTagProbeDeclarations) +- (void)setShowsTagField:(BOOL)value; +- (BOOL)showsTagField; +- (void)setTagNames:(NSArray *)names; +- (NSArray *)tagNames; +@end + +static BOOL emptyTags(NSSavePanel *panel) { + NSArray *tags = [panel tagNames]; + return tags != nil && [tags isKindOfClass:[NSArray class]] && [tags count] == 0; +} + +static BOOL checkPanel(Class panelClass) { + SEL selectors[] = {@selector(setShowsTagField:), @selector(showsTagField), + @selector(setTagNames:), @selector(tagNames)}; + BOOL available = YES, inherited = YES, values = NO; + /* This stateless probe must work without panel initialization. Bare runtime + * allocation avoids NSWindow/nib/display setup; it does NOT test the chooser. + * Dispose without running NSWindow's deallocator on an uninitialized panel. */ + NSSavePanel *panel = (NSSavePanel *)class_createInstance(panelClass, 0); + for (unsigned i = 0; i < sizeof(selectors) / sizeof(selectors[0]); ++i) { + Method base = class_getInstanceMethod([NSSavePanel class], selectors[i]); + Method method = class_getInstanceMethod(panelClass, selectors[i]); + available &= panel != nil && method != NULL && [panel respondsToSelector:selectors[i]]; + inherited &= base != NULL && method != NULL && + method_getImplementation(base) == method_getImplementation(method); + } + if (panelClass == [NSOpenPanel class]) + inherited &= class_getSuperclass(panelClass) == [NSSavePanel class]; + if (available) { + @try { + values = ![panel showsTagField] && emptyTags(panel); + [panel setShowsTagField:YES]; + values &= ![panel showsTagField]; + [panel setShowsTagField:NO]; + values &= ![panel showsTagField]; + NSMutableArray *names = [NSMutableArray arrayWithObject:@"probe-tag"]; + [panel setTagNames:names]; + [names addObject:@"second-tag"]; + values &= emptyTags(panel); + [panel setTagNames:[NSArray array]]; + values &= emptyTags(panel); + [panel setTagNames:nil]; + values &= emptyTags(panel); + } @catch (NSException *exception) { + fprintf(stderr, "SAVE_PANEL_TAG exception=%s\n", [[exception name] UTF8String]); + values = NO; + } + } + if (panel != nil) + object_dispose(panel); + BOOL pass = available && inherited && values; + printf("SAVE_PANEL_TAG class=%s selectors=%d inherited=%d values=%d pass=%d\n", + class_getName(panelClass), available, inherited, values, pass); + return pass; +} + +int main(void) { + @autoreleasepool { + BOOL save = checkPanel([NSSavePanel class]); + BOOL open = checkPanel([NSOpenPanel class]); + printf("SAVE_PANEL_TAG_CHECK pass=%d\n", save && open); + return save && open ? 0 : 1; + } +} From 0d8da8889409ec483c334543c8ccc66d637fc2f5 Mon Sep 17 00:00:00 2001 From: nb <40745505+nicolai455@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:02:29 +0200 Subject: [PATCH 33/54] fix: preserve ambiguous guest processes during prefix cleanup (#111) * fix: preserve ambiguous guest processes during prefix cleanup * fix: warn only for matching runtime cleanup candidates --------- Co-authored-by: lucannez64 <58551020+lucannez64@users.noreply.github.com> --- docs/TROUBLESHOOTING.md | 2 +- tests/test_runtime_cleanup_scope.py | 151 ++++++++++++++++++++ tools/ensure-darling-runtime.py | 57 ++++---- tools/riot-runtime-cleanup-offline-check.py | 7 +- 4 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 tests/test_runtime_cleanup_scope.py diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 15ffb7b..13a2f7b 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -376,7 +376,7 @@ It prints each thread-local access that calls its thunk without the descriptor i ## Base install refuses to continue while Darling is not running -`sudo cmake --install` prints `Darling currently running for , shutting it down...` several times and then `Darling launchd PID … remains alive after shutdown`. A guest launchd outlived its darlingserver, so `darling shutdown` sees no container to stop. `python3 build/runtime/tools/ensure-darling-runtime.py` stops such a launchd (your own, only when no darlingserver runs); `scripts/run.sh` and `scripts/install-runtime.sh` run it too. +`sudo cmake --install` prints `Darling currently running for , shutting it down...` several times and then `Darling launchd PID … remains alive after shutdown`. A guest launchd outlived its darlingserver, so `darling shutdown` sees no container to stop. Automatic cleanup by process name is disabled because guest names and UIDs do not prove prefix ownership (killing by name risks terminating an active launchd from an unrelated prefix or session). If setup is blocked, shut down the original Darling session from its own namespace (`darling shutdown`), or inspect the leftover PID under `/proc/` before terminating it manually. ## Lower fight FPS than Windows diff --git a/tests/test_runtime_cleanup_scope.py b/tests/test_runtime_cleanup_scope.py new file mode 100644 index 0000000..afc76f8 --- /dev/null +++ b/tests/test_runtime_cleanup_scope.py @@ -0,0 +1,151 @@ +"""Cleanup scope regressions: fake proc/prefix trees and mocked signals only.""" +import contextlib +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import MagicMock, Mock, patch + + +SCRIPT = Path(__file__).resolve().parents[1] / 'tools/ensure-darling-runtime.py' + + +class RuntimeCleanupScopeTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix='cleanup-scope-') + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / 'selected prefix' + self.other = self.root / 'unrelated prefix' + self.shm = self.root / 'shm' + for folder in (self.prefix / 'private/tmp', self.other / 'private/tmp', self.shm): + folder.mkdir(parents=True) + # Guard signals before import; no real process may ever be signalled. + self.kill = self.enterContext(patch('os.kill')) + spec = importlib.util.spec_from_file_location('cleanup_scope', SCRIPT) + assert spec is not None and spec.loader is not None + self.cleanup = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.cleanup) + self.stderr = self.enterContext(contextlib.redirect_stderr(io.StringIO())) + self.enterContext(contextlib.redirect_stdout(io.StringIO())) + self.enterContext(patch.dict(os.environ, {'DPREFIX': str(self.prefix)})) + self.enterContext(patch.object(self.cleanup, 'Path', side_effect=self.fake_path)) + # Its default Path('/proc') is bound at definition time, before patching Path. + running = self.cleanup.any_server_running + self.enterContext(patch.object(self.cleanup, 'any_server_running', + side_effect=lambda proc_root=None: running( + self.proc if proc_root is None else proc_root))) + + def fake_path(self, value): + # Never inspect host /proc or remove host shared-memory files. + return {'/proc': self.proc, '/dev/shm': self.shm}.get(str(value), Path(value)) + + def process(self, pid, *, comm='launchd', argv=('/sbin/launchd',), state='S'): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'comm').write_text(comm + '\n') + (folder / 'cmdline').write_bytes(b'\0'.join(arg.encode() for arg in argv) + b'\0') + (folder / 'status').write_text(f'State:\t{state} (fixture)\n') + return folder + + def test_direct_cleanup_entry_points_preserve_ambiguous_processes(self): + self.process(101) + self.process(102, comm='chrome_crashpad', argv=( + '/Applications/Riot Client.app/Contents/Frameworks/chrome_crashpad_handler',)) + self.cleanup.stop_orphaned_launchd(self.proc) + self.cleanup.stop_riot_crashpad(self.proc) + self.kill.assert_not_called() + self.assertIn('prefix ownership', self.stderr.getvalue()) + self.assertIn('launchd candidate PIDs 101', self.stderr.getvalue()) + self.assertIn('Riot crashpad candidate PIDs 102', self.stderr.getvalue()) + + def test_clean_start_is_silent(self): + self.cleanup.main() + self.assertEqual(self.stderr.getvalue(), '') + self.kill.assert_not_called() + + def test_unrelated_processes_are_silent(self): + self.process(110, comm='chrome_crashpad', argv=( + '/opt/google/chrome/chrome_crashpad_handler', '--annotation=riot')) + self.process(111, comm='unrelated', argv=('/usr/bin/tool', '/sbin/launchd')) + self.process(112, comm='unrelated', argv=('/usr/bin/tool', + '/Applications/Riot Client.app/chrome_crashpad_handler')) + self.cleanup.main() + self.assertEqual(self.stderr.getvalue(), '') + self.kill.assert_not_called() + + def test_foreign_owned_candidates_are_silent(self): + self.process(113) + self.process(114, comm='chrome_crashpad', argv=( + '/Applications/Riot Client.app/chrome_crashpad_handler',)) + with patch.object(Path, 'stat', return_value=Mock(st_uid=os.getuid() + 1)): + self.cleanup.stop_orphaned_launchd(self.proc) + self.cleanup.stop_riot_crashpad(self.proc) + self.assertEqual(self.stderr.getvalue(), '') + self.kill.assert_not_called() + + def test_main_preserves_processes_from_another_prefix_without_a_server(self): + self.process(101) + self.process(102, comm='chrome_crashpad', argv=( + str(self.other / 'Applications/Riot Client.app/crashpad_handler'),)) + selected = self.prefix / 'private/tmp/.com.riotgames.selected' + selected.write_text('stale') + unrelated = self.other / 'private/tmp/.com.riotgames.other' + unrelated.write_text('preserve') + shared = self.shm / 'darling-buffer-fixture' + shared.write_text('stale') + self.cleanup.main() + self.kill.assert_not_called() + self.assertFalse(selected.exists()) + self.assertFalse(shared.exists()) + self.assertEqual(unrelated.read_text(), 'preserve') + + def test_any_live_server_preserves_locks_and_shared_buffers(self): + self.process(103, comm='darlingserver', argv=('darlingserver',)) + lock = self.prefix / 'private/tmp/.com.riotgames.lock' + lock.write_text('active') + shared = self.shm / 'darling-buffer-fixture' + shared.write_text('active') + self.cleanup.main() + self.kill.assert_not_called() + self.assertTrue(lock.exists()) + self.assertTrue(shared.exists()) + + def test_unreadable_process_list_fails_closed(self): + hidden = MagicMock() + hidden.name = '104' + (hidden / 'comm').read_text.side_effect = PermissionError('hidden') + proc = Mock() + proc.iterdir.return_value = [hidden] + self.assertTrue(self.cleanup.any_server_running(proc)) + self.kill.assert_not_called() + + def test_dead_marker_is_recovered_only_in_the_selected_prefix(self): + self.process(105, state='Z') + marker = self.prefix / '.init.pid' + marker.write_text('105') + other = self.other / '.init.pid' + other.write_text('105') + self.cleanup.main() + self.assertFalse(marker.exists()) + recovered = list(self.prefix.glob('.init.pid.stale-105-*')) + self.assertEqual(len(recovered), 1) + self.assertEqual(recovered[0].read_text(), '105') + self.assertEqual(other.read_text(), '105') + self.kill.assert_not_called() + + def test_live_marker_is_not_replaced(self): + self.process(106) + marker = self.prefix / '.init.pid' + marker.write_text('106') + self.cleanup.main() + self.assertEqual(marker.read_text(), '106') + self.kill.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/ensure-darling-runtime.py b/tools/ensure-darling-runtime.py index 5e131b4..391c13b 100644 --- a/tools/ensure-darling-runtime.py +++ b/tools/ensure-darling-runtime.py @@ -2,7 +2,7 @@ """Clear a dead Darling server's stale PID marker, and stale shared buffers once no server runs.""" import os from pathlib import Path -import signal +import sys import time def recover_marker(prefix): marker = prefix / '.init.pid' @@ -38,39 +38,46 @@ def any_server_running(proc_root=Path('/proc')): pass return False -def stop_orphaned_launchd(proc_root=Path('/proc')): - """A guest launchd that outlived its darlingserver cannot be shut down with `darling shutdown` - and makes the base install (tools/shutdown-user.sh) refuse to continue.""" +def _matching_pids(kind, proc_root): + """Find same-user candidates by guest executable; this does not establish ownership.""" uid = os.getuid() for proc in proc_root.iterdir(): if not proc.name.isdigit(): continue try: - if proc.stat().st_uid != uid or (proc / 'cmdline').read_bytes().rstrip(b'\0') != b'/sbin/launchd': + if proc.stat().st_uid != uid: continue - os.kill(int(proc.name), signal.SIGKILL) - print(f'Stopped orphaned Darling launchd PID {proc.name}', flush=True) + argv = (proc / 'cmdline').read_bytes() + if kind == 'launchd': + matches = argv.rstrip(b'\0') == b'/sbin/launchd' + else: + executable = argv.split(b'\0', 1)[0].decode(errors='replace') + matches = (proc / 'comm').read_text().startswith('chrome_crashpad') and any( + bundle in executable for bundle in ( + '/Riot Client.app/', '/League of Legends.app/', + '/League of Legends (TM) Client.app/')) + if matches: + yield proc.name except (OSError, ValueError): - pass + continue + + +def stop_orphaned_launchd(proc_root=Path('/proc')): + """Warn about launchd candidates without assuming prefix ownership or signalling them.""" + pids = list(_matching_pids('launchd', proc_root)) + if pids: + print(f'Preserving launchd candidate PIDs {", ".join(pids)}: prefix ownership ' + 'cannot be proved. If setup is blocked, shut down the original Darling ' + 'session or inspect the leftover processes manually.', file=sys.stderr, flush=True) + def stop_riot_crashpad(proc_root=Path('/proc')): - """A Riot crash handler outliving its server holds the old session's ports; spare every other app's.""" - uid = os.getuid() - for proc in proc_root.iterdir(): - if not proc.name.isdigit(): - continue - try: - if not (proc / 'comm').read_text().startswith('chrome_crashpad') or proc.stat().st_uid != uid: - continue - argv = (proc / 'cmdline').read_bytes().split(b'\0') - executable = argv[0].decode(errors='replace') - # Match the executable's bundle, not arbitrary arguments (a browser may mention Riot). - if any(bundle in executable for bundle in ( - '/Riot Client.app/', '/League of Legends.app/', '/League of Legends (TM) Client.app/' - )): - os.kill(int(proc.name), signal.SIGTERM) - except (OSError, ValueError): - pass + """Warn about Riot crashpad candidates without assuming prefix ownership or signalling them.""" + pids = list(_matching_pids('crashpad', proc_root)) + if pids: + print(f'Preserving Riot crashpad candidate PIDs {", ".join(pids)}: prefix ownership ' + 'cannot be proved. Use the original session to shut down leftover helpers.', + file=sys.stderr, flush=True) def remove_owned(paths): uid = os.getuid() diff --git a/tools/riot-runtime-cleanup-offline-check.py b/tools/riot-runtime-cleanup-offline-check.py index f9bc9fa..5d5eac4 100644 --- a/tools/riot-runtime-cleanup-offline-check.py +++ b/tools/riot-runtime-cleanup-offline-check.py @@ -25,10 +25,9 @@ with tempfile.TemporaryDirectory(prefix='darling-cleanup-check-') as folder: process(104, 'unrelated', ['/usr/bin/unrelated', '/sbin/launchd']) with patch.object(cleanup.os, 'kill') as kill: cleanup.stop_riot_crashpad(root) - assert kill.call_args_list == [((102, cleanup.signal.SIGTERM),)] - kill.reset_mock() + kill.assert_not_called() cleanup.stop_orphaned_launchd(root) - assert kill.call_args_list == [((103, cleanup.signal.SIGKILL),)] + kill.assert_not_called() assert not cleanup.any_server_running(root) process(105, 'darlingserver', ['darlingserver', '--prefix=another-prefix']) assert cleanup.any_server_running(root) @@ -58,4 +57,4 @@ with tempfile.TemporaryDirectory(prefix='darling-cleanup-check-') as folder: crashpad.assert_not_called() orphan.assert_not_called() remove.assert_not_called() -print('RUNTIME_CLEANUP_OFFLINE pass=1 browser_preserved=1 other_prefix_preserved=1 foreign_owner_preserved=1 hidden_process_preserved=1 orphan_recovered=1') +print('RUNTIME_CLEANUP_OFFLINE pass=1 browser_preserved=1 other_prefix_preserved=1 foreign_owner_preserved=1 hidden_process_preserved=1 orphan_preserved=1') From a089a23072796e7f71fd61bea1b8da40b4cb8fbd Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Tue, 20 Oct 2026 13:31:02 +0200 Subject: [PATCH 34/54] fix(nix): harden fontconfig compatibility across host setups --- docs/NIX.md | 6 +++ flake.nix | 34 +++++++++++--- tests/test_nix_fonts.py | 95 +++++++++++++++++++++++++++++++++++++++ tests/test_nix_repairs.py | 2 +- 4 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 tests/test_nix_fonts.py diff --git a/docs/NIX.md b/docs/NIX.md index c831f34..05910c8 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -215,6 +215,12 @@ the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your user font directories remain available. This also prevents an icon-only user font inventory from becoming the only fallback for ordinary text. +Inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides are cleared inside +the FHS environment so foreign include paths or an invalid sysroot cannot hide +the packaged fonts. Custom XDG font/configuration directories and legacy +`~/.fonts` remain supported, including paths with spaces. The host configuration +and user font files are not modified. + To check font availability without starting Darling or Riot: ```sh diff --git a/flake.nix b/flake.nix index de2c1af..f071941 100644 --- a/flake.nix +++ b/flake.nix @@ -17,7 +17,14 @@ darlingFonts = [ pkgs.liberation_ttf pkgs.dejavu_fonts ]; darlingFontsConf = pkgs.makeFontsConf { fontDirectories = darlingFonts; - includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" ]; + includes = [ + "${pkgs.fontconfig.out}/etc/fonts/conf.d" + (pkgs.writeText "darling-user-fonts.conf" '' + + ~/.fonts + + '') + ]; }; # Python environment with dependencies required by tooling @@ -163,8 +170,8 @@ export DPREFIX="''${DPREFIX:-$HOME/.darling}" export LC_ALL=C.UTF-8 export LANG=C.UTF-8 + unset FONTCONFIG_PATH FONTCONFIG_SYSROOT export FONTCONFIG_FILE=${darlingFontsConf} - export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts export DARLING_RUNNER_DIR=${darlingRunner}/bin export PATH="$DARLING_RUNNER_DIR:/usr/local/bin:''${PATH:-}" export PKG_CONFIG_PATH="/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/pkgconfig:/usr/lib64/pkgconfig:/usr/share/pkgconfig:''${PKG_CONFIG_PATH:-}" @@ -267,10 +274,10 @@ DESKTOP_EOF fonts = pkgs.runCommand "darling-lol-font-tests" { nativeBuildInputs = [ pkgs.fontconfig ]; } '' - export HOME="$TMPDIR/home" XDG_CONFIG_HOME="$TMPDIR/config" - export XDG_DATA_HOME="$TMPDIR/data" XDG_CACHE_HOME="$TMPDIR/cache" + export HOME="$TMPDIR/home with spaces" XDG_CONFIG_HOME="$TMPDIR/config with spaces" + export XDG_DATA_HOME="$TMPDIR/data with spaces" XDG_CACHE_HOME="$TMPDIR/cache with spaces" + unset FONTCONFIG_PATH FONTCONFIG_SYSROOT export FONTCONFIG_FILE=${darlingFontsConf} - export FONTCONFIG_PATH=${pkgs.fontconfig.out}/etc/fonts mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME" # Check real font selection with no host or user text fonts available. check_font() { @@ -294,6 +301,23 @@ DESKTOP_EOF cp -R ${pkgs.font-awesome}/share/fonts "$XDG_DATA_HOME/fonts" fc-list --format '%{file}\n' | grep -F "$XDG_DATA_HOME/fonts/" check_aliases + + # Retain custom XDG and legacy fonts as well as user alias rules. + chmod u+w "$XDG_DATA_HOME/fonts" + mkdir -p "$HOME/.fonts" "$XDG_CONFIG_HOME/fontconfig" + cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSans-Regular.ttf "$XDG_DATA_HOME/fonts/user-text.ttf" + cp ${pkgs.liberation_ttf}/share/fonts/truetype/LiberationSerif-Regular.ttf "$HOME/.fonts/legacy-text.ttf" + fc-list --format '%{file}\n' | grep -Fx "$XDG_DATA_HOME/fonts/user-text.ttf" + fc-list --format '%{file}\n' | grep -Fx "$HOME/.fonts/legacy-text.ttf" + cat > "$XDG_CONFIG_HOME/fontconfig/fonts.conf" <<'USERCONF_EOF' + + + DarlingCompatibilityAlias + Liberation Serif + + + USERCONF_EOF + check_font DarlingCompatibilityAlias 'Liberation Serif' touch "$out" ''; diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py new file mode 100644 index 0000000..0f084f1 --- /dev/null +++ b/tests/test_nix_fonts.py @@ -0,0 +1,95 @@ +import json +import os +import re +import subprocess +import sys +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +def extract_fhs_profile(flake_text: str) -> str: + match = re.search(r"profile\s*=\s*''(.*?)'';", flake_text, re.DOTALL) + if not match: + raise ValueError("Could not find profile in flake.nix") + raw_profile = match.group(1) + # Nix multi-line strings: (?~/.fonts", flake_text) + + # FHS profile unsets foreign/sysroot fontconfig vars before exporting package FONTCONFIG_FILE + self.assertIn("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT", flake_text) + self.assertIn("export FONTCONFIG_FILE=${darlingFontsConf}", flake_text) + + unset_pos = flake_text.index("unset FONTCONFIG_PATH FONTCONFIG_SYSROOT") + export_pos = flake_text.index("export FONTCONFIG_FILE=${darlingFontsConf}") + self.assertLess(unset_pos, export_pos, "unset must happen before export FONTCONFIG_FILE") + + def test_fhs_profile_hostile_environment_sanitization(self): + flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") + profile_script = extract_fhs_profile(flake_text) + + hostile_envs = [ + { + "FONTCONFIG_SYSROOT": "/not/a/real/host/sysroot", + "FONTCONFIG_PATH": "/foreign/host/etc/fonts", + }, + { + "FONTCONFIG_SYSROOT": "", + "FONTCONFIG_PATH": "/foreign/host/etc/fonts", + }, + ] + + for hostile_font_env in hostile_envs: + with self.subTest(hostile_font_env=hostile_font_env): + test_env = dict(hostile_font_env) + test_env.update({ + "HOME": "/tmp/mock-home", + "XDG_DATA_HOME": "/tmp/mock-xdg data with spaces", + "XDG_CONFIG_HOME": "/tmp/mock-xdg config with spaces", + "UNRELATED_USER_VAR": "preserve_this_value", + "PATH": os.environ.get("PATH", "/bin:/usr/bin"), + }) + + dump_cmd = ( + f"{profile_script}\n" + f'"{sys.executable}" -c \'import os, json; print(json.dumps(dict(os.environ)))\'' + ) + + proc = subprocess.run( + ["bash", "-c", dump_cmd], + env=test_env, + capture_output=True, + text=True, + check=True, + ) + + resulting_env = json.loads(proc.stdout) + + self.assertNotIn("FONTCONFIG_SYSROOT", resulting_env) + self.assertNotIn("FONTCONFIG_PATH", resulting_env) + self.assertTrue( + resulting_env.get("FONTCONFIG_FILE", "").startswith("/nix/store/"), + f"Unexpected FONTCONFIG_FILE: {resulting_env.get('FONTCONFIG_FILE')}", + ) + self.assertEqual(resulting_env.get("XDG_DATA_HOME"), "/tmp/mock-xdg data with spaces") + self.assertEqual(resulting_env.get("XDG_CONFIG_HOME"), "/tmp/mock-xdg config with spaces") + self.assertEqual(resulting_env.get("UNRELATED_USER_VAR"), "preserve_this_value") + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index df02a09..6db36e9 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -453,7 +453,7 @@ class SessionTests(unittest.TestCase): self.assertIn('terminal', result.stderr.lower()) self.assertFalse((root / 'build').exists()) self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) - self.assertIn('checks.${system}.packaging', (ROOT / 'flake.nix').read_text()) + self.assertIn('checks.${system}', (ROOT / 'flake.nix').read_text()) def test_rootless_update_refuses_live_marker_without_build_or_stop(self): with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: From eb962b8c9f385c393a651c1dade4bcbf027830b1 Mon Sep 17 00:00:00 2001 From: Nicolai Bruns Date: Tue, 20 Oct 2026 13:43:22 +0200 Subject: [PATCH 35/54] docs(tests): document fontconfig regression helpers --- tests/test_nix_fonts.py | 5 +++++ tests/test_nix_repairs.py | 1 + 2 files changed, 6 insertions(+) diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py index 0f084f1..ac0e5ae 100644 --- a/tests/test_nix_fonts.py +++ b/tests/test_nix_fonts.py @@ -9,6 +9,7 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent def extract_fhs_profile(flake_text: str) -> str: + """Extract the FHS shell profile, substituting inert paths for Nix values.""" match = re.search(r"profile\s*=\s*''(.*?)'';", flake_text, re.DOTALL) if not match: raise ValueError("Could not find profile in flake.nix") @@ -19,7 +20,10 @@ def extract_fhs_profile(flake_text: str) -> str: return profile_script class NixFontsConfigTest(unittest.TestCase): + """Check packaged font configuration and isolation from host overrides.""" + def test_flake_font_configuration(self): + """Require bundled text fonts, user font support, and ordered sanitization.""" flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") # Flake must bundle both font families in darlingFontsConf @@ -41,6 +45,7 @@ class NixFontsConfigTest(unittest.TestCase): self.assertLess(unset_pos, export_pos, "unset must happen before export FONTCONFIG_FILE") def test_fhs_profile_hostile_environment_sanitization(self): + """Clear hostile fontconfig overrides while preserving user XDG settings.""" flake_text = (REPO_ROOT / "flake.nix").read_text(encoding="utf-8") profile_script = extract_fhs_profile(flake_text) diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 6db36e9..09ebb58 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -440,6 +440,7 @@ class SessionTests(unittest.TestCase): self.assertFalse((root / 'other-prefix').exists()) def test_first_run_eof_is_actionable_and_desktop_requests_terminal(self): + """Report missing terminal input without setup and package a terminal launcher.""" with tempfile.TemporaryDirectory(prefix='nix-session-test-') as directory: root = Path(directory) (root / 'scripts').mkdir() From c686798ddbf3ab11d72a997a1ee336f3d93a61b8 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:57:05 +0200 Subject: [PATCH 36/54] test(nix): run font compatibility regressions in CI --- .github/workflows/source-check.yml | 2 ++ docs/NIX.md | 7 ++++--- flake.nix | 2 ++ tests/test_nix_fonts.py | 6 +++--- tests/test_nix_repairs.py | 3 ++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index c068e01..3e91493 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -21,6 +21,8 @@ jobs: - run: python tools/riot-launcher-offline-check.py - run: python tools/riot-runtime-cleanup-offline-check.py - run: python tools/riot-cdn-dns-offline-check.py + - run: python -m unittest discover -s tests -p 'test_nix_fonts.py' + - run: python -m unittest discover -s tests -p 'test_nix_repairs.py' - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' diff --git a/docs/NIX.md b/docs/NIX.md index 05910c8..2cde500 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -215,9 +215,10 @@ the host's `/usr/share/fonts`, which the FHS environment replaces. Fonts in your user font directories remain available. This also prevents an icon-only user font inventory from becoming the only fallback for ordinary text. -Inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides are cleared inside -the FHS environment so foreign include paths or an invalid sysroot cannot hide -the packaged fonts. Custom XDG font/configuration directories and legacy +The FHS environment selects the packaged fontconfig configuration and clears +inherited `FONTCONFIG_PATH` and `FONTCONFIG_SYSROOT` overrides. Clearing the +sysroot prevents it from redirecting the packaged configuration and font paths. +Custom XDG font/configuration directories and legacy `~/.fonts` remain supported, including paths with spaces. The host configuration and user font files are not modified. diff --git a/flake.nix b/flake.nix index f071941..ef8a7b5 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,8 @@ fontDirectories = darlingFonts; includes = [ "${pkgs.fontconfig.out}/etc/fonts/conf.d" + # makeFontsConf adds XDG font directories, but neither it nor + # conf.d/50-user.conf adds the legacy ~/.fonts directory. (pkgs.writeText "darling-user-fonts.conf" '' ~/.fonts diff --git a/tests/test_nix_fonts.py b/tests/test_nix_fonts.py index ac0e5ae..4562268 100644 --- a/tests/test_nix_fonts.py +++ b/tests/test_nix_fonts.py @@ -88,9 +88,9 @@ class NixFontsConfigTest(unittest.TestCase): self.assertNotIn("FONTCONFIG_SYSROOT", resulting_env) self.assertNotIn("FONTCONFIG_PATH", resulting_env) - self.assertTrue( - resulting_env.get("FONTCONFIG_FILE", "").startswith("/nix/store/"), - f"Unexpected FONTCONFIG_FILE: {resulting_env.get('FONTCONFIG_FILE')}", + self.assertEqual( + resulting_env.get("FONTCONFIG_FILE"), + "/nix/store/mock-darlingFontsConf", ) self.assertEqual(resulting_env.get("XDG_DATA_HOME"), "/tmp/mock-xdg data with spaces") self.assertEqual(resulting_env.get("XDG_CONFIG_HOME"), "/tmp/mock-xdg config with spaces") diff --git a/tests/test_nix_repairs.py b/tests/test_nix_repairs.py index 09ebb58..49e232f 100644 --- a/tests/test_nix_repairs.py +++ b/tests/test_nix_repairs.py @@ -454,7 +454,8 @@ class SessionTests(unittest.TestCase): self.assertIn('terminal', result.stderr.lower()) self.assertFalse((root / 'build').exists()) self.assertIn('Terminal=true', (ROOT / 'flake.nix').read_text()) - self.assertIn('checks.${system}', (ROOT / 'flake.nix').read_text()) + self.assertIn('packaging = pkgs.runCommand "darling-lol-packaging-tests"', + (ROOT / 'flake.nix').read_text()) def test_rootless_update_refuses_live_marker_without_build_or_stop(self): with tempfile.TemporaryDirectory(prefix='nix-update-idle-') as directory: From e46890adb49ad9d3c240866d942ebe0e1a517b3a Mon Sep 17 00:00:00 2001 From: ekisu Date: Mon, 19 Oct 2026 19:19:55 -0300 Subject: [PATCH 37/54] perf(indium): cache buffer address keys in CPU memory --- patches/metal--deps--indium.patch | 49 ++++++- tests/fixtures/indium-address-shadow.cpp | 175 +++++++++++++++++++++++ tests/test_indium_address_shadow.py | 50 +++++++ 3 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/indium-address-shadow.cpp create mode 100644 tests/test_indium_address_shadow.py diff --git a/patches/metal--deps--indium.patch b/patches/metal--deps--indium.patch index f3c149f..f6c73e5 100644 --- a/patches/metal--deps--indium.patch +++ b/patches/metal--deps--indium.patch @@ -289,7 +289,7 @@ index b7ffcce..5b1e84c 100644 }; }; diff --git a/private-include/indium/command-encoder.private.hpp b/private-include/indium/command-encoder.private.hpp -index c3b1b93..3dab2ce 100644 +index c3b1b93..6b8783d 100644 --- a/private-include/indium/command-encoder.private.hpp +++ b/private-include/indium/command-encoder.private.hpp @@ -1,6 +1,7 @@ @@ -300,7 +300,7 @@ index c3b1b93..3dab2ce 100644 #include #include #include -@@ -17,117 +18,295 @@ +@@ -17,117 +18,330 @@ #include @@ -529,6 +529,40 @@ index c3b1b93..3dab2ce 100644 + struct AddressPool { + std::shared_ptr chunk; + size_t used = 0; ++ ++ // Two shader stages are live in the render encoder. Cache their small ++ // immutable tables in CPU memory, not in the GPU-mapped scratch chunk. ++ // This is only a read cache: misses retain the original byte comparison. ++ struct Shadow { ++ bool valid = false; ++ uint32_t offset; ++ VkDeviceSize range; ++ size_t count; ++ std::array addresses; ++ }; ++ std::array shadows; ++ size_t nextShadow = 0; ++ ++ bool sameAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) const { ++ for (const auto& shadow : shadows) { ++ // A table's last eight bytes can be padding. If a new layout ++ // reads that padding, only the mapped bytes define the old key. ++ if (shadow.valid && shadow.offset == offset && shadow.range == range && count <= shadow.count) ++ return !memcmp(shadow.addresses.data(), addresses, count * sizeof(uint64_t)); ++ } ++ return !memcmp(static_cast(chunk->contents()) + offset, addresses, count * sizeof(uint64_t)); ++ } ++ ++ void rememberAddresses(uint32_t offset, VkDeviceSize range, const uint64_t* addresses, size_t count) { ++ if (count > 64) return; ++ auto& shadow = shadows[nextShadow]; ++ nextShadow = (nextShadow + 1) % shadows.size(); ++ shadow.offset = offset; ++ shadow.range = range; ++ shadow.count = count; ++ memcpy(shadow.addresses.data(), addresses, count * sizeof(uint64_t)); ++ shadow.valid = true; ++ } + }; - if (functionResources.buffers.size() > 0) { @@ -583,17 +617,18 @@ index c3b1b93..3dab2ce 100644 + if (bindingReuseEnabled() && previous && addressPool.chunk && previous->range == tableSize && + previous->offset <= chunkSize && tableSize <= chunkSize - previous->offset && + previous->buffer == static_cast(addressPool.chunk.get())->buffer() && -+ !memcmp(static_cast(addressPool.chunk->contents()) + previous->offset, -+ addresses.data(), addresses.size() * sizeof(uint64_t))) ++ addressPool.sameAddresses(previous->offset, previous->range, addresses.data(), addresses.size())) + return *previous; + size_t tableOffset = (addressPool.used + alignment - 1) & ~(alignment - 1); + if (!addressPool.chunk || tableOffset + tableSize > chunkSize) { + addressPool.chunk = privateDevice->acquireScratchChunk(); ++ for (auto& shadow : addressPool.shadows) shadow.valid = false; + // we need to keep the chunk alive until the operation is completed + keepAliveBuffers.push_back(addressPool.chunk); + tableOffset = 0; + } + memcpy(static_cast(addressPool.chunk->contents()) + tableOffset, addresses.data(), addresses.size() * sizeof(uint64_t)); ++ if (previous) addressPool.rememberAddresses(static_cast(tableOffset), tableSize, addresses.data(), addresses.size()); + addressPool.used = tableOffset + tableSize; + // plain pointer: a shared_ptr cast here was an atomic reference count round trip per stage per draw + auto* privateAddressBuffer = static_cast(addressPool.chunk.get()); @@ -654,7 +689,7 @@ index c3b1b93..3dab2ce 100644 descSet.descriptorCount = 1; descSet.pBufferInfo = &info; } -@@ -136,17 +315,27 @@ namespace Indium { +@@ -136,17 +350,27 @@ namespace Indium { auto& bindingInfo = funcInfo.bindings[j]; if (bindingInfo.type == Iridium::BindingType::Texture) { @@ -691,7 +726,7 @@ index c3b1b93..3dab2ce 100644 auto& descSet = writeDescSet.emplace_back(); descSet.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; descSet.dstSet = descriptorSets[i]; -@@ -156,19 +345,21 @@ namespace Indium { +@@ -156,19 +380,21 @@ namespace Indium { descSet.descriptorCount = 1; descSet.pImageInfo = &info; } else if (bindingInfo.type == Iridium::BindingType::Sampler) { @@ -724,7 +759,7 @@ index c3b1b93..3dab2ce 100644 info.sampler = privateSampler->sampler(); auto& descSet = writeDescSet.emplace_back(); -@@ -188,10 +379,4 @@ namespace Indium { +@@ -188,10 +414,4 @@ namespace Indium { return descriptorSets; }; diff --git a/tests/fixtures/indium-address-shadow.cpp b/tests/fixtures/indium-address-shadow.cpp new file mode 100644 index 0000000..4d10ebd --- /dev/null +++ b/tests/fixtures/indium-address-shadow.cpp @@ -0,0 +1,175 @@ +// CPU-only buffer/Vulkan shims; the scratch, pool and table helper are extracted +// from the authoritative component patch, not copied into this fixture. +#include +#include +#include +#include +#include +#include +#include +#include +#include +static size_t allocationCalls = 0; +void* operator new(size_t size) { + ++allocationCalls; + if (auto* p = std::malloc(size ? size : 1)) return p; + throw std::bad_alloc(); +} +void operator delete(void* p) noexcept { std::free(p); } +void operator delete(void* p, size_t) noexcept { std::free(p); } +using VkBuffer = uint64_t; +using VkDeviceSize = uint64_t; +constexpr VkBuffer VK_NULL_HANDLE = 0; +namespace Iridium { enum class BindingType { Buffer, Texture, Sampler }; } +static bool enabled = true; +bool bindingReuseEnabled() { return enabled; } +void logUnboundResource(const char*, size_t, size_t, size_t) {} +struct Buffer { virtual ~Buffer() = default; virtual void* contents() = 0; }; +struct PrivateBuffer : Buffer { + uint64_t id, address; + size_t reads = 0; + std::vector bytes; + PrivateBuffer(uint64_t id, uint64_t address) : id(id), address(address), bytes(256*1024, 0xa5) {} + void* contents() override { ++reads; return bytes.data(); } + VkBuffer buffer() const { return id; } + uint64_t gpuAddress() const { return address; } +}; +struct PrivateDevice { + size_t chunks = 0; + uint64_t fallback = 0xf00d; + uint64_t fallbackBufferAddress() { return fallback; } + std::shared_ptr acquireScratchChunk() { ++chunks; return std::make_shared(chunks, chunks << 20); } +}; +struct FunctionResources { std::vector, size_t>> buffers; }; +struct Resolved { PrivateBuffer* value = nullptr; }; +struct ResolvedFunctionResources { std::vector buffers; }; +struct Binding { Iridium::BindingType type; size_t index; }; +struct FunctionInfo { std::vector bindings; }; + +/* ACTUAL_PATCHED_HELPER */ + +struct Check { + AddressPool pool; + std::shared_ptr device = std::make_shared(); + std::vector> keep; + std::array tables{}; + Check() { keep.reserve(8); } + PrivateBuffer* mapped() { return static_cast(pool.chunk.get()); } + void run(size_t stage, const FunctionResources& resources, const FunctionInfo& info, + bool reuse, const ResolvedFunctionResources* resolved = nullptr, bool previous = true) { + auto old = tables[stage]; + auto used = pool.used, chunks = device->chunks, calls = allocationCalls; + auto table = makeBufferAddressTable(resources, info, resolved, device, keep, pool, + previous ? &tables[stage] : nullptr); + // Only the existing DescriptorScratch overflow and new scratch chunk + // may allocate (make_shared control/object plus the chunk byte vector). + assert(allocationCalls - calls == (info.bindings.size() > 64 ? 1u : 0u) + + (device->chunks - chunks) * 2); + assert((table.buffer == old.buffer && table.offset == old.offset && pool.used == used) == reuse); + assert(table.range == ((std::count_if(info.bindings.begin(), info.bindings.end(), + [](const Binding& b) { return b.type == Iridium::BindingType::Buffer; }) * 8 + 15) & ~size_t(15))); + assert(keep.size() == device->chunks); + size_t index = 0; + for (const auto& binding : info.bindings) { + if (binding.type != Iridium::BindingType::Buffer) continue; + PrivateBuffer* buffer = nullptr; + if (resolved && binding.index < resolved->buffers.size()) buffer = resolved->buffers[binding.index].value; + else if (binding.index < resources.buffers.size()) buffer = dynamic_cast(resources.buffers[binding.index].first.get()); + uint64_t expected = buffer ? buffer->gpuAddress() + resources.buffers[binding.index].second : device->fallback; + uint64_t actual; + memcpy(&actual, mapped()->bytes.data() + table.offset + index++ * 8, 8); + assert(actual == expected); + } + tables[stage] = table; + } +}; +FunctionInfo info(size_t count, size_t start = 0) { + FunctionInfo result; + for (size_t i = 0; i < count; ++i) result.bindings.push_back({Iridium::BindingType::Buffer, start+i}); + return result; +} +int main() { + // Two 64-key entries: +1080 bytes per pool on the tested 64-bit ABI. + static_assert(sizeof(AddressPool) == sizeof(std::shared_ptr) + sizeof(size_t) + 1080); + FunctionResources r; + auto resource = std::make_shared(100, 0x1000); + for (size_t i = 0; i < 150; ++i) r.buffers.push_back({resource, i*16}); + Check p; + auto f = info(3); + p.run(0, r, f, false); + auto accesses = p.mapped()->reads; + p.run(0, r, f, true); assert(p.mapped()->reads == accesses); + r.buffers[100].second++; p.run(0, r, f, true); + auto equivalent = std::make_shared(101, 0x0ff0); + r.buffers[0] = {equivalent, 16}; p.run(0, r, f, true); + r.buffers[1].second++; p.run(0, r, f, false); + auto mixed = f; + mixed.bindings.push_back({Iridium::BindingType::Texture, 999}); + mixed.bindings.push_back({Iridium::BindingType::Sampler, 999}); + p.run(0, r, mixed, true); + p.run(1, r, info(4, 20), false); + accesses = p.mapped()->reads; + p.run(0, r, f, true); p.run(1, r, info(4, 20), true); + assert(p.mapped()->reads == accesses); // both interleaved stages hit CPU keys + for (size_t i = 0; i < 5; ++i) { + r.buffers[20].second++; p.run(1, r, info(4, 20), false); + } + accesses = p.mapped()->reads; + p.run(0, r, f, true); + assert(p.mapped()->reads == accesses + 1); // eviction retains mapped fallback + + Check padding; + padding.run(0, r, info(1), false); + r.buffers[1] = {resource, uint64_t(0xa5a5a5a5a5a5a5a5ULL - resource->gpuAddress())}; + accesses = padding.mapped()->reads; + padding.run(0, r, info(2), true); // odd -> even reads real padding + assert(padding.mapped()->reads == accesses + 1); + r.buffers[1].second++; padding.run(0, r, info(2), false); + accesses = padding.mapped()->reads; + padding.run(0, r, info(1), true); // even -> odd needs only cached prefix + assert(padding.mapped()->reads == accesses); + padding.run(0, r, info(3), false); + + Check large; + for (size_t count : {size_t(64), size_t(65), size_t(150), size_t(32767), size_t(32768)}) { + large.run(0, r, info(count), false); + large.run(0, r, info(count), true); + } + r.buffers[64].second++; large.run(0, r, info(65), false); + large.run(1, r, info(8), false, nullptr, false); + large.run(1, r, info(8), true); + + Check missing; + FunctionResources empty; + missing.run(0, empty, info(2, 999), false); + missing.run(0, empty, info(2, 999), true); + missing.device->fallback++; missing.run(0, empty, info(2, 999), false); + ResolvedFunctionResources resolved; + resolved.buffers.resize(r.buffers.size()); resolved.buffers[0].value = resource.get(); + missing.run(1, r, info(1), false, &resolved); + missing.run(1, r, info(1), true, &resolved); + resolved.buffers[0].value = nullptr; + missing.run(1, r, info(1), false, &resolved); + + Check roll; + roll.run(0, r, info(2), false); roll.run(1, r, info(2, 3), false); + auto oldChunk = roll.pool.chunk; + roll.pool.used = 256*1024; + r.buffers[0].second++; roll.run(0, r, info(2), false); + assert(roll.pool.chunk != oldChunk && roll.keep.size() == 2); + // Old offset 256 must not supply a stale key for untouched new-chunk bytes. + roll.tables[1] = {roll.tables[0].buffer, 256, 16}; + roll.run(1, r, info(2, 3), false); roll.run(1, r, info(2, 3), true); + enabled = false; roll.run(1, r, info(2, 3), false); enabled = true; + + Check guard; + guard.run(0, r, info(0), false); guard.run(0, r, info(0), true); + auto id = guard.tables[0].buffer; + guard.tables[0] = {id, 256*1024, 0}; guard.run(0, r, info(0), true); + guard.tables[0] = {id, 256*1024+1, 0}; guard.run(0, r, info(0), false); + accesses = guard.mapped()->reads; + guard.tables[0] = {id, 256*1024-8, 16}; guard.run(0, r, info(1), false); + assert(guard.mapped()->reads == accesses + 1); // write only; no invalid read + guard.tables[0].buffer += 100; guard.run(0, r, info(1), false); + guard.tables[0].range = 32; guard.run(0, r, info(1), false); +} diff --git a/tests/test_indium_address_shadow.py b/tests/test_indium_address_shadow.py new file mode 100644 index 0000000..1a8e886 --- /dev/null +++ b/tests/test_indium_address_shadow.py @@ -0,0 +1,50 @@ +"""Compile the shipped address-table helper against an inert CPU-only fixture.""" +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +HEADER = 'private-include/indium/command-encoder.private.hpp' + + +def address_helper(): + patch = (ROOT / 'patches/metal--deps--indium.patch').read_text() + marker = f'diff --git a/{HEADER} b/{HEADER}\n' + section = patch.split(marker, 1)[1].split('\ndiff --git ', 1)[0] + # Extract each postimage hunk separately: never silently join a source gap. + hunks = re.split(r'(?m)^@@ [^\n]*\n', section)[1:] + postimages = [''.join(line[1:] for line in hunk.splitlines(keepends=True) + if line.startswith((' ', '+'))) for hunk in hunks] + + def region(start, end): + matches = [text[text.index(start):text.index(end, text.index(start))] + for text in postimages if start in text and end in text] + if len(matches) != 1: + raise AssertionError('expected one complete patched region: ' + start) + return matches[0] + + return (region(' template class DescriptorScratch', + ' // Per-encoder binding tables') + + region('\tstruct AddressPool {', '\n\ttemplate')) + + +@unittest.skipUnless(shutil.which('c++'), 'C++ compiler required') +class AddressShadowChecks(unittest.TestCase): + def test_interleaving_fallback_padding_rollover_large_keys_and_allocations(self): + fixture = (ROOT / 'tests/fixtures/indium-address-shadow.cpp').read_text() + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) + source = path / 'check.cpp' + source.write_text(fixture.replace('/* ACTUAL_PATCHED_HELPER */', address_helper())) + build = subprocess.run(['c++', '-std=c++17', '-O1', str(source), '-o', str(path/'check')], + capture_output=True, text=True) + self.assertEqual(build.returncode, 0, build.stderr) + result = subprocess.run([str(path/'check')], capture_output=True, text=True, timeout=30) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == '__main__': + unittest.main() From fb520d4d71a7997189fd8a011f27ad32c50062db Mon Sep 17 00:00:00 2001 From: ekisu Date: Tue, 20 Oct 2026 00:34:25 -0300 Subject: [PATCH 38/54] test(indium): run accepted address shadow regression in CI --- .github/workflows/source-check.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 3e91493..d8c96b5 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -16,6 +16,10 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.12' + - name: Require C++ regression compiler + run: | + command -v c++ + c++ --version - run: python scripts/validate.py - run: python tools/riot-input-offline-check.py - run: python tools/riot-launcher-offline-check.py @@ -28,5 +32,6 @@ jobs: - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' - run: python -m unittest discover -s tests -p 'test_system_profiler.py' - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' + - run: python -m unittest discover -s tests -p 'test_indium_address_shadow.py' -v - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' From 0947a9f20d704524a7362532f98860a8c076937f Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Tue, 20 Oct 2026 23:49:39 -0600 Subject: [PATCH 39/54] launcher: use a neutral Darling Launcher name and icon Use a project-owned identity for the visible desktop entry while keeping the existing binary, package and window-matching identifiers working. The helper now copies the project's original icon instead of extracting one from the client, and the old game-named entry and icons it created are removed. Extend the packaging check to assert the neutral fields. --- flake.nix | 42 ++++--- scripts/create-desktop-entry.sh | 2 +- tools/desktop-entry.py | 121 +++++++++++++++++++ tools/riot-desktop-entry.py | 202 -------------------------------- 4 files changed, 146 insertions(+), 221 deletions(-) create mode 100755 tools/desktop-entry.py delete mode 100755 tools/riot-desktop-entry.py diff --git a/flake.nix b/flake.nix index ef8a7b5..cc37e30 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Unofficial Darling League of Legends - experimental macOS LoL runner on Linux via Darling"; + description = "Unofficial Darling launcher for the macOS game client on Linux"; inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; @@ -136,7 +136,7 @@ pythonEnv ]; - # FHS environment for building and running Darling and League of Legends. + # FHS environment for building and running Darling and the macOS game client. # Darling requires root/multitasking mount & namespace semantics, /usr/local layout, # and dynamically compiled overlays. An FHS environment provides the standard standard # filesystem hierarchies (/usr/include, /usr/lib, /usr/bin) and native dynamic linker. @@ -223,10 +223,10 @@ mkdir -p $out/bin $out/share/darling-lol $out/share/icons/hicolor/scalable/apps $out/share/icons/hicolor/128x128/apps $out/share/pixmaps $out/share/applications cp -r * $out/share/darling-lol/ - cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-lol.svg - cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-lol.png - cp darling-lol.png $out/share/pixmaps/darling-lol.png - cp darling-lol.svg $out/share/pixmaps/darling-lol.svg + cp darling-lol.svg $out/share/icons/hicolor/scalable/apps/darling-launcher.svg + cp darling-lol.png $out/share/icons/hicolor/128x128/apps/darling-launcher.png + cp darling-lol.png $out/share/pixmaps/darling-launcher.png + cp darling-lol.svg $out/share/pixmaps/darling-launcher.svg # Install configured launcher substitute ./darling-lol-launcher.sh $out/bin/darling-lol \ @@ -235,6 +235,7 @@ --replace-fail "@UNSHARE_BIN@" "${pkgs.util-linux}/bin/unshare" chmod +x $out/bin/darling-lol wrapProgram $out/bin/darling-lol --prefix PATH : ${pkgs.lib.makeBinPath [ pythonEnv pkgs.coreutils pkgs.util-linux pkgs.gawk ]} + ln -s darling-lol $out/bin/darling-launcher makeWrapper $out/bin/darling-lol $out/bin/darling-lol-installer \ --add-flags "--installer" @@ -245,15 +246,15 @@ makeWrapper $out/bin/darling-lol $out/bin/darling-lol-build \ --add-flags "./scripts/build.sh" - cat << 'DESKTOP_EOF' > $out/share/applications/darling-lol.desktop + cat << 'DESKTOP_EOF' > $out/share/applications/darling-launcher.desktop [Desktop Entry] Categories=Game; -Comment=Unofficial League of Legends macOS client compatibility layer for Linux via Darling -Exec=darling-lol -GenericName=MOBA Game -Icon=darling-lol -Keywords=League;LoL;Riot;Darling; -Name=League of Legends (unofficial) +Comment=Unofficial launcher for the macOS game client via Darling +Exec=darling-launcher +GenericName=Game +Icon=darling-launcher +Keywords=Darling;Launcher;Compatibility; +Name=Darling Launcher StartupWMClass=RiotClientServices Terminal=true Type=Application @@ -264,7 +265,7 @@ DESKTOP_EOF ''; meta = with pkgs.lib; { - description = "Unofficial tooling and patches to run macOS League of Legends on Linux with Darling"; + description = "Unofficial tooling and patches to run the macOS game client on Linux with Darling"; homepage = "https://github.com/lucannez64/darling-lol"; license = licenses.gpl3Only; platforms = [ "x86_64-linux" ]; @@ -334,7 +335,12 @@ DESKTOP_EOF # Tests use private stub binaries/repositories, never Darling itself. python3 -m unittest discover -s tests -v python3 scripts/validate.py - grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-lol.desktop + grep -q '^Terminal=true$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Name=Darling Launcher$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Icon=darling-launcher$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Keywords=Darling;Launcher;Compatibility;$' ${darlingLol}/share/applications/darling-launcher.desktop + ! grep -qiE '^(Name|Comment|GenericName|Keywords)=.*(League|LoL|Riot|Vanguard|anti-cheat)' ${darlingLol}/share/applications/darling-launcher.desktop + test -x ${darlingLol}/bin/darling-launcher touch "$out" ''; }; @@ -349,7 +355,7 @@ DESKTOP_EOF default = { type = "app"; program = "${darlingLol}/bin/darling-lol"; - meta.description = "Launch League of Legends via Darling"; + meta.description = "Launch the macOS game client via Darling"; }; shell = { type = "app"; @@ -359,7 +365,7 @@ DESKTOP_EOF installer = { type = "app"; program = "${darlingLol}/bin/darling-lol-installer"; - meta.description = "Run the Darling and League of Legends rootless installer"; + meta.description = "Run the Darling rootless installer"; }; build = { type = "app"; @@ -371,7 +377,7 @@ DESKTOP_EOF devShells.${system}.default = pkgs.mkShell { packages = buildDeps ++ [ darlingLolFhs ]; shellHook = '' - echo "Darling LoL build & development environment loaded." + echo "Darling build & development environment loaded." echo "Tip: Run 'darling-lol-fhs' to enter the FHS build sandbox if standard paths (/usr/include, /usr/lib) are needed." ''; }; diff --git a/scripts/create-desktop-entry.sh b/scripts/create-desktop-entry.sh index 81b8c22..dbdb7a2 100755 --- a/scripts/create-desktop-entry.sh +++ b/scripts/create-desktop-entry.sh @@ -4,4 +4,4 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "$repo" python3 scripts/configure.py >/dev/null -python3 build/runtime/tools/riot-desktop-entry.py +python3 build/runtime/tools/desktop-entry.py diff --git a/tools/desktop-entry.py b/tools/desktop-entry.py new file mode 100755 index 0000000..0aa6c76 --- /dev/null +++ b/tools/desktop-entry.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Create and install the GNOME / FreeDesktop desktop entry and launcher icon.""" +import os +import shutil +import subprocess +from pathlib import Path + +PROJECT_ROOT = Path("@PROJECT_ROOT@") +HOST_HOME = Path("@HOST_HOME@") + + +def remove_legacy_entry(path: Path) -> None: + """Remove a previous launcher entry only when this project created it.""" + try: + if path.exists() and "desktop-run.sh" in path.read_text(errors="ignore"): + path.unlink() + except OSError: + pass + + +def main(): + icons_base = HOST_HOME / ".local/share/icons" + apps_base = HOST_HOME / ".local/share/applications" + + scalable_dir = icons_base / "hicolor/scalable/apps" + png_dir = icons_base / "hicolor/128x128/apps" + scalable_dir.mkdir(parents=True, exist_ok=True) + png_dir.mkdir(parents=True, exist_ok=True) + apps_base.mkdir(parents=True, exist_ok=True) + + source_svg = PROJECT_ROOT / "darling-lol.svg" + source_png = PROJECT_ROOT / "darling-lol.png" + if not source_svg.is_file() or not source_png.is_file(): + raise SystemExit("launcher icon is missing: expected darling-lol.svg and darling-lol.png") + + # Install the project's original icon under a neutral name. Do not extract + # or install an icon from the game/client installation. + svg_target = scalable_dir / "darling-launcher.svg" + png_target = png_dir / "darling-launcher.png" + shutil.copyfile(source_svg, svg_target) + shutil.copyfile(source_png, png_target) + shutil.copyfile(source_svg, icons_base / "darling-launcher.svg") + shutil.copyfile(source_png, icons_base / "darling-launcher.png") + + # Remove artifacts created by older versions of this helper. + remove_legacy_entry(apps_base / "league-of-legends.desktop") + for legacy_icon in ( + icons_base / "league-of-legends.svg", + icons_base / "league-of-legends.png", + scalable_dir / "league-of-legends.svg", + icons_base / "hicolor/512x512/apps/league-of-legends.png", + ): + try: + if legacy_icon.exists(): + legacy_icon.unlink() + except OSError: + pass + + # Create the desktop entry. Keep StartupWMClass: it is the client's real + # window class and is required for the compositor to match the window. + run_script = PROJECT_ROOT / "scripts/desktop-run.sh" + exec_path = str(run_script).replace("%", "%%") + exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path + desktop_content = f"""[Desktop Entry] +Version=1.0 +Type=Application +Name=Darling Launcher +GenericName=Game +Comment=Unofficial launcher for the macOS game client via Darling +Exec={exec_cmd} +Path={PROJECT_ROOT} +Icon=darling-launcher +Terminal=false +Categories=Game;ActionGame;StrategyGame; +Keywords=darling;launcher;compatibility; +StartupWMClass=Riot Client +StartupNotify=false +""" + + desktop_file = apps_base / "darling-launcher.desktop" + desktop_file.write_text(desktop_content) + os.chmod(desktop_file, 0o755) + print(f"Created application launcher: {desktop_file}") + + target_desktop_dirs = [] + try: + res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) + if res.returncode == 0 and res.stdout.strip(): + d = Path(res.stdout.strip()) + if d.is_dir() and d not in target_desktop_dirs: + target_desktop_dirs.append(d) + except Exception: + pass + for cand in [HOST_HOME / "Desktop"]: + if cand.is_dir() and cand not in target_desktop_dirs: + target_desktop_dirs.append(cand) + + for ddir in target_desktop_dirs: + remove_legacy_entry(ddir / "league-of-legends.desktop") + desktop_shortcut = ddir / "darling-launcher.desktop" + desktop_shortcut.write_text(desktop_content) + os.chmod(desktop_shortcut, 0o755) + gio = shutil.which("gio") + if gio: + subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False) + print(f"Created desktop shortcut: {desktop_shortcut}") + + # Refresh icon and desktop databases + update_desktop = shutil.which("update-desktop-database") + if update_desktop: + subprocess.run([update_desktop, str(apps_base)], check=False) + + update_icons = shutil.which("gtk-update-icon-cache") + if update_icons: + subprocess.run([update_icons, "-f", "-t", str(icons_base / "hicolor")], check=False) + + print("GNOME application launcher and icon installed successfully.") + + +if __name__ == "__main__": + main() diff --git a/tools/riot-desktop-entry.py b/tools/riot-desktop-entry.py deleted file mode 100755 index 049e94d..0000000 --- a/tools/riot-desktop-entry.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python3 -"""Create and install the GNOME / FreeDesktop desktop entry and launcher icon.""" -import os -import shutil -import subprocess -import sys -from pathlib import Path - -PROJECT_ROOT = Path("@PROJECT_ROOT@") -HOST_HOME = Path("@HOST_HOME@") -DPREFIX = Path("@DPREFIX@") - -SVG_ICON_CONTENT = """ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -""" - - -def extract_best_png_from_icns(icns_path: Path) -> bytes | None: - """Extract the highest-resolution embedded PNG from an Apple ICNS file.""" - try: - data = icns_path.read_bytes() - if not data.startswith(b"icns"): - return None - offset = 8 - best_png = None - best_len = 0 - while offset + 8 <= len(data): - size = int.from_bytes(data[offset + 4 : offset + 8], "big") - if size < 8 or offset + size > len(data): - break - chunk = data[offset + 8 : offset + size] - if chunk.startswith(b"\x89PNG\r\n\x1a\n") and len(chunk) > best_len: - best_png = chunk - best_len = len(chunk) - offset += size - return best_png - except Exception: - return None - - -def find_candidate_icns() -> Path | None: - """Find any available Riot or League .icns icon files.""" - candidates = [ - *list((PROJECT_ROOT / "build/installer").glob("*League of Legends*.app/Contents/Resources/*.icns")), - *list(DPREFIX.glob("Users/Shared/Riot Games/Riot Client.app/Contents/Resources/*.icns")), - *list(DPREFIX.glob("Applications/Riot Client.app/Contents/Resources/*.icns")), - *list(DPREFIX.glob("Applications/League of Legends.app/Contents/Resources/*.icns")), - ] - for c in candidates: - if c.is_file() and c.stat().st_size > 1024: - return c - return None - - -def main(): - icons_base = HOST_HOME / ".local/share/icons" - apps_base = HOST_HOME / ".local/share/applications" - desktop_dir = HOST_HOME / "Desktop" - - scalable_dir = icons_base / "hicolor/scalable/apps" - png_dir = icons_base / "hicolor/512x512/apps" - scalable_dir.mkdir(parents=True, exist_ok=True) - png_dir.mkdir(parents=True, exist_ok=True) - apps_base.mkdir(parents=True, exist_ok=True) - - # 1. Install scalable SVG icon - svg_target = scalable_dir / "league-of-legends.svg" - svg_target.write_text(SVG_ICON_CONTENT) - shutil.copyfile(svg_target, icons_base / "league-of-legends.svg") - - # 2. Extract or generate PNG icon - png_target = png_dir / "league-of-legends.png" - icns_file = find_candidate_icns() - extracted = False - if icns_file: - png_data = extract_best_png_from_icns(icns_file) - if png_data: - png_target.write_bytes(png_data) - shutil.copyfile(png_target, icons_base / "league-of-legends.png") - extracted = True - print(f"Extracted official icon from {icns_file.name} to {png_target}") - - if not extracted and not png_target.exists(): - # Try to rasterize SVG via rsvg-convert or magick - rsvg = shutil.which("rsvg-convert") - magick = shutil.which("magick") or shutil.which("convert") - if rsvg: - subprocess.run([rsvg, "-w", "512", "-h", "512", str(svg_target), "-o", str(png_target)], check=False) - if png_target.exists(): - shutil.copyfile(png_target, icons_base / "league-of-legends.png") - elif magick: - subprocess.run([magick, "-background", "none", "-resize", "512x512", str(svg_target), str(png_target)], check=False) - if png_target.exists(): - shutil.copyfile(png_target, icons_base / "league-of-legends.png") - - # 3. Create .desktop file - run_script = PROJECT_ROOT / "scripts/desktop-run.sh" - exec_path = str(run_script).replace("%", "%%") - exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path - desktop_content = f"""[Desktop Entry] -Version=1.0 -Type=Application -Name=League of Legends -GenericName=MOBA Game -Comment=Play League of Legends on Linux with darling-lol -Exec={exec_cmd} -Path={PROJECT_ROOT} -Icon=league-of-legends -Terminal=false -Categories=Game;ActionGame;StrategyGame; -Keywords=riot;league;lol;darling;games; -StartupWMClass=Riot Client -StartupNotify=false -""" - - desktop_file = apps_base / "league-of-legends.desktop" - desktop_file.write_text(desktop_content) - os.chmod(desktop_file, 0o755) - print(f"Created application launcher: {desktop_file}") - - target_desktop_dirs = [] - try: - res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) - if res.returncode == 0 and res.stdout.strip(): - d = Path(res.stdout.strip()) - if d.is_dir() and d not in target_desktop_dirs: - target_desktop_dirs.append(d) - except Exception: - pass - for cand in [HOST_HOME / "Desktop"]: - if cand.is_dir() and cand not in target_desktop_dirs: - target_desktop_dirs.append(cand) - - for ddir in target_desktop_dirs: - desktop_shortcut = ddir / "league-of-legends.desktop" - desktop_shortcut.write_text(desktop_content) - os.chmod(desktop_shortcut, 0o755) - gio = shutil.which("gio") - if gio: - subprocess.run([gio, "set", str(desktop_shortcut), "metadata::trusted", "true"], check=False) - print(f"Created desktop shortcut: {desktop_shortcut}") - - # 4. Refresh icon and desktop databases - update_desktop = shutil.which("update-desktop-database") - if update_desktop: - subprocess.run([update_desktop, str(apps_base)], check=False) - - update_icons = shutil.which("gtk-update-icon-cache") - if update_icons: - subprocess.run([update_icons, "-f", "-t", str(icons_base / "hicolor")], check=False) - - print("GNOME application launcher and icon installed successfully.") - - -if __name__ == "__main__": - main() From e00ee9952208777a9eceb836c070fd96e3040c50 Mon Sep 17 00:00:00 2001 From: lucannez64 <58551020+lucannez64@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:23:47 +0200 Subject: [PATCH 40/54] fix(launcher): only remove launcher artifacts this project created Match the legacy entry against this checkout's desktop-run.sh instead of any desktop-run.sh, and delete the legacy icon files only after that entry or the old artwork proves ownership. Quote and escape Exec arguments as the Desktop Entry specification requires, so a checkout path with reserved characters no longer produces an invalid entry, and escape the Path value. Use StartupWMClass=RiotClientServices in both entries: Darling derives WM_CLASS from -[NSProcessInfo processName], the basename of argv[0], and the launcher starts .../RiotClientServices. "Riot Client" is only the bundle display name, so the checkout entry never matched the window. Both entries now share Name/GenericName/Comment/Icon/Keywords/Categories and a test keeps them in sync; Terminal stays different because the checkout entry logs to build/desktop-launch.log while the packaged entry runs the session in a terminal. Also drop the stray top-level icon copies, handle dangling legacy symlinks, name the icon for assistive technology, document the alias and menu entry, and cover the helper with disposable-HOME tests. --- .github/workflows/source-check.yml | 1 + darling-lol.svg | 4 +- docs/NIX.md | 3 + docs/SETUP.md | 13 ++- docs/TESTING.md | 6 +- flake.nix | 4 + tests/test_desktop_entry.py | 157 ++++++++++++++++++++++++++++ tools/desktop-entry.py | 162 ++++++++++++++++++++--------- 8 files changed, 295 insertions(+), 55 deletions(-) create mode 100644 tests/test_desktop_entry.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index 3e91493..a866b9d 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -25,6 +25,7 @@ jobs: - run: python -m unittest discover -s tests -p 'test_nix_repairs.py' - run: python -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' - run: python -m unittest discover -s tests -p 'test_desktop_run.py' + - run: python -m unittest discover -s tests -p 'test_desktop_entry.py' - run: python -m unittest discover -s tests -p 'test_urlsession_runner.py' - run: python -m unittest discover -s tests -p 'test_system_profiler.py' - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' diff --git a/darling-lol.svg b/darling-lol.svg index 7ec69b9..1916ca4 100644 --- a/darling-lol.svg +++ b/darling-lol.svg @@ -1,5 +1,5 @@ - - darling-lol + + Darling Launcher diff --git a/docs/NIX.md b/docs/NIX.md index 2cde500..0323f61 100644 --- a/docs/NIX.md +++ b/docs/NIX.md @@ -91,6 +91,9 @@ darling-lol --installer darling-lol-shell ``` +The application menu entry is `Darling Launcher`, and `darling-launcher` is an +alias for `darling-lol` for scripts that prefer the neutral name. + If the desktop entry does not appear, ensure your desktop session includes `~/.nix-profile/share` in `XDG_DATA_DIRS`, then log out and back in. Non-NixOS systems may need this desktop-session integration after installing Nix. diff --git a/docs/SETUP.md b/docs/SETUP.md index 4d85c6b..0c0d39b 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -61,10 +61,15 @@ The normal launcher starts neither an Electron inspector nor client API/UI helpe ## Application-menu diagnostics and recovery options -`scripts/create-desktop-entry.sh` creates an application-menu entry that runs -`scripts/desktop-run.sh`, preserving the normal launcher behavior and saving -stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can contain -private session details; do not attach raw launcher logs to a public issue. +`scripts/create-desktop-entry.sh` creates a `Darling Launcher` application-menu +entry that runs `scripts/desktop-run.sh`, preserving the normal launcher behavior +and saving stdout/stderr in `build/desktop-launch.log` with mode 0600. Logs can +contain private session details; do not attach raw launcher logs to a public +issue. The helper also removes the `league-of-legends.*` entry and icons written +by older versions, but only when it can prove this project created them. The +entry keeps `StartupWMClass=RiotClientServices`: Darling derives `WM_CLASS` from +the basename of `argv[0]`, so that is the window class of the client the entry +starts, while `Riot Client` is only its bundle display name. Region/locale remain the existing `RCLOL_REGION`/`RCLOL_LOCALE` options. `RCLOL_CDN_IPV4_ONLY` defaults to `1` in the Riot launcher and can be set to `0` diff --git a/docs/TESTING.md b/docs/TESTING.md index 63cdd2a..1e80d10 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -78,6 +78,7 @@ Host-only checks, which do not start Darling: python3 tools/riot-cdn-dns-offline-check.py python3 -m unittest discover -s tests -p 'test_hidden_riot_recovery.py' python3 -m unittest discover -s tests -p 'test_desktop_run.py' +python3 -m unittest discover -s tests -p 'test_desktop_entry.py' python3 -m unittest discover -s tests -p 'test_urlsession_runner.py' ``` @@ -87,7 +88,10 @@ insensitivity, A queries, non-CDN names and misleading domain suffixes without sending a DNS query. Recovery tests use fake `/proc` entries and mock every compositor/shutdown command. The desktop test checks exit/argument forwarding, append-only output and tightening an existing public log to mode 0600. The -runner test uses a fake `darling` executable to verify loopback fixtures and +desktop-entry test renders the helper against a disposable `HOME` and checks +the neutral fields, Exec quoting, icon paths, that only launcher artifacts +this project created are removed, and that the checkout and packaged entries +share their identity fields. The runner test uses a fake `darling` executable to verify loopback fixtures and private-prefix-only shutdown/cleanup. With the pinned Darling sources available, run the host resolver regression: diff --git a/flake.nix b/flake.nix index cc37e30..322e2e9 100644 --- a/flake.nix +++ b/flake.nix @@ -339,6 +339,10 @@ DESKTOP_EOF grep -q '^Name=Darling Launcher$' ${darlingLol}/share/applications/darling-launcher.desktop grep -q '^Icon=darling-launcher$' ${darlingLol}/share/applications/darling-launcher.desktop grep -q '^Keywords=Darling;Launcher;Compatibility;$' ${darlingLol}/share/applications/darling-launcher.desktop + grep -q '^Categories=Game;$' ${darlingLol}/share/applications/darling-launcher.desktop + # Darling derives WM_CLASS from argv[0], so the window class is the + # client executable name; this is an interoperability identifier. + grep -q '^StartupWMClass=RiotClientServices$' ${darlingLol}/share/applications/darling-launcher.desktop ! grep -qiE '^(Name|Comment|GenericName|Keywords)=.*(League|LoL|Riot|Vanguard|anti-cheat)' ${darlingLol}/share/applications/darling-launcher.desktop test -x ${darlingLol}/bin/darling-launcher touch "$out" diff --git a/tests/test_desktop_entry.py b/tests/test_desktop_entry.py new file mode 100644 index 0000000..4806f3e --- /dev/null +++ b/tests/test_desktop_entry.py @@ -0,0 +1,157 @@ +"""Verify the desktop-entry helper against a disposable HOME.""" +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'tools/desktop-entry.py' +FLAKE = ROOT / 'flake.nix' +OLD_SVG = '' +IDENTITY_KEYS = ('Name', 'GenericName', 'Comment', 'Icon', 'Keywords', 'Categories', 'StartupWMClass') + + +def parse_entry(text): + entry = {} + for line in text.splitlines(): + if '=' in line and not line.startswith(('#', '[')): + key, value = line.split('=', 1) + entry[key] = value + return entry + + +class DesktopEntryTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='desktop entry ', dir=os.environ.get('TMPDIR')) + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.home = self.root / 'home' + self.home.mkdir() + + def project(self, name='project'): + project = self.root / name + project.mkdir(parents=True) + shutil.copy2(ROOT / 'darling-lol.svg', project / 'darling-lol.svg') + shutil.copy2(ROOT / 'darling-lol.png', project / 'darling-lol.png') + return project + + def install(self, project): + script = project / 'desktop-entry.py' + script.write_text(HELPER.read_text() + .replace('@PROJECT_ROOT@', str(project)) + .replace('@HOST_HOME@', str(self.home))) + return subprocess.run([sys.executable, str(script)], capture_output=True, text=True, + env=dict(os.environ, HOME=str(self.home)), check=False) + + def entry(self): + return self.home / '.local/share/applications/darling-launcher.desktop' + + def icons(self): + base = self.home / '.local/share/icons' + return base / 'hicolor/scalable/apps/darling-launcher.svg', base / 'hicolor/128x128/apps/darling-launcher.png' + + def test_installs_neutral_entry_and_icons(self): + project = self.project() + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + text = self.entry().read_text() + self.assertIn('Name=Darling Launcher\n', text) + self.assertIn('Icon=darling-launcher\n', text) + self.assertIn(f'Path={project}\n', text) + for field in ('Name', 'Comment', 'GenericName', 'Keywords'): + line = next(line for line in text.splitlines() if line.startswith(field + '=')) + for brand in ('League', 'LoL', 'Riot', 'Vanguard', 'anti-cheat'): + self.assertNotIn(brand.lower(), line.lower()) + svg, png = self.icons() + self.assertTrue(svg.is_file()) + self.assertTrue(png.is_file()) + self.assertEqual(png.read_bytes(), (project / 'darling-lol.png').read_bytes()) + + def test_preserves_entries_and_icons_it_did_not_create(self): + project = self.project() + other = self.root / 'elsewhere' + (self.home / '.local/share/applications').mkdir(parents=True) + legacy_entry = self.home / '.local/share/applications/league-of-legends.desktop' + legacy_entry.write_text(f'[Desktop Entry]\nExec={other}/scripts/desktop-run.sh\n') + (self.home / 'Desktop').mkdir() + desktop_entry = self.home / 'Desktop/league-of-legends.desktop' + desktop_entry.write_text(f'[Desktop Entry]\nExec={other}/scripts/desktop-run.sh\n') + icons = self.home / '.local/share/icons' + icons.mkdir(parents=True) + (icons / 'league-of-legends.png').write_text('user icon') + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(legacy_entry.is_file()) + self.assertTrue(desktop_entry.is_file()) + self.assertEqual((icons / 'league-of-legends.png').read_text(), 'user icon') + self.assertTrue(self.entry().is_file()) + + def test_removes_legacy_artifacts_it_created(self): + project = self.project() + applications = self.home / '.local/share/applications' + applications.mkdir(parents=True) + owned = f'[Desktop Entry]\nExec="{project}/scripts/desktop-run.sh"\n' + legacy_entry = applications / 'league-of-legends.desktop' + legacy_entry.write_text(owned) + (self.home / 'Desktop').mkdir() + desktop_link = self.home / 'Desktop/league-of-legends.desktop' + desktop_link.symlink_to(project / 'scripts/desktop-run.sh') + icons = self.home / '.local/share/icons' + scalable = icons / 'hicolor/scalable/apps' + legacy_png_dir = icons / 'hicolor/512x512/apps' + scalable.mkdir(parents=True) + legacy_png_dir.mkdir(parents=True) + legacy_svg = scalable / 'league-of-legends.svg' + legacy_svg.write_text(OLD_SVG) + legacy_png = legacy_png_dir / 'league-of-legends.png' + legacy_png.write_bytes(b'old client icon') + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(legacy_entry.exists()) + self.assertFalse(desktop_link.is_symlink()) + self.assertFalse(legacy_svg.exists()) + self.assertFalse(legacy_png.exists()) + self.assertTrue(self.entry().is_file()) + + def test_quotes_exec_arguments_with_reserved_characters(self): + project = self.project('proj $dollar#hash\'quote') + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + text = self.entry().read_text() + exec_line = next(line for line in text.splitlines() if line.startswith('Exec=')) + self.assertTrue(exec_line.startswith('Exec="'), exec_line) + self.assertTrue(exec_line.endswith('"'), exec_line) + self.assertIn('\\$dollar', exec_line) + if shutil.which('desktop-file-validate'): + check = subprocess.run(['desktop-file-validate', str(self.entry())], + capture_output=True, text=True, check=False) + self.assertEqual(check.returncode, 0, check.stdout + check.stderr) + + def test_identity_fields_match_the_packaged_entry(self): + project = self.project() + result = self.install(project) + self.assertEqual(result.returncode, 0, result.stderr) + rendered = parse_entry(self.entry().read_text()) + packaged_here = re.search( + r"cat << 'DESKTOP_EOF' > \$out/share/applications/darling-launcher\.desktop\n(.*?)\n\s*DESKTOP_EOF", + FLAKE.read_text(), re.S) + self.assertIsNotNone(packaged_here, 'packaged desktop entry not found in flake.nix') + packaged = parse_entry(packaged_here.group(1)) + for key in IDENTITY_KEYS: + self.assertIn(key, packaged) + self.assertEqual(rendered.get(key), packaged[key], key) + + def test_missing_icon_fails(self): + project = self.project() + (project / 'darling-lol.png').unlink() + result = self.install(project) + self.assertNotEqual(result.returncode, 0) + self.assertIn('launcher icon is missing', result.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/desktop-entry.py b/tools/desktop-entry.py index 0aa6c76..e77d1a0 100755 --- a/tools/desktop-entry.py +++ b/tools/desktop-entry.py @@ -8,14 +8,84 @@ from pathlib import Path PROJECT_ROOT = Path("@PROJECT_ROOT@") HOST_HOME = Path("@HOST_HOME@") +# Marks a launcher entry created by this project. Previous versions pointed +# Exec at this checkout's desktop-run.sh, so an entry without that marker +# belongs to someone else and is left alone. +LEGACY_RUN_SCRIPT = "scripts/desktop-run.sh" -def remove_legacy_entry(path: Path) -> None: - """Remove a previous launcher entry only when this project created it.""" +# Distinctive fragments of the artwork the previous helper wrote. +LEGACY_SVG_MARKERS = ('id="goldGrad"', 'M172 108 L242 108') + +# Characters that force Exec quoting, per the Desktop Entry specification. +EXEC_RESERVED = set(' \t\n"\'\\><~|&;$*?#()`') + + +def escape_string(value: str) -> str: + """Escape a value of Desktop Entry type ``string``.""" + return (value.replace("\\", "\\\\").replace("\t", "\\t") + .replace("\n", "\\n").replace("\r", "\\r")) + + +def quote_exec_argument(value: str) -> str: + """Escape and quote one Exec argument as the specification requires.""" + escaped = value.replace("%", "%%") + if not any(char in EXEC_RESERVED for char in escaped): + return escaped + escaped = "".join("\\" + char if char in '"`$\\' else char for char in escaped) + return f'"{escaped}"' + + +def legacy_entry_owned(path: Path) -> bool: + """Return True when a launcher entry was created by this project.""" + marker = str(PROJECT_ROOT / LEGACY_RUN_SCRIPT) try: - if path.exists() and "desktop-run.sh" in path.read_text(errors="ignore"): - path.unlink() + text = path.read_text(errors="ignore") except OSError: + # A dangling symlink still counts when it points at this checkout. + try: + text = os.readlink(path) + except OSError: + return False + return marker in text or marker.replace("%", "%%") in text + + +def remove_legacy_entry(path: Path) -> bool: + """Remove a previous launcher entry only when this project created it.""" + if not (path.is_symlink() or path.exists()): + return False + if not legacy_entry_owned(path): + return False + try: + path.unlink() + except OSError: + return False + return True + + +def legacy_svg_owned(path: Path) -> bool: + """Return True when an icon is the artwork the previous helper wrote.""" + try: + text = path.read_text(errors="ignore") + except OSError: + return False + return all(marker in text for marker in LEGACY_SVG_MARKERS) + + +def shortcut_directories() -> list[Path]: + """Return the desktop directories that hold launcher shortcuts.""" + directories: list[Path] = [] + try: + result = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) + if result.returncode == 0 and result.stdout.strip(): + path = Path(result.stdout.strip()) + if path.is_dir(): + directories.append(path) + except Exception: pass + for candidate in [HOST_HOME / "Desktop"]: + if candidate.is_dir() and candidate not in directories: + directories.append(candidate) + return directories def main(): @@ -35,32 +105,42 @@ def main(): # Install the project's original icon under a neutral name. Do not extract # or install an icon from the game/client installation. - svg_target = scalable_dir / "darling-launcher.svg" - png_target = png_dir / "darling-launcher.png" - shutil.copyfile(source_svg, svg_target) - shutil.copyfile(source_png, png_target) - shutil.copyfile(source_svg, icons_base / "darling-launcher.svg") - shutil.copyfile(source_png, icons_base / "darling-launcher.png") + shutil.copyfile(source_svg, scalable_dir / "darling-launcher.svg") + shutil.copyfile(source_png, png_dir / "darling-launcher.png") - # Remove artifacts created by older versions of this helper. - remove_legacy_entry(apps_base / "league-of-legends.desktop") - for legacy_icon in ( - icons_base / "league-of-legends.svg", - icons_base / "league-of-legends.png", - scalable_dir / "league-of-legends.svg", - icons_base / "hicolor/512x512/apps/league-of-legends.png", - ): - try: - if legacy_icon.exists(): - legacy_icon.unlink() - except OSError: - pass + # Remove artifacts created by older versions of this helper. Only files + # proven to be ours are deleted: an entry that pointed at this checkout's + # desktop-run.sh, and the SVG with the old artwork. The old PNG was + # extracted from the client and cannot be verified, so it is removed only + # when one of those proofs succeeded. + owned = remove_legacy_entry(apps_base / "league-of-legends.desktop") + desktop_dirs = shortcut_directories() + for directory in desktop_dirs: + owned = remove_legacy_entry(directory / "league-of-legends.desktop") or owned + legacy_svgs = (scalable_dir / "league-of-legends.svg", icons_base / "league-of-legends.svg") + if any(legacy_svg_owned(path) for path in legacy_svgs): + owned = True - # Create the desktop entry. Keep StartupWMClass: it is the client's real - # window class and is required for the compositor to match the window. - run_script = PROJECT_ROOT / "scripts/desktop-run.sh" - exec_path = str(run_script).replace("%", "%%") - exec_cmd = f'"{exec_path}"' if " " in exec_path else exec_path + if owned: + for legacy_icon in ( + *legacy_svgs, + icons_base / "league-of-legends.png", + icons_base / "hicolor/512x512/apps/league-of-legends.png", + ): + try: + legacy_icon.unlink(missing_ok=True) + except OSError: + pass + + # Create the desktop entry. Keep StartupWMClass: Darling's AppKit sets + # WM_CLASS from -[NSProcessInfo processName], which is the basename of + # argv[0], and the launcher starts .../RiotClientServices. It is an + # interoperability identifier, not a user-visible name. + # Terminal stays false here because this entry runs desktop-run.sh, which + # saves session output in build/desktop-launch.log; the packaged entry runs + # the session directly and therefore needs a terminal. + exec_cmd = quote_exec_argument(str(PROJECT_ROOT / LEGACY_RUN_SCRIPT)) + project_path = escape_string(str(PROJECT_ROOT)) desktop_content = f"""[Desktop Entry] Version=1.0 Type=Application @@ -68,12 +148,12 @@ Name=Darling Launcher GenericName=Game Comment=Unofficial launcher for the macOS game client via Darling Exec={exec_cmd} -Path={PROJECT_ROOT} +Path={project_path} Icon=darling-launcher Terminal=false -Categories=Game;ActionGame;StrategyGame; -Keywords=darling;launcher;compatibility; -StartupWMClass=Riot Client +Categories=Game; +Keywords=Darling;Launcher;Compatibility; +StartupWMClass=RiotClientServices StartupNotify=false """ @@ -82,22 +162,8 @@ StartupNotify=false os.chmod(desktop_file, 0o755) print(f"Created application launcher: {desktop_file}") - target_desktop_dirs = [] - try: - res = subprocess.run(["xdg-user-dir", "DESKTOP"], capture_output=True, text=True, check=False) - if res.returncode == 0 and res.stdout.strip(): - d = Path(res.stdout.strip()) - if d.is_dir() and d not in target_desktop_dirs: - target_desktop_dirs.append(d) - except Exception: - pass - for cand in [HOST_HOME / "Desktop"]: - if cand.is_dir() and cand not in target_desktop_dirs: - target_desktop_dirs.append(cand) - - for ddir in target_desktop_dirs: - remove_legacy_entry(ddir / "league-of-legends.desktop") - desktop_shortcut = ddir / "darling-launcher.desktop" + for directory in desktop_dirs: + desktop_shortcut = directory / "darling-launcher.desktop" desktop_shortcut.write_text(desktop_content) os.chmod(desktop_shortcut, 0o755) gio = shutil.which("gio") From 1b43e8f38cb8e9423446853770fd3a5b56c3155f Mon Sep 17 00:00:00 2001 From: Kalah <58551020+lucannez64@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:01:57 +0200 Subject: [PATCH 41/54] Add donations section to README Added a donations section with a link to Ko-Fi. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 9176f73..b6144f7 100644 --- a/README.md +++ b/README.md @@ -152,4 +152,7 @@ repository. See [compliance and terminology guidance](docs/COMPLIANCE.md) for the project's scope and review expectations. +## Donations +If you want to help me live or just pay me a coffee +you can at [Ko-Fi](https://ko-fi.com/lucannez64) From 415aa7a9490dfa7aefad7883f08286ec6dbdbf99 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 12:15:35 -0600 Subject: [PATCH 42/54] feat(launcher): hide the League client UI while a match runs Add an opt-in alternative to closing LeagueClientUx: hide the managed LeagueClientUx and Riot Client X11 windows while the game process runs and show them again when it exits. The client stays open; the game window is never touched. Detection is attributed to the launcher prefix through __mldr_sockpath and fails closed when proc, ownership or window state is unreadable. The helper requires X11/Xwayland, restores the window on end, signal or error, and is mutually exclusive with RCLOL_CLOSE_CLIENT_IN_GAME=1. Document the flag, compliance scope and limits. A local 30-second test on an i5-3470/RX 550 reduced summed client/CEF CPU from 120.7% to 93.4%; this is a CPU-bound-host measurement, not a universal FPS claim. No client binaries, CEF, assets or anti-cheat are modified. --- .github/workflows/source-check.yml | 1 + README.md | 8 + docs/SETUP.md | 8 + docs/TROUBLESHOOTING.md | 49 ++++ tests/test_hide_league_client_in_game.py | 165 +++++++++++++ tools/hide-league-client-in-game.py | 295 +++++++++++++++++++++++ tools/run-riot-client.sh | 11 + 7 files changed, 537 insertions(+) create mode 100644 tests/test_hide_league_client_in_game.py create mode 100755 tools/hide-league-client-in-game.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a866b9d..afc8de7 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -31,3 +31,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py' diff --git a/README.md b/README.md index b6144f7..97774de 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,14 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. +If you want to keep the client open during matches, opt in to hiding its window instead of closing it: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. + ## Testing Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..a83e8b7 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,6 +78,14 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. +`RCLOL_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` +and `Riot Client` X11 windows while a match runs, then showing them when the +game process ends. It keeps the client open and is mutually exclusive with +`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, +requires X11/Xwayland, and does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +for the measured CPU effect and limitations. + `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..2125d45 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,6 +192,55 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` +### Keep the client open but hide it during a match + +`LeagueClientUx` can consume a noticeable amount of CPU while the client stays +open during a match. An opt-in alternative to closing it hides the managed +client window instead: + +```sh +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +The launcher detects when a match starts, unmaps the `LeagueClientUx` and +`Riot Client` X11 windows, and maps them again when the game process ends. The +client is not closed, the League backend and Riot client keep running, and the +game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence +when both flags are set. + +The helper is off by default and window-management only: it does not patch +client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix +through the loader's `__mldr_sockpath`, and fails closed when ownership, proc +state, environment or window information cannot be read. It requires an X11 or +Xwayland desktop; on a native Wayland window manager without Xwayland it cannot +find the windows. + +On a CPU-bound host the effect appears after CEF has been hidden for a few +seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding +`LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from +120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the +window returned it to 119.9%. This is not a universal FPS or percentage claim: +a game that is GPU-bound, a client with no active UI work, or a different +desktop may show less benefit. The hidden window can also need a moment to +repaint after it is shown. + +`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client +chat during the match is not needed. Configure one of the two, not both: + +```sh +# Keep the client open, hide its windows while playing +RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh + +# Or close the client UI while playing +RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh +``` + +Diagnose the match detector without changing any window: + +```sh +python3 tools/hide-league-client-in-game.py --once +``` + ### Riot reports NO INTERNET FOUND while the host is online Older base runtimes ship Google DNS servers (`8.8.8.8` and `8.8.4.4`), which diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py new file mode 100644 index 0000000..22740fe --- /dev/null +++ b/tests/test_hide_league_client_in_game.py @@ -0,0 +1,165 @@ +"""Exercise the in-match client window hide with fake /proc entries and X11.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'hide_in_game', ROOT / 'tools/hide-league-client-in-game.py') +hide_in_game = importlib.util.module_from_spec(spec) +spec.loader.exec_module(hide_in_game) + +GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends' +MAIN = '/Applications/League of Legends.app/Contents/LoL/LeagueClientUx.app/Contents/MacOS/LeagueClientUx' +RIOT = '/Applications/Riot Client.app/Contents/MacOS/RiotClientServices' + + +class FakeX11: + def __init__(self): + self.unmapped = [] + self.shown = [] + + def unmap(self, window): + self.unmapped.append(window) + + def show(self, window): + self.shown.append(window) + + +class HideClientInGameTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + self.env_patch = patch.dict(os.environ, {'DPREFIX': str(self.prefix)}) + self.env_patch.start() + self.addCleanup(self.env_patch.stop) + + def environment(self, prefix): + return b'%s=%s\0' % ( + hide_in_game.SOCK_VARIABLE, + os.fsencode(prefix / hide_in_game.SOCK_NAME), + ) + + def process(self, pid, executable=GAME, prefix='own', environ=None, args=('--flag',)): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'cmdline').write_bytes( + executable.encode() + b'\0' + + b''.join(arg.encode() + b'\0' for arg in args)) + if environ is None: + environ = self.environment(self.prefix if prefix == 'own' else prefix) + (folder / 'environ').write_bytes(environ) + (folder / 'stat').write_text(f'{pid} (game) S') + return folder + + def test_game_running_for_own_prefix(self): + self.process(123) + self.assertTrue(hide_in_game.game_running(self.prefix, self.proc)) + + def test_no_game_is_false(self): + self.process(123, executable=MAIN) + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_of_another_prefix_is_false(self): + self.process(123, prefix=self.root / 'other') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_game_without_socket_is_false(self): + self.process(123, environ=b'') + self.assertFalse(hide_in_game.game_running(self.prefix, self.proc)) + + def test_unreadable_environment_is_unknown(self): + folder = self.process(123) + original = Path.read_bytes + + def read(path): + if path.name == 'environ': + raise PermissionError() + return original(path) + + with patch.object(Path, 'read_bytes', read): + self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc)) + + def test_target_windows_ignores_game_class(self): + with patch.object(hide_in_game, 'xprop_root', + return_value=['0x10', '0x20', '0x30']), \ + patch.object(hide_in_game, 'window_class', + side_effect=[ + 'wm_class(string) = "leagueclientux"', + 'wm_class(string) = "leagueoflegends"', + 'wm_class(string) = "riot client"', + ]): + self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30]) + + def test_hides_then_shows_on_match_end(self): + states = iter([True, True, False]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=3) + self.assertEqual(x11.unmapped, [0xabc]) + self.assertEqual(x11.shown, [0xabc]) + self.assertIn('hidden 0xabc', output.getvalue()) + self.assertIn('the match ended', output.getvalue()) + self.assertIn('shown 0xabc', output.getvalue()) + + def test_unreadable_state_restores_the_window(self): + states = iter([True, None]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xdef], + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + ) + watcher.run(max_steps=2) + self.assertEqual(x11.unmapped, [0xdef]) + self.assertEqual(x11.shown, [0xdef]) + self.assertIn('unreadable /proc state', output.getvalue()) + + def test_no_game_does_not_touch_windows(self): + x11 = FakeX11() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda: [0xabc], + state_source=lambda _prefix: False, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + watcher.run(max_steps=1) + self.assertEqual(x11.unmapped, []) + self.assertEqual(x11.shown, []) + + def test_main_is_disabled_by_default(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }): + self.assertEqual(hide_in_game.main([]), 0) + + def test_close_mode_takes_precedence(self): + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '1', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '1', + }): + self.assertEqual(hide_in_game.main([]), 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py new file mode 100755 index 0000000..2762c46 --- /dev/null +++ b/tools/hide-league-client-in-game.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Opt-in: hide the League client UI while a match is running. + +`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with +`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for +`LeagueClientUx` and `Riot Client`, and shows them again when the game process +exits. The Riot client, League backend, CEF helpers and game keep running. + +This is launcher/window management only. It does not patch client binaries, +CEF, anti-cheat or DRM, and it never hides the game window. It is off by +default, fails closed, and is intended for X11/Xwayland desktops. + +The game is attributed to this prefix through the loader's +`__mldr_sockpath`, because guest processes do not carry `DPREFIX`. If proc, +ownership or window information cannot be read, no window is touched. + +Diagnose one observation with: + + python3 tools/hide-league-client-in-game.py --once + +Enable it for a launcher run with: + + RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +""" + +import argparse +import ctypes +import os +import re +import signal +import subprocess +import sys +import time +from pathlib import Path + +GAME_NAME = 'LeagueofLegends' +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' +POLL_SECONDS = 2.0 +TARGET_CLASSES = ('leagueclientux', 'riot client') + + +def target_prefix(): + """The prefix this launcher owns, or None when it cannot be determined.""" + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def process_state(proc): + try: + return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0] + except (OSError, IndexError): + return None + + +def command_names(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return [] + return [ + os.path.basename(part.decode(errors='replace')) + for part in raw.split(b'\0') + if part + ] + + +def game_running(prefix, proc_root=Path('/proc')): + """True/False for a game of this prefix; None when /proc is unreliable. + + The game executable is matched by its exact file name. The process must + carry the loader socket of this prefix, so a game from another install is + never considered. Any unreadable candidate makes the result None (fail + closed). + """ + owner = (prefix / SOCK_NAME).parent.resolve() + try: + entries = list(proc_root.iterdir()) + except OSError: + return None + for proc in entries: + if not proc.name.isdigit() or process_state(proc) in ('Z', None): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + if GAME_NAME not in command_names(proc): + continue + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + socket = environment.get(SOCK_VARIABLE) + if socket is None: + continue + if Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + return True + except FileNotFoundError: + if proc.exists(): + return None + continue + except (OSError, ValueError): + return None + return False + + +def xprop_root(): + try: + listing = subprocess.check_output( + ['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return [] + return re.findall(r'0x[0-9a-fA-F]+', listing) + + +def window_class(window): + try: + prop = subprocess.check_output( + ['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return '' + return prop.lower() + + +def target_windows(): + """Managed client windows only; the game window is never selected.""" + found = [] + for window in xprop_root(): + classes = window_class(window) + if any(name in classes for name in TARGET_CLASSES): + found.append(int(window, 16)) + return sorted(set(found)) + + +class X11: + def __init__(self): + self.lib = ctypes.CDLL('libX11.so.6') + self.lib.XOpenDisplay.restype = ctypes.c_void_p + self.lib.XOpenDisplay.argtypes = [ctypes.c_char_p] + self.lib.XUnmapWindow.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XMapRaised.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + self.lib.XSync.argtypes = [ctypes.c_void_p, ctypes.c_int] + self.lib.XCloseDisplay.argtypes = [ctypes.c_void_p] + error_type = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) + self._error_handler = error_type(lambda _display, _event: 0) + self.lib.XSetErrorHandler.argtypes = [error_type] + self.lib.XSetErrorHandler.restype = ctypes.c_void_p + self.lib.XSetErrorHandler(self._error_handler) + self.display = self.lib.XOpenDisplay(None) + if not self.display: + raise RuntimeError('cannot open DISPLAY') + + def unmap(self, window): + self.lib.XUnmapWindow(self.display, window) + self.lib.XSync(self.display, 0) + + def show(self, window): + self.lib.XMapRaised(self.display, window) + self.lib.XSync(self.display, 0) + + def close(self): + self.lib.XCloseDisplay(self.display) + + +class Watcher: + """Hide while a match is running, restore on end, uncertainty or exit.""" + + def __init__(self, prefix, x11, window_source=target_windows, + state_source=game_running, sleep=time.sleep, out=sys.stdout): + self.prefix = prefix + self.x11 = x11 + self.window_source = window_source + self.state_source = state_source + self.sleep = sleep + self.out = out + self.hidden = set() + self.reported_no_window = False + + def _show_all(self, reason): + if not self.hidden: + return + print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True) + for window in sorted(self.hidden): + try: + self.x11.show(window) + print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', + file=sys.stderr, flush=True) + self.hidden.clear() + + def _hide_new(self): + windows = self.window_source() + if not windows and not self.hidden and not self.reported_no_window: + print('hide-league-client-in-game: no managed client window; not hiding', + file=self.out, flush=True) + self.reported_no_window = True + for window in windows: + if window in self.hidden: + continue + try: + self.x11.unmap(window) + self.hidden.add(window) + print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) + except Exception as error: + print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', + file=sys.stderr, flush=True) + + def run(self, max_steps=None): + steps = 0 + while True: + state = self.state_source(self.prefix) + if state is None: + self._show_all('unreadable /proc state') + elif state: + if not self.hidden and self.reported_no_window: + self.reported_no_window = False + self._hide_new() + else: + self._show_all('the match ended') + self.reported_no_window = False + steps += 1 + if max_steps is not None and steps >= max_steps: + return 0 + self.sleep(POLL_SECONDS) + + def restore(self): + self._show_all('exiting') + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--detach', action='store_true', + help='leave the launcher session so a terminal hang-up cannot end it') + parser.add_argument('--once', action='store_true', + help='one game observation and exit (no window changes)') + args = parser.parse_args(argv) + + if os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', + file=sys.stderr, flush=True) + return 0 + if os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', + flush=True) + return 0 + prefix = target_prefix() + if prefix is None: + print('hide-league-client-in-game: unusable DPREFIX: no action', + file=sys.stderr, flush=True) + return 1 + if args.once: + state = game_running(prefix) + print(f'hide-league-client-in-game: match={"unreadable" if state is None else "running" if state else "absent"}', + flush=True) + return 0 + if args.detach: + try: + os.setsid() + except OSError: + pass + try: + x11 = X11() + except Exception as error: + print(f'hide-league-client-in-game: cannot open X11: {error}', + file=sys.stderr, flush=True) + return 1 + + watcher = Watcher(prefix, x11) + + def stop(_signum, _frame): + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + print('hide-league-client-in-game: watching %s for a running match; ' + 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) + try: + watcher.run() + except KeyboardInterrupt: + pass + finally: + watcher.restore() + x11.close() + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..88dc083 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -19,6 +19,17 @@ fi if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & fi +# Opt-in alternative for users who keep the client open during a match: hide +# the managed client windows instead of closing the UI. It never touches the +# League backend, the Riot client or the game window; it restores the window +# when the game process ends. Close and hide are mutually exclusive. +if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 + else + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + fi +fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}" From 3c046252a757deae8355f44d2eab6d595f54213a Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 12:32:03 -0600 Subject: [PATCH 43/54] fix(launcher): address hide-client review findings - associate target windows with the selected prefix through the guest PID, X11 _NET_WM_PID, NSpid and the loader socket; fail closed when ownership is unknown; - let --once run without either opt-in gate, matching the documented diagnostic; - route SIGHUP through the restore handler; - cache non-empty WM_CLASS results per window and prune stale entries; - cover unreadable /proc, host PID mapping and prefix ownership in tests. --- tests/test_hide_league_client_in_game.py | 67 ++++++++++++- tools/hide-league-client-in-game.py | 120 ++++++++++++++++++++--- 2 files changed, 167 insertions(+), 20 deletions(-) diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 22740fe..87f4e05 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -88,6 +88,43 @@ class HideClientInGameTests(unittest.TestCase): with patch.object(Path, 'read_bytes', read): self.assertIsNone(hide_in_game.game_running(self.prefix, self.proc)) + def test_unreadable_process_directory_is_unknown(self): + class UnreadableProc: + def iterdir(self): + raise OSError() + + self.assertIsNone(hide_in_game.game_running(self.prefix, UnreadableProc())) + + def test_host_socket_for_guest_uses_nspid(self): + host = self.proc / '999' + host.mkdir() + (host / 'status').write_text('Name:\tCrBrowserMain\nNSpid:\t999\t5877\n') + (host / 'environ').write_bytes(self.environment(self.prefix)) + self.assertEqual( + hide_in_game.host_socket_for_guest(5877, self.proc), + os.fsencode(self.prefix / hide_in_game.SOCK_NAME), + ) + + def test_window_belongs_to_prefix(self): + socket = os.fsencode(self.prefix / hide_in_game.SOCK_NAME) + with patch.object(hide_in_game, 'window_pid', return_value=5877), \ + patch.object(hide_in_game, 'host_socket_for_guest', return_value=socket): + self.assertTrue(hide_in_game.window_belongs_to_prefix('0x10', self.prefix)) + self.assertFalse(hide_in_game.window_belongs_to_prefix( + '0x10', self.root / 'other')) + + def test_once_runs_without_opt_in(self): + output = io.StringIO() + with patch.dict(os.environ, { + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }), patch.object(hide_in_game, 'target_prefix', + return_value=self.prefix), \ + patch.object(hide_in_game, 'game_running', return_value=True), \ + patch.object(hide_in_game.sys, 'stdout', output): + self.assertEqual(hide_in_game.main(['--once']), 0) + self.assertIn('match=running', output.getvalue()) + def test_target_windows_ignores_game_class(self): with patch.object(hide_in_game, 'xprop_root', return_value=['0x10', '0x20', '0x30']), \ @@ -96,8 +133,28 @@ class HideClientInGameTests(unittest.TestCase): 'wm_class(string) = "leagueclientux"', 'wm_class(string) = "leagueoflegends"', 'wm_class(string) = "riot client"', - ]): - self.assertEqual(hide_in_game.target_windows(), [0x10, 0x30]) + ]), \ + patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True): + self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10, 0x30]) + + def test_target_windows_requires_this_prefix(self): + with patch.object(hide_in_game, 'xprop_root', + return_value=['0x10', '0x20']), \ + patch.object(hide_in_game, 'window_class', + return_value='wm_class(string) = "leagueclientux"'), \ + patch.object(hide_in_game, 'window_belongs_to_prefix', + side_effect=[True, False]): + self.assertEqual(hide_in_game.target_windows(self.prefix), [0x10]) + + def test_target_windows_caches_non_empty_classes(self): + cache = {} + with patch.object(hide_in_game, 'xprop_root', return_value=['0x10']), \ + patch.object(hide_in_game, 'window_class', + return_value='wm_class(string) = "leagueclientux"') as classes, \ + patch.object(hide_in_game, 'window_belongs_to_prefix', return_value=True): + self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) + self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) + self.assertEqual(classes.call_count, 1) def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) @@ -105,7 +162,7 @@ class HideClientInGameTests(unittest.TestCase): output = io.StringIO() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xabc], + window_source=lambda _prefix, _cache: [0xabc], state_source=lambda _prefix: next(states), sleep=lambda _seconds: None, out=output, @@ -123,7 +180,7 @@ class HideClientInGameTests(unittest.TestCase): output = io.StringIO() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xdef], + window_source=lambda _prefix, _cache: [0xdef], state_source=lambda _prefix: next(states), sleep=lambda _seconds: None, out=output, @@ -137,7 +194,7 @@ class HideClientInGameTests(unittest.TestCase): x11 = FakeX11() watcher = hide_in_game.Watcher( self.prefix, x11, - window_source=lambda: [0xabc], + window_source=lambda _prefix, _cache: [0xabc], state_source=lambda _prefix: False, sleep=lambda _seconds: None, out=io.StringIO(), diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 2762c46..91264d8 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -117,23 +117,112 @@ def xprop_root(): return re.findall(r'0x[0-9a-fA-F]+', listing) -def window_class(window): +def xprop_value(window, prop): try: - prop = subprocess.check_output( - ['xprop', '-id', window, 'WM_CLASS'], text=True, timeout=5 + return subprocess.check_output( + ['xprop', '-id', window, prop], text=True, timeout=5 ) except (OSError, subprocess.SubprocessError): return '' - return prop.lower() -def target_windows(): - """Managed client windows only; the game window is never selected.""" +def window_class(window): + return xprop_value(window, 'WM_CLASS').lower() + + +def window_pid(window): + match = re.search(r'=\s*(\d+)', xprop_value(window, '_NET_WM_PID')) + return int(match.group(1)) if match else None + + +def _socket_from_process(proc): + try: + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + except (OSError, ValueError): + return None + return environment.get(SOCK_VARIABLE) + + +def host_socket_for_guest(guest_pid, proc_root=Path('/proc')): + """Find the host process for a guest X11 PID and return its loader socket. + + Darling's X11 window stores the guest PID in _NET_WM_PID, while the + process visible in /proc is the host loader. NSpid carries both IDs. The + direct /proc/ case is checked too for a native process. + """ + direct = proc_root / str(guest_pid) + if direct.exists(): + socket = _socket_from_process(direct) + if socket is not None: + return socket + sockets = set() + try: + entries = list(proc_root.iterdir()) + except OSError: + return None + for proc in entries: + if not proc.name.isdigit(): + continue + try: + status = (proc / 'status').read_text() + except OSError: + continue + for line in status.splitlines(): + if not line.startswith('NSpid:'): + continue + if str(guest_pid) not in line.split()[1:]: + continue + socket = _socket_from_process(proc) + if socket is not None: + sockets.add(socket) + return sockets.pop() if len(sockets) == 1 else None + + +def window_belongs_to_prefix(window, prefix): + pid = window_pid(window) + if pid is None: + return False + socket = host_socket_for_guest(pid) + if socket is None: + return False + try: + return Path(os.fsdecode(socket)).parent.resolve() == (prefix / SOCK_NAME).parent.resolve() + except OSError: + return False + + +def target_windows(prefix, class_cache=None): + """Managed client windows for this prefix; the game window is never selected. + + WM_CLASS is cached only when non-empty. The cache is pruned when a window + leaves _NET_CLIENT_LIST; empty results are retried because a class can be + set after the window appears. + """ + current = [] + for raw in xprop_root(): + try: + current.append((int(raw, 16), raw)) + except ValueError: + continue + current_ids = {window for window, _raw in current} + if class_cache is not None: + for stale in set(class_cache) - current_ids: + del class_cache[stale] found = [] - for window in xprop_root(): - classes = window_class(window) - if any(name in classes for name in TARGET_CLASSES): - found.append(int(window, 16)) + for window, raw in current: + classes = class_cache.get(window) if class_cache is not None else None + if not classes: + classes = window_class(raw) + if classes and class_cache is not None: + class_cache[window] = classes + if not classes or not any(name in classes for name in TARGET_CLASSES): + continue + if window_belongs_to_prefix(raw, prefix): + found.append(window) return sorted(set(found)) @@ -179,6 +268,7 @@ class Watcher: self.sleep = sleep self.out = out self.hidden = set() + self.class_cache = {} self.reported_no_window = False def _show_all(self, reason): @@ -195,7 +285,7 @@ class Watcher: self.hidden.clear() def _hide_new(self): - windows = self.window_source() + windows = self.window_source(self.prefix, self.class_cache) if not windows and not self.hidden and not self.reported_no_window: print('hide-league-client-in-game: no managed client window; not hiding', file=self.out, flush=True) @@ -242,11 +332,11 @@ def main(argv=None): help='one game observation and exit (no window changes)') args = parser.parse_args(argv) - if os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', file=sys.stderr, flush=True) return 0 - if os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + if not args.once and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', flush=True) return 0 @@ -277,8 +367,8 @@ def main(argv=None): def stop(_signum, _frame): raise KeyboardInterrupt - signal.signal(signal.SIGTERM, stop) - signal.signal(signal.SIGINT, stop) + for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(signum, stop) print('hide-league-client-in-game: watching %s for a running match; ' 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) try: From 4faa62233676c5e04035238dae7abd23634054fb Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 14:56:03 -0600 Subject: [PATCH 44/54] feat(launcher): select client-in-game mode from preferences Add RCLOL_CLIENT_IN_GAME_TRICKS=1 as the practical launcher switch. It reads lol-user-experience from LCULocalPreferences.yaml: - potatoModeEnabled false or unreadable: no action; - closeLeagueClientDuringGame true: close the client UI; - closeLeagueClientDuringGame false: hide the client windows. The existing close and hide switches remain low-level options when the automatic switch is not set. The hide helper accepts the master switch, and the resolver plus preference cases are covered by tests and documentation. The local .desktop entry was validated with the master switch; the installed preferences select hide on this machine. --- .github/workflows/source-check.yml | 1 + README.md | 14 +++-- docs/SETUP.md | 17 +++--- docs/TROUBLESHOOTING.md | 56 +++++++++---------- tests/test_client_in_game_tricks.py | 69 ++++++++++++++++++++++++ tests/test_hide_league_client_in_game.py | 20 ++++++- tools/client-in-game-tricks.py | 57 ++++++++++++++++++++ tools/hide-league-client-in-game.py | 19 ++++--- tools/run-riot-client.sh | 39 ++++++++------ 9 files changed, 231 insertions(+), 61 deletions(-) create mode 100644 tests/test_client_in_game_tricks.py create mode 100755 tools/client-in-game-tricks.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index afc8de7..780121a 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -32,3 +32,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' - run: python -m unittest discover -s tests -p 'test_hide_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_client_in_game_tricks.py' diff --git a/README.md b/README.md index 97774de..51a3cf4 100644 --- a/README.md +++ b/README.md @@ -109,13 +109,21 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. -If you want to keep the client open during matches, opt in to hiding its window instead of closing it: +For automatic client handling during a match, opt in with one switch: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The helper detects the match, hides the `LeagueClientUx`/`Riot Client` X11 windows, and restores them when the game ends. It does not close the client, patch CEF or touch the game window. It is off by default, requires X11/Xwayland, and `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence. See [troubleshooting](docs/TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) for the measured CPU effect and caveats. +It reads `lol-user-experience` from the installed League client's +`LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the +client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or unreadable preferences it does +nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME` +low-level switches remain available when the automatic switch is not set. See +[troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match) +for the measured effect and caveats. ## Testing diff --git a/docs/SETUP.md b/docs/SETUP.md index a83e8b7..2679892 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,12 +78,17 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. -`RCLOL_HIDE_CLIENT_IN_GAME=1` opts in to hiding the managed `LeagueClientUx` -and `Riot Client` X11 windows while a match runs, then showing them when the -game process ends. It keeps the client open and is mutually exclusive with -`RCLOL_CLOSE_CLIENT_IN_GAME=1` (close takes precedence). It is off by default, -requires X11/Xwayland, and does not patch client binaries, CEF or the game. See -[the troubleshooting measurement](TROUBLESHOOTING.md#keep-the-client-open-but-hide-it-during-a-match) +`RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a +match. It reads `lol-user-experience` from the installed client's +`LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client +UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed +`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game +ends. With client optimizations disabled or preferences unreadable it does +nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and +`RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. The feature is off by default, requires X11/Xwayland, and +does not patch client binaries, CEF or the game. See +[the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured CPU effect and limitations. `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2125d45..0c37ba5 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,30 +192,39 @@ observation, without waiting for a match: python3 tools/close-league-client-in-game.py --once --dry-run ``` -### Keep the client open but hide it during a match +### Automatic client handling during a match -`LeagueClientUx` can consume a noticeable amount of CPU while the client stays -open during a match. An opt-in alternative to closing it hides the managed -client window instead: +A single opt-in switch reads the client's own preferences and chooses the mode: ```sh -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh +RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh ``` -The launcher detects when a match starts, unmaps the `LeagueClientUx` and -`Riot Client` X11 windows, and maps them again when the game process ends. The -client is not closed, the League backend and Riot client keep running, and the -game window is never hidden. `RCLOL_CLOSE_CLIENT_IN_GAME=1` takes precedence -when both flags are set. +It reads `lol-user-experience` from the installed client's +`LCULocalPreferences.yaml`: -The helper is off by default and window-management only: it does not patch -client binaries, CEF, anti-cheat or DRM. It attributes the game to this prefix -through the loader's `__mldr_sockpath`, and fails closed when ownership, proc -state, environment or window information cannot be read. It requires an X11 or -Xwayland desktop; on a native Wayland window manager without Xwayland it cannot -find the windows. +| Client preference | Launcher action | +|---|---| +| `potatoModeEnabled: false` or unreadable | no client-in-game helper | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI | +| `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows | -On a CPU-bound host the effect appears after CEF has been hidden for a few +The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps +them again when the game process ends. The client is not closed, the League +backend and Riot client keep running, and the game window is never hidden. The +close path uses the UI-close helper described above. + +The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and +`RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic +switch is not set. All modes are off by default. The helpers are +window-management only: they do not patch client binaries, CEF, anti-cheat or +DRM. They attribute the game to this prefix through the loader's +`__mldr_sockpath`, and fail closed when ownership, proc state, environment or +window information cannot be read. The hide helper requires an X11 or Xwayland +desktop; on a native Wayland window manager without Xwayland it cannot find the +windows. + +On a CPU-bound host the hide effect appears after CEF has been hidden for a few seconds. In a local 30-second test on an i5-3470 with a Radeon RX 550, hiding `LeagueClientUx` reduced the summed CPU of the matched client/CEF processes from 120.7% to 93.4% (about 23%, or 27 percentage points, of one core); showing the @@ -224,16 +233,9 @@ a game that is GPU-bound, a client with no active UI work, or a different desktop may show less benefit. The hidden window can also need a moment to repaint after it is shown. -`RCLOL_CLOSE_CLIENT_IN_GAME=1` remains the more aggressive option when in-client -chat during the match is not needed. Configure one of the two, not both: - -```sh -# Keep the client open, hide its windows while playing -RCLOL_HIDE_CLIENT_IN_GAME=1 ./scripts/run.sh - -# Or close the client UI while playing -RCLOL_CLOSE_CLIENT_IN_GAME=1 ./scripts/run.sh -``` +With client optimizations enabled and close-during-game enabled, the close +helper remains the more aggressive option when in-client chat during the match +is not needed. Diagnose the match detector without changing any window: diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py new file mode 100644 index 0000000..6477571 --- /dev/null +++ b/tests/test_client_in_game_tricks.py @@ -0,0 +1,69 @@ +"""Select the automatic client-in-game mode from LCULocalPreferences.yaml.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'client_in_game_tricks', ROOT / 'tools/client-in-game-tricks.py') +tricks = importlib.util.module_from_spec(spec) +spec.loader.exec_module(tricks) + + +class ClientInGameTricksTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / 'LCULocalPreferences.yaml' + + def write(self, optimization=None, close=None): + lines = ['lol-user-experience:', ' data:'] + if optimization is not None: + lines.append(f' potatoModeEnabled: {optimization}') + if close is not None: + lines.append(f' closeLeagueClientDuringGame: {close}') + self.path.write_text('\n'.join(lines) + '\n') + + def test_close_when_both_preferences_are_true(self): + self.write(True, True) + self.assertEqual(tricks.mode(self.path), 'close') + + def test_hide_when_optimizations_are_on_and_close_is_false(self): + self.write(True, False) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_hide_when_close_preference_is_missing(self): + self.write(True) + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_none_when_optimizations_are_off(self): + self.write(False, True) + self.assertEqual(tricks.mode(self.path), 'none') + + def test_none_when_optimizations_are_missing_or_file_is_unreadable(self): + self.write(None, True) + self.assertEqual(tricks.mode(self.path), 'none') + self.assertEqual(tricks.mode(self.path.with_name('missing.yaml')), 'none') + + def test_last_duplicate_value_wins_and_quotes_are_accepted(self): + self.path.write_text( + 'potatoModeEnabled: false\n' + 'closeLeagueClientDuringGame: false\n' + 'potatoModeEnabled: "true"\n' + 'closeLeagueClientDuringGame: "true"\n') + self.assertEqual(tricks.mode(self.path), 'close') + + def test_main_prints_mode(self): + self.write(True, False) + output = io.StringIO() + with patch('sys.stdout', output): + self.assertEqual(tricks.main([str(self.path)]), 0) + self.assertEqual(output.getvalue().strip(), 'hide') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 87f4e05..95be689 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -5,7 +5,7 @@ import os from pathlib import Path import tempfile import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch ROOT = Path(__file__).resolve().parents[1] spec = importlib.util.spec_from_file_location( @@ -217,6 +217,24 @@ class HideClientInGameTests(unittest.TestCase): }): self.assertEqual(hide_in_game.main([]), 0) + def test_master_tricks_switch_enables_hide(self): + class FakeX11: + def close(self): + pass + + watcher = Mock() + with patch.dict(os.environ, { + 'RCLOL_CLIENT_IN_GAME_TRICKS': '1', + 'RCLOL_HIDE_CLIENT_IN_GAME': '', + 'RCLOL_CLOSE_CLIENT_IN_GAME': '', + }), patch.object(hide_in_game, 'target_prefix', + return_value=self.prefix), \ + patch.object(hide_in_game, 'X11', return_value=FakeX11()), \ + patch.object(hide_in_game, 'Watcher', return_value=watcher): + self.assertEqual(hide_in_game.main([]), 0) + watcher.run.assert_called_once_with() + watcher.restore.assert_called_once_with() + if __name__ == '__main__': unittest.main() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py new file mode 100755 index 0000000..bdc8d6b --- /dev/null +++ b/tools/client-in-game-tricks.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Resolve the client-in-game mode from League's local preferences. + +The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads +`lol-user-experience` from LCULocalPreferences.yaml: + +- potatoModeEnabled must be true (client optimizations enabled); +- closeLeagueClientDuringGame true -> close the client UI while a match runs; +- closeLeagueClientDuringGame false -> hide the client windows instead; +- missing/unreadable preferences -> no action (none). + +This helper only selects a mode. It does not change preferences or files. +""" + +import re +import sys +from pathlib import Path + +CLOSE_KEY = 'closeLeagueClientDuringGame' +OPTIMIZATION_KEY = 'potatoModeEnabled' +TRUE_VALUES = {'true', 'yes', '1'} +FALSE_VALUES = {'false', 'no', '0'} + + +def read_bool(path, key): + """Return True/False for the last value, or None when absent/unreadable.""" + try: + lines = Path(path).read_text().splitlines() + except (OSError, UnicodeError): + return None + value = None + for line in lines: + match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line) + if match: + value = match.group(1).strip('"\'').lower() + if value in TRUE_VALUES: + return True + if value in FALSE_VALUES: + return False + return None + + +def mode(path): + if read_bool(path, OPTIMIZATION_KEY) is not True: + return 'none' + return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide' + + +def main(argv=None): + argv = sys.argv[1:] if argv is None else argv + path = argv[0] if argv else '' + print(mode(path)) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 91264d8..68ad0b9 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Opt-in: hide the League client UI while a match is running. -`RCLOL_HIDE_CLIENT_IN_GAME=1` is an alternative to closing the client UI with -`RCLOL_CLOSE_CLIENT_IN_GAME=1`. It hides the managed X11 windows for -`LeagueClientUx` and `Riot Client`, and shows them again when the game process -exits. The Riot client, League backend, CEF helpers and game keep running. +The launcher's `RCLOL_CLIENT_IN_GAME_TRICKS=1` mode selects this helper when +the client's own close-during-game preference is false. The low-level +`RCLOL_HIDE_CLIENT_IN_GAME=1` switch remains available for direct launches. It +hides the managed X11 windows for `LeagueClientUx` and `Riot Client`, and shows +them again when the game process exits. The Riot client, League backend, CEF +helpers and game keep running. This is launcher/window management only. It does not patch client binaries, CEF, anti-cheat or DRM, and it never hides the game window. It is off by @@ -332,13 +334,14 @@ def main(argv=None): help='one game observation and exit (no window changes)') args = parser.parse_args(argv) - if not args.once and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': + tricks = os.environ.get('RCLOL_CLIENT_IN_GAME_TRICKS') == '1' + if not args.once and not tricks and os.environ.get('RCLOL_CLOSE_CLIENT_IN_GAME') == '1': print('hide-league-client-in-game: ignored; RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence', file=sys.stderr, flush=True) return 0 - if not args.once and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': - print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1', - flush=True) + if not args.once and not tricks and os.environ.get('RCLOL_HIDE_CLIENT_IN_GAME') != '1': + print('hide-league-client-in-game: disabled; set RCLOL_HIDE_CLIENT_IN_GAME=1 or ' + 'RCLOL_CLIENT_IN_GAME_TRICKS=1', flush=True) return 0 prefix = target_prefix() if prefix is None: diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 88dc083..7d76a6d 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,23 +12,30 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi -# Opt-in: close the League client UI while a match runs. It keeps the CPU for the game -# and leaves the client the backend starts when the match ends as the only CEF instance; -# a second instance on the same profile never paints its window, which is the black -# window seen with Riot's own "Close client during game" option. Off by default. -if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then +# Opt-in automatic client-in-game handling. This single switch reads the +# client's own lol-user-experience preferences: close the UI when the user +# selected "close client during game", otherwise hide the client windows. +# It does nothing unless potatoModeEnabled (client optimizations) is true. +if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then + pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" + mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) + case "$mode" in + close) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 + python3 "$runner_dir/close-league-client-in-game.py" --detach & + ;; + hide) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + ;; + *) + echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2 + ;; + esac +elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then python3 "$runner_dir/close-league-client-in-game.py" --detach & -fi -# Opt-in alternative for users who keep the client open during a match: hide -# the managed client windows instead of closing the UI. It never touches the -# League backend, the Riot client or the game window; it restores the window -# when the game process ends. Close and hide are mutually exclusive. -if [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then - if [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then - echo 'RCLOL_HIDE_CLIENT_IN_GAME ignored: RCLOL_CLOSE_CLIENT_IN_GAME=1 takes precedence' >&2 - else - python3 "$runner_dir/hide-league-client-in-game.py" --detach & - fi +elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/hide-league-client-in-game.py" --detach & fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. From 5a06f34725d9eed8552f0ece763c42b37311ee5b Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:07:47 -0600 Subject: [PATCH 45/54] fix(launcher): fail closed on automatic mode edge cases - restrict preference reads to the lol-user-experience section; - distinguish a failed close-preference read from a missing key; - reject xprop's 'not found' text as a WM_CLASS value; - track a window before unmap so an interrupting signal still restores it; - report the tricks-mode opt-out in the hide helper's startup message; - cover the new cases with preference, window and interruption tests. --- tests/test_client_in_game_tricks.py | 26 +++++++++++-- tests/test_hide_league_client_in_game.py | 43 +++++++++++++++++++++ tools/client-in-game-tricks.py | 49 +++++++++++++++++------- tools/hide-league-client-in-game.py | 10 +++-- 4 files changed, 107 insertions(+), 21 deletions(-) diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 6477571..4b19d28 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -51,12 +51,30 @@ class ClientInGameTricksTests(unittest.TestCase): def test_last_duplicate_value_wins_and_quotes_are_accepted(self): self.path.write_text( - 'potatoModeEnabled: false\n' - 'closeLeagueClientDuringGame: false\n' - 'potatoModeEnabled: "true"\n' - 'closeLeagueClientDuringGame: "true"\n') + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: false\n' + ' closeLeagueClientDuringGame: false\n' + ' potatoModeEnabled: "true"\n' + ' closeLeagueClientDuringGame: "true"\n') self.assertEqual(tricks.mode(self.path), 'close') + def test_keys_outside_the_named_section_are_ignored(self): + self.path.write_text( + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: true\n' + 'other-section:\n' + ' potatoModeEnabled: true\n' + ' closeLeagueClientDuringGame: true\n') + self.assertEqual(tricks.mode(self.path), 'hide') + + def test_second_read_failure_fails_closed(self): + self.write(True, False) + text = self.path.read_text() + with patch.object(Path, 'read_text', side_effect=[text, OSError()]): + self.assertEqual(tricks.mode(self.path), 'none') + def test_main_prints_mode(self): self.write(True, False) output = io.StringIO() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 95be689..946bbad 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -156,6 +156,49 @@ class HideClientInGameTests(unittest.TestCase): self.assertEqual(hide_in_game.target_windows(self.prefix, cache), [0x10]) self.assertEqual(classes.call_count, 1) + def test_missing_wm_class_is_not_cached_as_a_class(self): + with patch.object(hide_in_game, 'xprop_value', + return_value='WM_CLASS: not found.'): + self.assertEqual(hide_in_game.window_class('0x10'), '') + + def test_unmap_failure_removes_the_window_from_hidden(self): + class FailingX11(FakeX11): + def unmap(self, window): + raise RuntimeError('unmap failed') + + watcher = hide_in_game.Watcher( + self.prefix, FailingX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + watcher._hide_new() + self.assertEqual(watcher.hidden, set()) + + def test_interrupted_unmap_keeps_the_window_tracked(self): + shown = [] + + class InterruptingX11: + def unmap(self, window): + raise KeyboardInterrupt + + def show(self, window): + shown.append(window) + + watcher = hide_in_game.Watcher( + self.prefix, InterruptingX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=io.StringIO(), + ) + with self.assertRaises(KeyboardInterrupt): + watcher._hide_new() + self.assertEqual(watcher.hidden, {0xabc}) + watcher.restore() + self.assertEqual(shown, [0xabc]) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index bdc8d6b..adf28c6 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -7,32 +7,45 @@ The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads - potatoModeEnabled must be true (client optimizations enabled); - closeLeagueClientDuringGame true -> close the client UI while a match runs; - closeLeagueClientDuringGame false -> hide the client windows instead; -- missing/unreadable preferences -> no action (none). +- missing preferences or an unreadable file -> no action (none). -This helper only selects a mode. It does not change preferences or files. +Only the named `lol-user-experience` section is considered, so identical keys +in another section cannot select a helper. This helper only selects a mode; it +does not change preferences or files. """ -import re import sys from pathlib import Path +SECTION = 'lol-user-experience' CLOSE_KEY = 'closeLeagueClientDuringGame' OPTIMIZATION_KEY = 'potatoModeEnabled' TRUE_VALUES = {'true', 'yes', '1'} FALSE_VALUES = {'false', 'no', '0'} -def read_bool(path, key): - """Return True/False for the last value, or None when absent/unreadable.""" - try: - lines = Path(path).read_text().splitlines() - except (OSError, UnicodeError): - return None +def read_bool(path, key, section=SECTION): + """Return True/False for the last key in section, or None when absent. + + OSError/UnicodeError is raised for an unreadable file so callers can fail + closed instead of treating a read race as a missing preference. + """ + lines = Path(path).read_text().splitlines() + in_section = False value = None for line in lines: - match = re.match(r'^\s*' + re.escape(key) + r':\s*(\S+)', line) - if match: - value = match.group(1).strip('"\'').lower() + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if not line.startswith((' ', '\t')): + if stripped == section + ':': + in_section = True + elif in_section: + break + continue + if not in_section or not stripped.startswith(key + ':'): + continue + value = stripped.split(':', 1)[1].strip().strip('"\'').lower() if value in TRUE_VALUES: return True if value in FALSE_VALUES: @@ -41,9 +54,17 @@ def read_bool(path, key): def mode(path): - if read_bool(path, OPTIMIZATION_KEY) is not True: + try: + optimization = read_bool(path, OPTIMIZATION_KEY) + except (OSError, UnicodeError): return 'none' - return 'close' if read_bool(path, CLOSE_KEY) is True else 'hide' + if optimization is not True: + return 'none' + try: + close = read_bool(path, CLOSE_KEY) + except (OSError, UnicodeError): + return 'none' + return 'close' if close is True else 'hide' def main(argv=None): diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 68ad0b9..bf6f212 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -129,7 +129,8 @@ def xprop_value(window, prop): def window_class(window): - return xprop_value(window, 'WM_CLASS').lower() + value = xprop_value(window, 'WM_CLASS').lower() + return value if '=' in value else '' def window_pid(window): @@ -295,11 +296,12 @@ class Watcher: for window in windows: if window in self.hidden: continue + self.hidden.add(window) try: self.x11.unmap(window) - self.hidden.add(window) print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) except Exception as error: + self.hidden.discard(window) print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', file=sys.stderr, flush=True) @@ -372,8 +374,10 @@ def main(argv=None): for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): signal.signal(signum, stop) + optout = ('RCLOL_CLIENT_IN_GAME_TRICKS=0' if tricks + else 'RCLOL_HIDE_CLIENT_IN_GAME=0') print('hide-league-client-in-game: watching %s for a running match; ' - 'opt out with RCLOL_HIDE_CLIENT_IN_GAME=0' % prefix, flush=True) + 'opt out with %s' % (prefix, optout), flush=True) try: watcher.run() except KeyboardInterrupt: From c8dd3886fc91e82f47c99cd69e8338fa94f2bd7e Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:27:33 -0600 Subject: [PATCH 46/54] fix(launcher): recover a stale client tree without restarting Darling Darlingserver normally stays alive after the Riot Client or game is closed. If the client window disappears but Riot/League/CEF processes remain, restarting via run.sh reuses a half-dead runtime and can fail to open a window. Add an opt-in RCLOL_RECOVER_STALE_CLIENT=1 preflight. When a darlingserver is alive, no game is running, no managed client window is visible and stale client processes belong to this prefix, stop only that client/CEF tree. Preserve darlingserver, launchd/mldr, sockets and .init.pid, then continue with the normal product launch. The helper fails closed when X11 is unavailable, never touches a running game or a visible client window, and does not patch client binaries, CEF or assets. It saves the Darling bootstrap, but not Riot Client/CEF startup; a full runtime restart remains the fallback if no window appears. --- .github/workflows/source-check.yml | 1 + README.md | 8 ++ docs/SETUP.md | 8 ++ docs/TROUBLESHOOTING.md | 9 ++ tests/test_recover_stale_client.py | 110 +++++++++++++++ tools/recover-stale-client.py | 212 +++++++++++++++++++++++++++++ tools/run-riot-client.sh | 6 + 7 files changed, 354 insertions(+) create mode 100644 tests/test_recover_stale_client.py create mode 100755 tools/recover-stale-client.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index a866b9d..df99278 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -31,3 +31,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_metal_device_info.py' - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' + - run: python -m unittest discover -s tests -p 'test_recover_stale_client.py' diff --git a/README.md b/README.md index b6144f7..111ae26 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,14 @@ First installation and future Riot updates can still encounter compatibility gap For another region, set `RCLOL_REGION` and `RCLOL_LOCALE` before launch. For example `RCLOL_REGION=NA RCLOL_LOCALE=en_US ./scripts/run.sh`. Linux display selection follows `$DISPLAY`; the legacy launcher fallback is `:1`. +If the game or Riot client was closed but `darlingserver` is still running, opt in to partial recovery before the next launch: + +```sh +RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh +``` + +It stops only a windowless Riot/League/CEF tree, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). + ## Testing Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. diff --git a/docs/SETUP.md b/docs/SETUP.md index 0c0d39b..e3cafa8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -78,6 +78,14 @@ opts in to Hyprland-only recovery of a default-prefix Riot session with no visible window; it is off by default. See [troubleshooting](TROUBLESHOOTING.md#riot-exits-and-league-reports-that-it-lost-its-connection) for the observed failures, recovery guards and NSURLSession limitations. +`RCLOL_RECOVER_STALE_CLIENT=1` opts in to partial runtime recovery before +launch. If `darlingserver` is alive, no game is running, no managed client +window is visible and stale Riot/League/CEF processes remain, the launcher stops +only that client tree and reuses the runtime. It preserves `darlingserver`, +launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it +does nothing. See +[troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window). + `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 13a2f7b..64d4289 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -149,6 +149,15 @@ ownership or window information cannot be read. A hidden background client or download is restarted when recovery is enabled. Other desktop environments are not supported by this helper; recovery is off by default. +For a live Darling runtime with no visible client window but stale Riot/League/ +CEF processes, `RCLOL_RECOVER_STALE_CLIENT=1` performs a partial recovery before +the next launch. It keeps `darlingserver`, launchd/mldr, sockets and `.init.pid`, +stops only the stale client process tree, and then lets the normal launcher start +the product again. It does nothing while a game process is running or when a +managed client window is visible. With X11/Xwayland unavailable it fails closed. +This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If +no window appears afterward, use a full runtime shutdown/restart. + ### Client window stays black after a match The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers) diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py new file mode 100644 index 0000000..743907f --- /dev/null +++ b/tests/test_recover_stale_client.py @@ -0,0 +1,110 @@ +"""Test the opt-in partial client-tree recovery with fake /proc entries.""" +import importlib.util +import io +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'recover_stale_client', ROOT / 'tools/recover-stale-client.py') +recover_stale_client = importlib.util.module_from_spec(spec) +spec.loader.exec_module(recover_stale_client) + +GAME = '/Applications/League of Legends.app/Contents/LoL/LeagueofLegends' +CLIENT = '/Users/Shared/Riot Games/Riot Client.app/Contents/MacOS/RiotClientServices' + + +class RecoverStaleClientTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + + def process(self, pid, comm, argv, env=None, executable=None): + folder = self.proc / str(pid) + folder.mkdir() + (folder / 'comm').write_text(comm + '\n') + (folder / 'cmdline').write_bytes( + (argv[0] if executable is None else executable).encode() + b'\0' + + b''.join(arg.encode() + b'\0' for arg in argv[1:])) + (folder / 'stat').write_text(f'{pid} ({comm}) S') + if env is not None: + (folder / 'environ').write_bytes(env) + return folder + + def client_env(self, prefix=None): + prefix = self.prefix if prefix is None else prefix + return b'%s=%s\0' % ( + recover_stale_client.SOCK_VARIABLE, + os.fsencode(prefix / recover_stale_client.SOCK_NAME), + ) + + def run_recover(self, window=False): + killed = [] + + def kill(pid, signum): + killed.append((pid, signum)) + + out = io.StringIO() + with patch.object(recover_stale_client, 'visible_client_window', + return_value=window): + result = recover_stale_client.recover( + self.prefix, proc_root=self.proc, window_check=lambda: window, + kill=kill, sleep=lambda _seconds: None, out=out) + return result, killed, out.getvalue() + + def test_no_darlingserver_does_nothing(self): + self.process(10, 'mldr', ['/sbin/launchd']) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no darlingserver', output) + + def test_game_is_never_touched(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [GAME]) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('game running', output) + + def test_visible_window_does_nothing(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [CLIENT], self.client_env()) + result, killed, output = self.run_recover(window=True) + self.assertEqual((result, killed), (0, [])) + self.assertIn('client window present', output) + + def test_stale_client_is_killed_but_runtime_is_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', ['/sbin/launchd']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + result, killed, output = self.run_recover() + self.assertEqual(result, 0) + self.assertEqual([pid for pid, _sig in killed], [12, 12]) + self.assertIn('stale client tree', output) + + def test_other_prefix_and_game_are_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', [CLIENT], self.client_env(self.root / 'other')) + self.process(12, 'mldr', [GAME], self.client_env()) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('game running', output) + + def test_runtime_process_names_are_not_killed(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(11, 'mldr', ['/sbin/launchd']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + result, killed, _output = self.run_recover() + self.assertEqual(result, 0) + self.assertNotIn(10, [pid for pid, _sig in killed]) + self.assertNotIn(11, [pid for pid, _sig in killed]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py new file mode 100755 index 0000000..3c29805 --- /dev/null +++ b/tools/recover-stale-client.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Partially recover a live Darling runtime with a stale client tree. + +This is opt-in through RCLOL_RECOVER_STALE_CLIENT=1. It only acts when all +of these are true: + +- a darlingserver is running; +- no League game process is running; +- no managed Riot/League client window is visible; +- one or more Riot/League client, CEF or crash-handler processes are still + present for this prefix. + +It terminates only those client processes with SIGTERM/SIGKILL. The +darlingserver, launchd/mldr services, sockets and .init.pid stay intact so the +next launcher run can reuse the runtime. It never patches files or the game. +""" + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +GAME_NAME = 'LeagueofLegends' +WINDOW_CLASSES = ('leagueclientux', 'riot client') +CLIENT_MARKERS = ( + '/Applications/League of Legends.app', + '/Applications/Riot Client.app', + '/Users/Shared/Riot Games/Riot Client.app', + 'RiotClientServices', + 'Riot Client', + 'LeagueClient', + 'LeagueClientUx', + 'LeagueCrashHandler', +) +RUNTIME_NAMES = ('darlingserver', 'darling') +SOCK_VARIABLE = b'__mldr_sockpath' +SOCK_NAME = '.darlingserver.sock' + + +def target_prefix(): + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def command_names(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return [] + return [ + os.path.basename(part.decode(errors='replace')) + for part in raw.split(b'\0') + if part + ] + + +def command_text(proc): + try: + raw = (proc / 'cmdline').read_bytes() + except OSError: + return '' + return b' '.join(part for part in raw.split(b'\0') if part).decode( + errors='replace') + + +def process_state(proc): + try: + return (proc / 'stat').read_text().rsplit(')', 1)[1].split()[0] + except (OSError, IndexError): + return None + + +def server_running(proc_root=Path('/proc')): + try: + entries = list(proc_root.iterdir()) + except OSError: + return False + for proc in entries: + if not proc.name.isdigit(): + continue + try: + if (proc / 'comm').read_text().strip() == 'darlingserver': + return True + except OSError: + pass + return False + + +def game_running(proc_root=Path('/proc')): + for proc in proc_root.iterdir() if proc_root.exists() else (): + if not proc.name.isdigit(): + continue + names = command_names(proc) + if GAME_NAME in names: + return True + return False + + +def visible_client_window(): + """Return True when a managed client window is present. + + A missing xprop or display is treated as 'cannot prove there is no + window' so we do not kill a possibly working client. + """ + try: + listing = subprocess.check_output( + ['xprop', '-root', '_NET_CLIENT_LIST'], text=True, timeout=5 + ) + except (OSError, subprocess.SubprocessError): + return True + for raw in listing.split(): + if not raw.startswith('0x'): + continue + try: + classes = subprocess.check_output( + ['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5 + ).lower() + except (OSError, subprocess.SubprocessError): + return True + if any(name in classes for name in WINDOW_CLASSES): + return True + return False + + +def client_pids(prefix, proc_root=Path('/proc')): + """Client/CEF PIDs belonging to prefix, excluding Darling and the game.""" + owner = (prefix / SOCK_NAME).parent.resolve() + found = [] + try: + entries = list(proc_root.iterdir()) + except OSError: + return [] + for proc in entries: + if not proc.name.isdigit() or process_state(proc) in ('Z', None): + continue + try: + if proc.stat().st_uid != os.getuid(): + continue + names = command_names(proc) + if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names): + continue + text = command_text(proc) + if not any(marker in text for marker in CLIENT_MARKERS): + continue + try: + env = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + except OSError: + continue + socket = env.get(SOCK_VARIABLE) + if socket is None: + continue + if Path(os.fsdecode(socket)).parent.resolve() != owner: + continue + found.append(int(proc.name)) + except (OSError, ValueError): + continue + return sorted(found) + + +def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window, + kill=os.kill, sleep=time.sleep, out=sys.stdout): + if not server_running(proc_root): + print('recover-stale-client: no darlingserver; normal startup', file=out, flush=True) + return 0 + if game_running(proc_root): + print('recover-stale-client: game running; no action', file=out, flush=True) + return 0 + if window_check(): + print('recover-stale-client: client window present; no action', file=out, flush=True) + return 0 + pids = client_pids(prefix, proc_root) + if not pids: + print('recover-stale-client: no stale client processes; no action', file=out, flush=True) + return 0 + print('recover-stale-client: stale client tree without a window; ' + 'terminating %d process(es)' % len(pids), file=out, flush=True) + for pid in pids: + try: + kill(pid, signal.SIGTERM) + except OSError: + pass + sleep(4) + for pid in pids: + try: + kill(pid, signal.SIGKILL) + except OSError: + pass + return 0 + + +def main(): + if os.environ.get('RCLOL_RECOVER_STALE_CLIENT') != '1': + print('recover-stale-client: disabled; set RCLOL_RECOVER_STALE_CLIENT=1', + flush=True) + return 0 + prefix = target_prefix() + if prefix is None: + print('recover-stale-client: unusable DPREFIX; no action', + file=sys.stderr, flush=True) + return 1 + return recover(prefix) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 767294b..085434e 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -9,6 +9,12 @@ if [ "${RCLOL_CORE_DUMPS:-0}" != 1 ]; then fi runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) . "$runner_dir/runtime-paths.sh" +# Opt-in partial recovery: if Darling is alive but a previous client tree is +# windowless, stop only that client/CEF tree and reuse the runtime. Never +# touches the game, darlingserver or the runtime sockets. +if [ "${RCLOL_RECOVER_STALE_CLIENT:-0}" = 1 ]; then + python3 "$runner_dir/recover-stale-client.py" +fi if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi From 732e9c8881b0252b19f899d9a061d0999d8fe699 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:30:45 -0600 Subject: [PATCH 47/54] fix(launcher): handle YAML comments and log failures - strip inline YAML comments from preference values, including quoted values; - keep a successfully unmapped window tracked when the success log raises BrokenPipeError, while still discarding a window whose unmap failed; - cover both cases with tests. --- tests/test_client_in_game_tricks.py | 8 ++++++++ tests/test_hide_league_client_in_game.py | 18 ++++++++++++++++++ tools/client-in-game-tricks.py | 9 ++++++++- tools/hide-league-client-in-game.py | 8 +++++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 4b19d28..53a0db3 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -69,6 +69,14 @@ class ClientInGameTricksTests(unittest.TestCase): ' closeLeagueClientDuringGame: true\n') self.assertEqual(tricks.mode(self.path), 'hide') + def test_inline_yaml_comments_are_ignored(self): + self.path.write_text( + 'lol-user-experience:\n' + ' data:\n' + ' potatoModeEnabled: true # client optimizations\n' + ' closeLeagueClientDuringGame: "true" # close during matches\n') + self.assertEqual(tricks.mode(self.path), 'close') + def test_second_read_failure_fails_closed(self): self.write(True, False) text = self.path.read_text() diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index 946bbad..f03e8c1 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -199,6 +199,24 @@ class HideClientInGameTests(unittest.TestCase): watcher.restore() self.assertEqual(shown, [0xabc]) + def test_log_failure_does_not_discard_an_unmapped_window(self): + class BrokenOutput: + def write(self, _text): + raise BrokenPipeError + + def flush(self): + pass + + watcher = hide_in_game.Watcher( + self.prefix, FakeX11(), + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=BrokenOutput(), + ) + watcher._hide_new() + self.assertEqual(watcher.hidden, {0xabc}) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index adf28c6..1b7a314 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -45,7 +45,14 @@ def read_bool(path, key, section=SECTION): continue if not in_section or not stripped.startswith(key + ':'): continue - value = stripped.split(':', 1)[1].strip().strip('"\'').lower() + value = stripped.split(':', 1)[1].strip() + if value[:1] in ('"', "'"): + quote = value[0] + end = value.find(quote, 1) + value = value[1:end] if end != -1 else value[1:] + else: + value = value.split('#', 1)[0].strip() + value = value.lower() if value in TRUE_VALUES: return True if value in FALSE_VALUES: diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index bf6f212..22b9ca2 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -299,11 +299,17 @@ class Watcher: self.hidden.add(window) try: self.x11.unmap(window) - print(f'hide-league-client-in-game: hidden 0x{window:x}', file=self.out, flush=True) except Exception as error: self.hidden.discard(window) print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', file=sys.stderr, flush=True) + continue + try: + print(f'hide-league-client-in-game: hidden 0x{window:x}', + file=self.out, flush=True) + except Exception: + # Logging must not lose a successfully unmapped window. + pass def run(self, max_steps=None): steps = 0 From 0a04d4e16c7e3ad73d41445513010bd72993bdec Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Thu, 22 Oct 2026 15:50:38 -0600 Subject: [PATCH 48/54] fix(launcher): remove orphaned client-in-game helpers A launcher can exit while its long-lived close/hide helper remains. The next run then starts another watcher, so several copies watch the same prefix. Add an opt-in RCLOL_CLEAN_STALE_HELPERS=1 preflight that removes only helper processes of this prefix reparented to init/systemd. Helpers of a live launcher, other prefixes, the game, Riot/League/CEF, darlingserver and the runtime are preserved. Unreadable or relative process data leaves a process alone. Validated across repeated launcher starts: the cleanup removed four, then one, then one orphaned watcher; a subsequent dry run reported no orphaned helpers. Zombie reaping after partial recovery is tracked separately in #124. --- .github/workflows/source-check.yml | 1 + README.md | 12 ++ docs/SETUP.md | 9 ++ docs/TROUBLESHOOTING.md | 24 ++++ tests/test_clean_stale_helpers.py | 110 +++++++++++++++++ tools/clean-stale-helpers.py | 183 +++++++++++++++++++++++++++++ tools/run-riot-client.sh | 7 ++ 7 files changed, 346 insertions(+) create mode 100644 tests/test_clean_stale_helpers.py create mode 100755 tools/clean-stale-helpers.py diff --git a/.github/workflows/source-check.yml b/.github/workflows/source-check.yml index df99278..9594dc3 100644 --- a/.github/workflows/source-check.yml +++ b/.github/workflows/source-check.yml @@ -32,3 +32,4 @@ jobs: - run: python -m unittest discover -s tests -p 'test_exec_guest.py' - run: python -m unittest discover -s tests -p 'test_close_league_client_in_game.py' - run: python -m unittest discover -s tests -p 'test_recover_stale_client.py' + - run: python -m unittest discover -s tests -p 'test_clean_stale_helpers.py' diff --git a/README.md b/README.md index 111ae26..2e7ebf0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,18 @@ RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh It stops only a windowless Riot/League/CEF tree, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). +If repeated launches leave several `close-league-client-in-game.py` watchers, +opt in to removing the orphaned ones before the new helper starts: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh +``` + +It only removes helpers of this prefix whose parent is `init`/`systemd` (the +launcher that started them has exited). A helper of a live launcher, another +prefix, the game, Riot/League/CEF, `darlingserver` and the runtime are never +touched. See [troubleshooting](docs/TROUBLESHOOTING.md#multiple-client-in-game-helpers). + ## Testing Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. diff --git a/docs/SETUP.md b/docs/SETUP.md index e3cafa8..c8178a6 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -86,6 +86,15 @@ launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it does nothing. See [troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window). +`RCLOL_CLEAN_STALE_HELPERS=1` runs a preflight before the match helper starts. +It removes `close-league-client-in-game.py` and +`hide-league-client-in-game.py` processes of this prefix whose parent is +`init`/`systemd`, which means the launcher that started them has exited. A +helper of a live launcher, another prefix, the game, Riot/League/CEF and +`darlingserver` are preserved. It fails closed when a command line, +environment, stat entry or parent cannot be read. See +[troubleshooting](TROUBLESHOOTING.md#multiple-client-in-game-helpers). + `RCLOL_FILTER_HOST_ENV=1` opts in to removing host desktop-session variables before the guest starts: `WAYLAND_DISPLAY`, `XDG_SESSION_*`, `XDG_CURRENT_DESKTOP`, GNOME/KDE/GDM, systemd, SSH agent, terminal, toolkit (`QT_*`, `GTK_*`, `GDK_*`), diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 64d4289..ee79ad9 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -158,6 +158,30 @@ managed client window is visible. With X11/Xwayland unavailable it fails closed. This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If no window appears afterward, use a full runtime shutdown/restart. +### Multiple client-in-game helpers + +The close/hide helper is a long-lived watcher. If an earlier launcher exits +while it remains, the next launch can start another copy; list them with: + +```sh +pgrep -af 'close-league-client-in-game.py|hide-league-client-in-game.py' +``` + +To remove the orphaned copies before the next helper starts: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 ./scripts/run.sh +``` + +Only helpers of the current prefix whose parent is `init`/`systemd` are +terminated. Helpers whose launcher is still alive, other prefixes, the game, +Riot/League/CEF, `darlingserver` and the runtime are never touched. A dry run +is available from the configured tools directory: + +```sh +RCLOL_CLEAN_STALE_HELPERS=1 python3 build/runtime/tools/clean-stale-helpers.py --dry-run +``` + ### Client window stays black after a match The League client keeps its Chromium/CEF UI (`LeagueClientUx` and its helpers) diff --git a/tests/test_clean_stale_helpers.py b/tests/test_clean_stale_helpers.py new file mode 100644 index 0000000..52c0294 --- /dev/null +++ b/tests/test_clean_stale_helpers.py @@ -0,0 +1,110 @@ +"""Exercise orphan-helper cleanup with fake /proc entries and mocked signals.""" +import importlib.util +import io +import os +from pathlib import Path +import signal +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + 'clean_stale_helpers', ROOT / 'tools/clean-stale-helpers.py') +clean_stale_helpers = importlib.util.module_from_spec(spec) +spec.loader.exec_module(clean_stale_helpers) + +HELPER = '/repo/tools/close-league-client-in-game.py' +HIDE_HELPER = '/repo/tools/hide-league-client-in-game.py' + + +class CleanStaleHelpersTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.proc = self.root / 'proc' + self.proc.mkdir() + self.prefix = self.root / '.darling' + self.other = self.root / 'other-prefix' + + def parent(self, pid, comm): + folder = self.proc / str(pid) + folder.mkdir(exist_ok=True) + (folder / 'comm').write_text(comm + '\n') + + def process(self, pid, script=HELPER, parent_pid=500, parent_comm='systemd', + prefix='own', argv0='/usr/bin/python3', state='S', + environment=True, arguments=('--detach',)): + folder = self.proc / str(pid) + folder.mkdir() + argv = [argv0, script, *arguments] + (folder / 'cmdline').write_bytes( + b'\0'.join(arg.encode() for arg in argv) + b'\0') + if parent_comm is not None: + self.parent(parent_pid, parent_comm) + fields = [state, str(parent_pid)] + ['0'] * 17 + ['100'] + (folder / 'stat').write_text(f'{pid} (python3) ' + ' '.join(fields) + '\n') + if environment: + prefix = self.prefix if prefix == 'own' else prefix + (folder / 'environ').write_bytes( + b'DPREFIX=' + os.fsencode(prefix) + b'\0') + else: + (folder / 'environ').write_bytes(b'UNRELATED=1\0') + return folder + + def run_clean(self, dry_run=False): + killed = [] + + def kill(pid, signum): + killed.append((pid, signum)) + + out = io.StringIO() + result = clean_stale_helpers.clean( + self.prefix, proc_root=self.proc, kill=kill, + sleep=lambda _seconds: None, out=out, dry_run=dry_run) + return result, killed, out.getvalue() + + def test_removes_only_orphaned_helpers_of_this_prefix(self): + self.process(100, HELPER) + self.process(101, HIDE_HELPER) + self.process(200, HELPER, parent_pid=600, parent_comm='darling') + self.process(201, HELPER, prefix=self.other) + self.process(202, HELPER, argv0='/usr/bin/vim') + self.process(203, HELPER, state='Z') + self.process(204, HELPER, environment=False) + self.process(205, HELPER, parent_pid=700, parent_comm=None) + result, killed, output = self.run_clean() + self.assertEqual(result, 0) + self.assertEqual(sorted(killed), sorted([ + (100, signal.SIGTERM), (100, signal.SIGKILL), + (101, signal.SIGTERM), (101, signal.SIGKILL), + ])) + self.assertIn('removing 2 orphaned helper(s): 100,101', output) + + def test_dry_run_reports_without_signalling(self): + self.process(100) + result, killed, output = self.run_clean(dry_run=True) + self.assertEqual((result, killed), (0, [])) + self.assertIn('would remove 1 orphaned helper(s): 100', output) + + def test_no_orphans_is_a_noop(self): + self.process(200, parent_pid=600, parent_comm='darling') + result, killed, output = self.run_clean() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no orphaned helpers', output) + + def test_relative_helper_prefix_is_preserved(self): + self.process(100, prefix='relative-prefix') + result, killed, output = self.run_clean() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no orphaned helpers', output) + + def test_launcher_runs_cleanup_before_starting_a_helper(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertIn('RCLOL_CLEAN_STALE_HELPERS', source) + self.assertLess(source.index('clean-stale-helpers.py'), + source.index('close-league-client-in-game.py')) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/clean-stale-helpers.py b/tools/clean-stale-helpers.py new file mode 100755 index 0000000..a01d49a --- /dev/null +++ b/tools/clean-stale-helpers.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Remove orphaned client-in-game helpers from an earlier launcher run. + +The close/hide helpers are long-lived watch processes. A launcher can end +while they remain, and a later run starts another helper, so several copies +watch the same prefix. This opt-in preflight removes only helpers that belong +to this prefix and whose parent is init/systemd: the launcher that started +them has already exited and reparented them. It never signals a helper whose +parent is still alive, the current launcher, darlingserver, the game, +Riot/League/CEF, or a process of another prefix. + +It fails closed: an unreadable command line, environment, stat entry or +parent leaves that process alone. Enable it with +RCLOL_CLEAN_STALE_HELPERS=1; the launcher runs it before starting its own +helper. +""" + +import argparse +import os +import signal +import sys +import time +from pathlib import Path + +HELPER_NAMES = ('close-league-client-in-game.py', + 'hide-league-client-in-game.py') +REAPER_NAMES = ('init', 'systemd') +TERM_GRACE = 3.0 + + +def target_prefix(): + """The prefix this launcher owns, or None when it cannot be determined.""" + value = os.environ.get('DPREFIX', str(Path.home() / '.darling')) + prefix = Path(value).expanduser() + return prefix if prefix.is_absolute() else None + + +def process_info(proc): + """Return (state, ppid, start_ticks), or None when stat is unreadable.""" + try: + fields = (proc / 'stat').read_text().rsplit(')', 1)[1].split() + if len(fields) < 20: + return None + return fields[0], int(fields[1]), int(fields[19]) + except (OSError, ValueError, IndexError): + return None + + +def parent_is_reaper(ppid, proc_root): + """True when the parent is PID 1 or a systemd/init user manager.""" + if ppid == 1: + return True + try: + comm = (proc_root / str(ppid) / 'comm').read_text().strip() + except OSError: + return False + return comm in REAPER_NAMES + + +def helper_identity(proc, prefix, proc_root): + """Return the start ticks of an orphaned helper of prefix, or None. + + The script name must be an exact argument, and the executable must be a + Python interpreter. This avoids matching an editor or a log viewer that + happens to have the helper name in its arguments. DPREFIX is inherited by + the helper from run-riot-client.sh; a missing or different value belongs + to another install and is preserved. + """ + try: + if proc.stat().st_uid != os.getuid(): + return None + raw = (proc / 'cmdline').read_bytes() + argv = [part.decode(errors='replace') for part in raw.split(b'\0') if part] + if len(argv) < 2 or not os.path.basename(argv[0]).startswith(('python', 'pypy')): + return None + if not any(os.path.basename(arg) in HELPER_NAMES for arg in argv[1:]): + return None + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + raw_prefix = environment.get(b'DPREFIX') + if raw_prefix is None: + return None + helper_prefix = Path(os.fsdecode(raw_prefix)).expanduser() + if not helper_prefix.is_absolute(): + return None + helper_prefix = helper_prefix.resolve() + if helper_prefix != prefix: + return None + + info = process_info(proc) + if info is None or info[0] == 'Z': + return None + if not parent_is_reaper(info[1], proc_root): + return None + return info[2] + except FileNotFoundError: + return None + except (OSError, RuntimeError, ValueError): + return None + + +def clean(prefix, proc_root=Path('/proc'), kill=os.kill, sleep=time.sleep, + out=sys.stdout, dry_run=False): + """Terminate orphaned helpers for prefix; return 0 on every normal path.""" + if prefix is None or not prefix.is_absolute(): + print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True) + return 1 + try: + prefix = prefix.expanduser().resolve() + except (OSError, RuntimeError): + print('clean-stale-helpers: unusable DPREFIX: no action', file=out, flush=True) + return 1 + try: + entries = list(proc_root.iterdir()) + except OSError: + print('clean-stale-helpers: unreadable /proc: no action', file=out, flush=True) + return 0 + + helpers = {} + for proc in entries: + if not proc.name.isdigit() or int(proc.name) == os.getpid(): + continue + start_ticks = helper_identity(proc, prefix, proc_root) + if start_ticks is not None: + helpers[int(proc.name)] = start_ticks + if not helpers: + print('clean-stale-helpers: no orphaned helpers; nothing to do', + file=out, flush=True) + return 0 + + pids = sorted(helpers) + action = 'would remove' if dry_run else 'removing' + print('clean-stale-helpers: %s %d orphaned helper(s): %s' + % (action, len(pids), ','.join(str(pid) for pid in pids)), + file=out, flush=True) + if dry_run: + return 0 + + for pid, start_ticks in helpers.items(): + proc = proc_root / str(pid) + if helper_identity(proc, prefix, proc_root) != start_ticks: + continue + try: + kill(pid, signal.SIGTERM) + except OSError: + continue + sleep(TERM_GRACE) + for pid, start_ticks in helpers.items(): + proc = proc_root / str(pid) + if helper_identity(proc, prefix, proc_root) != start_ticks: + continue + try: + kill(pid, signal.SIGKILL) + except OSError: + pass + return 0 + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--prefix', type=Path, + help='prefix whose helpers should be checked') + parser.add_argument('--dry-run', action='store_true', + help='report what would be removed without signalling') + args = parser.parse_args() + if os.environ.get('RCLOL_CLEAN_STALE_HELPERS') != '1': + print('clean-stale-helpers: disabled; set RCLOL_CLEAN_STALE_HELPERS=1', + flush=True) + return 0 + prefix = args.prefix or target_prefix() + if prefix is None or not prefix.is_absolute(): + print('clean-stale-helpers: unusable DPREFIX: no action', + file=sys.stderr, flush=True) + return 1 + return clean(prefix, dry_run=args.dry_run) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 085434e..34e7e06 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -18,6 +18,13 @@ fi if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi +# Opt-in cleanup of helpers left by an earlier launcher. This runs before the +# current helper is started. It removes only helpers of this prefix whose +# parent is init/systemd, so a helper of a live launcher is not touched. +if [ "${RCLOL_CLEAN_STALE_HELPERS:-0}" = 1 ]; then + python3 "$runner_dir/clean-stale-helpers.py" --prefix "$prefix_dir" || + echo 'clean-stale-helpers: cleanup failed; continuing' >&2 +fi # Opt-in: close the League client UI while a match runs. It keeps the CPU for the game # and leaves the client the backend starts when the match ends as the only CEF instance; # a second instance on the same profile never paints its window, which is the black From f8bb14624e766a35a3d312a018c9c1fbc2a1370f Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 06:08:45 -0600 Subject: [PATCH 49/54] fix(launcher): address in-game helper review - restore hidden windows even when the launcher output pipe is closed; - exit the hide watcher after ten idle minutes and start it only after the installed-client check; - reject missing, malformed or invalid close-during-game preferences instead of silently selecting hide; - document the preference contract and idle behavior. --- README.md | 12 ++--- docs/SETUP.md | 11 ++--- docs/TROUBLESHOOTING.md | 5 ++- tests/test_client_in_game_tricks.py | 8 +++- tests/test_hide_league_client_in_game.py | 45 +++++++++++++++++++ tools/client-in-game-tricks.py | 57 +++++++++++++++++------- tools/hide-league-client-in-game.py | 57 +++++++++++++++++------- tools/run-riot-client.sh | 52 ++++++++++----------- 8 files changed, 178 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 51a3cf4..689f53f 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,13 @@ RCLOL_CLIENT_IN_GAME_TRICKS=1 ./scripts/run.sh It reads `lol-user-experience` from the installed League client's `LCULocalPreferences.yaml`. When `potatoModeEnabled` is true, it closes the -client UI if `closeLeagueClientDuringGame` is true; otherwise it hides the -`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game -ends. With client optimizations disabled or unreadable preferences it does -nothing. The `RCLOL_CLOSE_CLIENT_IN_GAME` and `RCLOL_HIDE_CLIENT_IN_GAME` -low-level switches remain available when the automatic switch is not set. See +client UI if `closeLeagueClientDuringGame` is true, and hides the +`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or +invalid preference values, disabled client optimizations and unreadable files +select no action. The `RCLOL_CLOSE_CLIENT_IN_GAME` and +`RCLOL_HIDE_CLIENT_IN_GAME` low-level switches remain available when the +automatic switch is not set. The helper exits after ten minutes without a +client or match once it has seen one. See [troubleshooting](docs/TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured effect and caveats. diff --git a/docs/SETUP.md b/docs/SETUP.md index 2679892..548a02f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -81,13 +81,14 @@ for the observed failures, recovery guards and NSURLSession limitations. `RCLOL_CLIENT_IN_GAME_TRICKS=1` opts in to automatic client handling during a match. It reads `lol-user-experience` from the installed client's `LCULocalPreferences.yaml`: with `potatoModeEnabled: true`, it closes the client -UI when `closeLeagueClientDuringGame: true`, otherwise it hides the managed -`LeagueClientUx`/`Riot Client` X11 windows and restores them when the game -ends. With client optimizations disabled or preferences unreadable it does -nothing. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and +UI when `closeLeagueClientDuringGame: true`, and hides the managed +`LeagueClientUx`/`Riot Client` X11 windows when that value is false. Missing or +invalid preference values, disabled client optimizations and unreadable files +select no action. The low-level `RCLOL_CLOSE_CLIENT_IN_GAME=1` and `RCLOL_HIDE_CLIENT_IN_GAME=1` switches remain available when the automatic switch is not set. The feature is off by default, requires X11/Xwayland, and -does not patch client binaries, CEF or the game. See +does not patch client binaries, CEF or the game. The hide helper exits after ten +minutes without a client or match once it has seen one. See [the troubleshooting measurement](TROUBLESHOOTING.md#automatic-client-handling-during-a-match) for the measured CPU effect and limitations. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0c37ba5..7869d27 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -206,13 +206,16 @@ It reads `lol-user-experience` from the installed client's | Client preference | Launcher action | |---|---| | `potatoModeEnabled: false` or unreadable | no client-in-game helper | +| `potatoModeEnabled: true`, close preference missing or invalid | no action | | `potatoModeEnabled: true`, `closeLeagueClientDuringGame: true` | close the client UI | | `potatoModeEnabled: true`, `closeLeagueClientDuringGame: false` | hide the client windows | The hide path unmaps the `LeagueClientUx` and `Riot Client` X11 windows and maps them again when the game process ends. The client is not closed, the League backend and Riot client keep running, and the game window is never hidden. The -close path uses the UI-close helper described above. +close path uses the UI-close helper described above. The hide helper exits after +ten minutes without a client or match once it has seen one; a missing or invalid +close preference selects no helper. The low-level `RCLOL_HIDE_CLIENT_IN_GAME=1` and `RCLOL_CLOSE_CLIENT_IN_GAME=1` switches remain available when the automatic diff --git a/tests/test_client_in_game_tricks.py b/tests/test_client_in_game_tricks.py index 53a0db3..752435b 100644 --- a/tests/test_client_in_game_tricks.py +++ b/tests/test_client_in_game_tricks.py @@ -36,9 +36,12 @@ class ClientInGameTricksTests(unittest.TestCase): self.write(True, False) self.assertEqual(tricks.mode(self.path), 'hide') - def test_hide_when_close_preference_is_missing(self): + def test_none_when_close_preference_is_missing_or_invalid(self): self.write(True) - self.assertEqual(tricks.mode(self.path), 'hide') + self.assertEqual(tricks.mode(self.path), 'none') + for value in ('maybe', '"true', 'true#note', '"true"junk'): + self.write(True, value) + self.assertEqual(tricks.mode(self.path), 'none', value) def test_none_when_optimizations_are_off(self): self.write(False, True) @@ -64,6 +67,7 @@ class ClientInGameTricksTests(unittest.TestCase): 'lol-user-experience:\n' ' data:\n' ' potatoModeEnabled: true\n' + ' closeLeagueClientDuringGame: false\n' 'other-section:\n' ' potatoModeEnabled: true\n' ' closeLeagueClientDuringGame: true\n') diff --git a/tests/test_hide_league_client_in_game.py b/tests/test_hide_league_client_in_game.py index f03e8c1..867f2f3 100644 --- a/tests/test_hide_league_client_in_game.py +++ b/tests/test_hide_league_client_in_game.py @@ -217,6 +217,46 @@ class HideClientInGameTests(unittest.TestCase): watcher._hide_new() self.assertEqual(watcher.hidden, {0xabc}) + def test_restore_log_failure_still_shows_the_window(self): + class BrokenOutput: + def write(self, _text): + raise BrokenPipeError + + def flush(self): + pass + + x11 = FakeX11() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda _prefix, _cache: [0xabc], + state_source=lambda _prefix: True, + sleep=lambda _seconds: None, + out=BrokenOutput(), + ) + watcher.hidden = {0xabc} + watcher._show_all('test') + self.assertEqual(x11.shown, [0xabc]) + self.assertEqual(watcher.hidden, set()) + + def test_idle_timeout_leaves_after_client_disappears(self): + states = iter([True, False, False]) + windows = iter([[0xabc], [], []]) + times = iter([0.0, 10.0, 700.0]) + x11 = FakeX11() + output = io.StringIO() + watcher = hide_in_game.Watcher( + self.prefix, x11, + window_source=lambda _prefix, _cache: next(windows), + state_source=lambda _prefix: next(states), + sleep=lambda _seconds: None, + out=output, + clock=lambda: next(times), + ) + self.assertEqual(watcher.run(max_steps=3), 0) + self.assertEqual(x11.unmapped, [0xabc]) + self.assertEqual(x11.shown, [0xabc]) + self.assertIn('no client or match', output.getvalue()) + def test_hides_then_shows_on_match_end(self): states = iter([True, True, False]) x11 = FakeX11() @@ -278,6 +318,11 @@ class HideClientInGameTests(unittest.TestCase): }): self.assertEqual(hide_in_game.main([]), 0) + def test_helper_starts_after_the_installed_client_check(self): + source = (ROOT / 'tools/run-riot-client.sh').read_text() + self.assertLess(source.index('Riot Client is not installed in'), + source.index('RCLOL_CLIENT_IN_GAME_TRICKS')) + def test_master_tricks_switch_enables_hide(self): class FakeX11: def close(self): diff --git a/tools/client-in-game-tricks.py b/tools/client-in-game-tricks.py index 1b7a314..2d4330c 100755 --- a/tools/client-in-game-tricks.py +++ b/tools/client-in-game-tricks.py @@ -7,7 +7,8 @@ The public launcher switch is RCLOL_CLIENT_IN_GAME_TRICKS=1. It reads - potatoModeEnabled must be true (client optimizations enabled); - closeLeagueClientDuringGame true -> close the client UI while a match runs; - closeLeagueClientDuringGame false -> hide the client windows instead; -- missing preferences or an unreadable file -> no action (none). +- missing or invalid closeLeagueClientDuringGame or potatoModeEnabled -> no action + (none). Only the named `lol-user-experience` section is considered, so identical keys in another section cannot select a helper. This helper only selects a mode; it @@ -24,8 +25,39 @@ TRUE_VALUES = {'true', 'yes', '1'} FALSE_VALUES = {'false', 'no', '0'} +def parse_bool(value): + """Return True/False/None for one YAML scalar, ignoring a trailing comment.""" + value = value.strip() + if not value: + return None + if value[0] in ('"', "'"): + quote = value[0] + end = value.find(quote, 1) + if end == -1: + return None + scalar = value[1:end] + remainder = value[end + 1:] + if remainder and not remainder[0].isspace(): + return None + remainder = remainder.strip() + if remainder and not remainder.startswith('#'): + return None + else: + scalar = value + for index, char in enumerate(scalar): + if char == '#' and (index == 0 or scalar[index - 1].isspace()): + scalar = scalar[:index].rstrip() + break + scalar = scalar.lower() + if scalar in TRUE_VALUES: + return True + if scalar in FALSE_VALUES: + return False + return None + + def read_bool(path, key, section=SECTION): - """Return True/False for the last key in section, or None when absent. + """Return True/False for the last key in section, or None when invalid. OSError/UnicodeError is raised for an unreadable file so callers can fail closed instead of treating a read race as a missing preference. @@ -45,19 +77,8 @@ def read_bool(path, key, section=SECTION): continue if not in_section or not stripped.startswith(key + ':'): continue - value = stripped.split(':', 1)[1].strip() - if value[:1] in ('"', "'"): - quote = value[0] - end = value.find(quote, 1) - value = value[1:end] if end != -1 else value[1:] - else: - value = value.split('#', 1)[0].strip() - value = value.lower() - if value in TRUE_VALUES: - return True - if value in FALSE_VALUES: - return False - return None + value = parse_bool(stripped.split(':', 1)[1]) + return value def mode(path): @@ -71,7 +92,11 @@ def mode(path): close = read_bool(path, CLOSE_KEY) except (OSError, UnicodeError): return 'none' - return 'close' if close is True else 'hide' + if close is True: + return 'close' + if close is False: + return 'hide' + return 'none' def main(argv=None): diff --git a/tools/hide-league-client-in-game.py b/tools/hide-league-client-in-game.py index 22b9ca2..f9135af 100755 --- a/tools/hide-league-client-in-game.py +++ b/tools/hide-league-client-in-game.py @@ -39,6 +39,7 @@ GAME_NAME = 'LeagueofLegends' SOCK_VARIABLE = b'__mldr_sockpath' SOCK_NAME = '.darlingserver.sock' POLL_SECONDS = 2.0 +IDLE_SECONDS = 600 TARGET_CLASSES = ('leagueclientux', 'riot client') @@ -263,35 +264,46 @@ class Watcher: """Hide while a match is running, restore on end, uncertainty or exit.""" def __init__(self, prefix, x11, window_source=target_windows, - state_source=game_running, sleep=time.sleep, out=sys.stdout): + state_source=game_running, sleep=time.sleep, out=sys.stdout, + clock=time.monotonic): self.prefix = prefix self.x11 = x11 self.window_source = window_source self.state_source = state_source self.sleep = sleep self.out = out + self.clock = clock self.hidden = set() self.class_cache = {} self.reported_no_window = False + self.seen = False + self.idle_since = None + + def _log(self, message, stream=None): + try: + print(message, file=self.out if stream is None else stream, flush=True) + except Exception: + # A closed launcher pipe must not prevent window restoration. + pass def _show_all(self, reason): if not self.hidden: return - print(f'hide-league-client-in-game: {reason}; showing the client', file=self.out, flush=True) + self._log(f'hide-league-client-in-game: {reason}; showing the client') for window in sorted(self.hidden): try: self.x11.show(window) - print(f'hide-league-client-in-game: shown 0x{window:x}', file=self.out, flush=True) except Exception as error: - print(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', - file=sys.stderr, flush=True) + self._log(f'hide-league-client-in-game: cannot show 0x{window:x}: {error}', + sys.stderr) + continue + self._log(f'hide-league-client-in-game: shown 0x{window:x}') self.hidden.clear() def _hide_new(self): windows = self.window_source(self.prefix, self.class_cache) if not windows and not self.hidden and not self.reported_no_window: - print('hide-league-client-in-game: no managed client window; not hiding', - file=self.out, flush=True) + self._log('hide-league-client-in-game: no managed client window; not hiding') self.reported_no_window = True for window in windows: if window in self.hidden: @@ -301,29 +313,44 @@ class Watcher: self.x11.unmap(window) except Exception as error: self.hidden.discard(window) - print(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', - file=sys.stderr, flush=True) + self._log(f'hide-league-client-in-game: cannot hide 0x{window:x}: {error}', + sys.stderr) continue - try: - print(f'hide-league-client-in-game: hidden 0x{window:x}', - file=self.out, flush=True) - except Exception: - # Logging must not lose a successfully unmapped window. - pass + self._log(f'hide-league-client-in-game: hidden 0x{window:x}') + + def _windows_present(self): + try: + return bool(self.window_source(self.prefix, self.class_cache)) + except Exception: + # Uncertainty must not turn into an automatic exit. + return True def run(self, max_steps=None): steps = 0 while True: + now = self.clock() state = self.state_source(self.prefix) if state is None: self._show_all('unreadable /proc state') elif state: + self.seen = True + self.idle_since = None if not self.hidden and self.reported_no_window: self.reported_no_window = False self._hide_new() else: self._show_all('the match ended') self.reported_no_window = False + if self._windows_present(): + self.seen = True + self.idle_since = None + elif self.seen: + if self.idle_since is None: + self.idle_since = now + if now - self.idle_since >= IDLE_SECONDS: + self._log('hide-league-client-in-game: no client or match for ' + f'{IDLE_SECONDS}s: leaving') + return 0 steps += 1 if max_steps is not None and steps >= max_steps: return 0 diff --git a/tools/run-riot-client.sh b/tools/run-riot-client.sh index 7d76a6d..611c810 100755 --- a/tools/run-riot-client.sh +++ b/tools/run-riot-client.sh @@ -12,31 +12,6 @@ runner_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) if [ "${RCLOL_RECOVER_HIDDEN_CLIENT:-0}" = 1 ]; then python3 "$runner_dir/recover-hidden-riot-client.py" fi -# Opt-in automatic client-in-game handling. This single switch reads the -# client's own lol-user-experience preferences: close the UI when the user -# selected "close client during game", otherwise hide the client windows. -# It does nothing unless potatoModeEnabled (client optimizations) is true. -if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then - pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" - mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) - case "$mode" in - close) - echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 - python3 "$runner_dir/close-league-client-in-game.py" --detach & - ;; - hide) - echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 - python3 "$runner_dir/hide-league-client-in-game.py" --detach & - ;; - *) - echo 'client-in-game-tricks: no action (client optimizations disabled or preferences unreadable)' >&2 - ;; - esac -elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then - python3 "$runner_dir/close-league-client-in-game.py" --detach & -elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then - python3 "$runner_dir/hide-league-client-in-game.py" --detach & -fi # The bundled downloader rejects IPv6 CONNECT_TO addresses. Only Riot CDN # AAAA lookups are suppressed; set this to 0 to retain the upstream resolver. export RCLOL_CDN_IPV4_ONLY="${RCLOL_CDN_IPV4_ONLY:-1}" @@ -84,6 +59,33 @@ if [ -z "${RIOT_LAUNCHER_HOST:-}" ] && [ ! -e "$client" ]; then ln -s "../Users/Shared/Riot Games/Riot Client.app" "$client" fi fi +# Opt-in automatic client-in-game handling. Start the watcher only after the +# installed-client check above: a failed installer launch must not leave a +# helper behind. The preference selector returns no action for missing or +# invalid close-during-game values. +if [ -z "${RIOT_LAUNCHER_HOST:-}" ]; then + if [ "${RCLOL_CLIENT_IN_GAME_TRICKS:-0}" = 1 ]; then + pref="$prefix_dir/Applications/League of Legends.app/Contents/LoL/Config/LCULocalPreferences.yaml" + mode=$(python3 "$runner_dir/client-in-game-tricks.py" "$pref" 2>/dev/null || echo none) + case "$mode" in + close) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=true -> close helper' >&2 + python3 "$runner_dir/close-league-client-in-game.py" --detach & + ;; + hide) + echo 'client-in-game-tricks: closeLeagueClientDuringGame=false -> hide helper' >&2 + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + ;; + *) + echo 'client-in-game-tricks: no action (client optimizations disabled or preferences missing/invalid)' >&2 + ;; + esac + elif [ "${RCLOL_CLOSE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/close-league-client-in-game.py" --detach & + elif [ "${RCLOL_HIDE_CLIENT_IN_GAME:-0}" = 1 ]; then + python3 "$runner_dir/hide-league-client-in-game.py" --detach & + fi +fi A=${RIOT_LAUNCHER_HOST:-"$client/Contents/MacOS/RiotClientServices"} # External XIM (for example Fcitx) consumes keys before Darling forwards them. # Use Xlib's local input method; allow explicit testing of another modifier. From 9194beef2fbc2cfc992a51ae577e05df3b2e729c Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 06:08:45 -0600 Subject: [PATCH 50/54] fix(launcher): harden stale-client recovery - fail closed when X11 omits _NET_CLIENT_LIST or WM_CLASS, and recognize the RiotClientServices window class; - require stale client processes to be at least 60 seconds old; - recheck process identity before SIGTERM and SIGKILL and recheck the window before signalling; - document the age and window-detection guards. --- README.md | 7 +- docs/SETUP.md | 9 +-- docs/TROUBLESHOOTING.md | 16 +++-- tests/test_recover_stale_client.py | 57 ++++++++++++++- tools/recover-stale-client.py | 110 ++++++++++++++++++----------- 5 files changed, 144 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 2e7ebf0..f9154eb 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,12 @@ If the game or Riot client was closed but `darlingserver` is still running, opt RCLOL_RECOVER_STALE_CLIENT=1 ./scripts/run.sh ``` -It stops only a windowless Riot/League/CEF tree, preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then starts the product normally. It does nothing while a game or managed client window is present and fails closed when it cannot inspect X11. See [troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). +It stops only a windowless Riot/League/CEF tree at least 60 seconds old, +preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`, and then +starts the product normally. It recognizes `LeagueClientUx`, `Riot Client` and +`RiotClientServices` windows, does nothing while a game or managed client window +is present, and fails closed when X11 properties cannot be read. See +[troubleshooting](docs/TROUBLESHOOTING.md#client-remains-running-without-a-window). If repeated launches leave several `close-league-client-in-game.py` watchers, opt in to removing the orphaned ones before the new helper starts: diff --git a/docs/SETUP.md b/docs/SETUP.md index c8178a6..cdcdbc8 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -80,10 +80,11 @@ for the observed failures, recovery guards and NSURLSession limitations. `RCLOL_RECOVER_STALE_CLIENT=1` opts in to partial runtime recovery before launch. If `darlingserver` is alive, no game is running, no managed client -window is visible and stale Riot/League/CEF processes remain, the launcher stops -only that client tree and reuses the runtime. It preserves `darlingserver`, -launchd/mldr, sockets and `.init.pid`; if X11/Xwayland cannot be inspected it -does nothing. See +window is visible and stale Riot/League/CEF processes at least 60 seconds old +remain, the launcher stops only that client tree and reuses the runtime. It +recognizes `LeagueClientUx`, `Riot Client` and `RiotClientServices` windows and +preserves `darlingserver`, launchd/mldr, sockets and `.init.pid`; if X11/Xwayland +properties cannot be inspected it does nothing. See [troubleshooting](TROUBLESHOOTING.md#client-remains-running-without-a-window). `RCLOL_CLEAN_STALE_HELPERS=1` runs a preflight before the match helper starts. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index ee79ad9..571dce4 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -150,13 +150,15 @@ download is restarted when recovery is enabled. Other desktop environments are not supported by this helper; recovery is off by default. For a live Darling runtime with no visible client window but stale Riot/League/ -CEF processes, `RCLOL_RECOVER_STALE_CLIENT=1` performs a partial recovery before -the next launch. It keeps `darlingserver`, launchd/mldr, sockets and `.init.pid`, -stops only the stale client process tree, and then lets the normal launcher start -the product again. It does nothing while a game process is running or when a -managed client window is visible. With X11/Xwayland unavailable it fails closed. -This saves the Darling bootstrap but not the Riot Client/CEF startup itself. If -no window appears afterward, use a full runtime shutdown/restart. +CEF processes at least 60 seconds old, `RCLOL_RECOVER_STALE_CLIENT=1` performs a +partial recovery before the next launch. It keeps `darlingserver`, launchd/mldr, +sockets and `.init.pid`, stops only the stale client process tree, and then lets +the normal launcher start the product again. It recognizes `LeagueClientUx`, +`Riot Client` and `RiotClientServices` windows, does nothing while a game process +is running or when a managed client window is visible, and fails closed when +X11/Xwayland properties are missing or unreadable. This saves the Darling +bootstrap but not the Riot Client/CEF startup itself. If no window appears +afterward, use a full runtime shutdown/restart. ### Multiple client-in-game helpers diff --git a/tests/test_recover_stale_client.py b/tests/test_recover_stale_client.py index 743907f..14099a0 100644 --- a/tests/test_recover_stale_client.py +++ b/tests/test_recover_stale_client.py @@ -24,16 +24,19 @@ class RecoverStaleClientTests(unittest.TestCase): self.root = Path(self.temp.name) self.proc = self.root / 'proc' self.proc.mkdir() + (self.proc / 'uptime').write_text('1000 0\n') self.prefix = self.root / '.darling' - def process(self, pid, comm, argv, env=None, executable=None): + def process(self, pid, comm, argv, env=None, executable=None, age=120): folder = self.proc / str(pid) folder.mkdir() (folder / 'comm').write_text(comm + '\n') (folder / 'cmdline').write_bytes( (argv[0] if executable is None else executable).encode() + b'\0' + b''.join(arg.encode() + b'\0' for arg in argv[1:])) - (folder / 'stat').write_text(f'{pid} ({comm}) S') + ticks = os.sysconf('SC_CLK_TCK') + fields = ['S'] + ['0'] * 18 + [str(int((1000 - age) * ticks))] + (folder / 'stat').write_text(f'{pid} ({comm}) ' + ' '.join(fields) + '\n') if env is not None: (folder / 'environ').write_bytes(env) return folder @@ -45,11 +48,13 @@ class RecoverStaleClientTests(unittest.TestCase): os.fsencode(prefix / recover_stale_client.SOCK_NAME), ) - def run_recover(self, window=False): + def run_recover(self, window=False, on_kill=None): killed = [] def kill(pid, signum): killed.append((pid, signum)) + if on_kill is not None: + on_kill(pid, signum) out = io.StringIO() with patch.object(recover_stale_client, 'visible_client_window', @@ -105,6 +110,52 @@ class RecoverStaleClientTests(unittest.TestCase): self.assertNotIn(10, [pid for pid, _sig in killed]) self.assertNotIn(11, [pid for pid, _sig in killed]) + def test_pid_identity_is_rechecked_before_sigkill(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(12, 'mldr', [CLIENT], self.client_env()) + + def reuse_pid(pid, signum): + if signum == recover_stale_client.signal.SIGTERM: + (self.proc / str(pid) / 'cmdline').write_bytes(b'/usr/bin/editor\0') + + result, killed, _output = self.run_recover(on_kill=reuse_pid) + self.assertEqual(result, 0) + self.assertEqual(killed, [(12, recover_stale_client.signal.SIGTERM)]) + + def test_young_client_is_preserved(self): + self.process(10, 'darlingserver', ['darlingserver']) + self.process(12, 'mldr', [CLIENT], self.client_env(), + age=recover_stale_client.MIN_AGE_SECONDS - 1) + result, killed, output = self.run_recover() + self.assertEqual((result, killed), (0, [])) + self.assertIn('no stale client processes', output) + + def test_missing_net_client_list_is_inconclusive(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + return_value='_NET_CLIENT_LIST: not found.\n'): + self.assertTrue(recover_stale_client.visible_client_window()) + + def test_empty_client_list_is_no_window(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + return_value='_NET_CLIENT_LIST(WINDOW): window id #\n'): + self.assertFalse(recover_stale_client.visible_client_window()) + + def test_missing_wm_class_is_inconclusive(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + side_effect=[ + '_NET_CLIENT_LIST(WINDOW): window id # 0x10\n', + 'WM_CLASS: not found.\n', + ]): + self.assertTrue(recover_stale_client.visible_client_window()) + + def test_riot_client_shell_is_a_visible_window(self): + with patch.object(recover_stale_client.subprocess, 'check_output', + side_effect=[ + '_NET_CLIENT_LIST(WINDOW): window id # 0x10\n', + 'WM_CLASS(STRING) = "RiotClientServices", "RiotClientServices"\n', + ]): + self.assertTrue(recover_stale_client.visible_client_window()) + if __name__ == '__main__': unittest.main() diff --git a/tools/recover-stale-client.py b/tools/recover-stale-client.py index 3c29805..66466ce 100755 --- a/tools/recover-stale-client.py +++ b/tools/recover-stale-client.py @@ -7,15 +7,17 @@ of these are true: - a darlingserver is running; - no League game process is running; - no managed Riot/League client window is visible; -- one or more Riot/League client, CEF or crash-handler processes are still - present for this prefix. +- one or more Riot/League client, CEF or crash-handler processes at least 60 + seconds old are still present for this prefix. It terminates only those client processes with SIGTERM/SIGKILL. The darlingserver, launchd/mldr services, sockets and .init.pid stay intact so the next launcher run can reuse the runtime. It never patches files or the game. """ +import math import os +import re import signal import subprocess import sys @@ -23,7 +25,8 @@ import time from pathlib import Path GAME_NAME = 'LeagueofLegends' -WINDOW_CLASSES = ('leagueclientux', 'riot client') +WINDOW_CLASSES = ('leagueclientux', 'riot client', 'riotclientservices') +MIN_AGE_SECONDS = 60 CLIENT_MARKERS = ( '/Applications/League of Legends.app', '/Applications/Riot Client.app', @@ -102,8 +105,9 @@ def game_running(proc_root=Path('/proc')): def visible_client_window(): """Return True when a managed client window is present. - A missing xprop or display is treated as 'cannot prove there is no - window' so we do not kill a possibly working client. + A missing xprop/property or display is treated as 'cannot prove there is no + window' so we do not kill a possibly working client. xprop exits zero and + prints "not found" for a property the window manager did not set. """ try: listing = subprocess.check_output( @@ -111,56 +115,71 @@ def visible_client_window(): ) except (OSError, subprocess.SubprocessError): return True - for raw in listing.split(): - if not raw.startswith('0x'): - continue + lowered = listing.lower() + if '_net_client_list' not in lowered or 'not found' in lowered: + return True + for raw in re.findall(r'0x[0-9a-fA-F]+', listing): try: classes = subprocess.check_output( ['xprop', '-id', raw, 'WM_CLASS'], text=True, timeout=5 ).lower() except (OSError, subprocess.SubprocessError): return True + if 'not found' in classes or '=' not in classes: + return True if any(name in classes for name in WINDOW_CLASSES): return True return False +def client_identity(prefix, pid, proc_root=Path('/proc')): + """Start ticks of a live stale client for prefix, or None when not eligible.""" + proc = proc_root / str(pid) + if process_state(proc) in ('Z', None): + return None + owner = (prefix / SOCK_NAME).parent.resolve() + try: + if proc.stat().st_uid != os.getuid(): + return None + names = command_names(proc) + if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names): + return None + text = command_text(proc) + if not any(marker in text for marker in CLIENT_MARKERS): + return None + environment = dict( + item.split(b'=', 1) + for item in (proc / 'environ').read_bytes().split(b'\0') + if b'=' in item + ) + socket = environment.get(SOCK_VARIABLE) + if socket is None: + return None + if Path(os.fsdecode(socket)).parent.resolve() != owner: + return None + uptime = float((proc_root / 'uptime').read_text().split()[0]) + ticks = os.sysconf('SC_CLK_TCK') + if not math.isfinite(uptime) or uptime < 0 or ticks <= 0: + return None + # Field 22 follows a comm field that may contain spaces/parentheses. + start_ticks = int((proc / 'stat').read_text().rsplit(')', 1)[1].split()[19]) + if start_ticks < 0 or uptime - start_ticks / ticks < MIN_AGE_SECONDS: + return None + return start_ticks + except (OSError, ValueError, IndexError): + return None + + def client_pids(prefix, proc_root=Path('/proc')): """Client/CEF PIDs belonging to prefix, excluding Darling and the game.""" - owner = (prefix / SOCK_NAME).parent.resolve() found = [] try: entries = list(proc_root.iterdir()) except OSError: return [] for proc in entries: - if not proc.name.isdigit() or process_state(proc) in ('Z', None): - continue - try: - if proc.stat().st_uid != os.getuid(): - continue - names = command_names(proc) - if GAME_NAME in names or any(name in RUNTIME_NAMES for name in names): - continue - text = command_text(proc) - if not any(marker in text for marker in CLIENT_MARKERS): - continue - try: - env = dict( - item.split(b'=', 1) - for item in (proc / 'environ').read_bytes().split(b'\0') - if b'=' in item - ) - except OSError: - continue - socket = env.get(SOCK_VARIABLE) - if socket is None: - continue - if Path(os.fsdecode(socket)).parent.resolve() != owner: - continue + if proc.name.isdigit() and client_identity(prefix, proc.name, proc_root) is not None: found.append(int(proc.name)) - except (OSError, ValueError): - continue return sorted(found) @@ -175,19 +194,30 @@ def recover(prefix, proc_root=Path('/proc'), window_check=visible_client_window, if window_check(): print('recover-stale-client: client window present; no action', file=out, flush=True) return 0 - pids = client_pids(prefix, proc_root) - if not pids: + identities = {} + for pid in client_pids(prefix, proc_root): + start_ticks = client_identity(prefix, pid, proc_root) + if start_ticks is not None: + identities[pid] = start_ticks + if not identities: print('recover-stale-client: no stale client processes; no action', file=out, flush=True) return 0 + if window_check(): + print('recover-stale-client: client window appeared; no action', file=out, flush=True) + return 0 print('recover-stale-client: stale client tree without a window; ' - 'terminating %d process(es)' % len(pids), file=out, flush=True) - for pid in pids: + 'terminating %d process(es)' % len(identities), file=out, flush=True) + for pid, start_ticks in identities.items(): + if client_identity(prefix, pid, proc_root) != start_ticks: + continue try: kill(pid, signal.SIGTERM) except OSError: pass sleep(4) - for pid in pids: + for pid, start_ticks in identities.items(): + if client_identity(prefix, pid, proc_root) != start_ticks: + continue try: kill(pid, signal.SIGKILL) except OSError: From c48272aa8f056b1f2d15f8f1abf35912387b2be6 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 13:39:16 -0600 Subject: [PATCH 51/54] docs: clarify interoperability and compliance wording Add docs/INTEROPERABILITY.md, an English scope document that describes the project's interoperability and responsible-use practices: what it distributes, how it handles local platform metadata and the MAC/AMFI probe, how it keeps copyright and licence notices, how it avoids access/authentication/integrity shortcuts, how it presents trademarks and handles notices, and the user's responsibility for Riot's terms. Update README, NOTICE, COMPLIANCE and PLATFORM_IDENTITY to link the document and to state the opt-in window-helper and synthetic-identity behaviour accurately. Add CONTRIBUTING with DCO sign-off and a scope checklist. Documentation-only change: no launcher, patch, build or test behaviour changes. Signed-off-by: 3AvisD --- CONTRIBUTING.md | 64 ++++++++++++++ NOTICE.md | 6 +- README.md | 13 +-- docs/COMPLIANCE.md | 33 +++++++- docs/INTEROPERABILITY.md | 171 ++++++++++++++++++++++++++++++++++++++ docs/PLATFORM_IDENTITY.md | 6 ++ 6 files changed, 283 insertions(+), 10 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/INTEROPERABILITY.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b9aa077 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,64 @@ +# Contributing + +Thank you for improving `darling-lol`. The project is an unofficial +interoperability effort; contributions must keep it in that scope. + +## Before you start + +- Read [`docs/COMPLIANCE.md`](docs/COMPLIANCE.md) and + [`docs/INTEROPERABILITY.md`](docs/INTEROPERABILITY.md). +- Do not add Riot client files, game binaries, game assets, logos, screenshots, + gameplay audio/video, account data, tokens or credentials. +- Do not add DRM/anti-cheat/authentication circumvention, telemetry spoofing, + credential handling, or gameplay automation. Changes that present the project + as an evasion tool are out of scope. +- Keep existing copyright, licence and attribution notices. New third-party code + or assets need a licence and provenance note in the pull request. + +## Licence of contributions + +- Original integration tooling (scripts, tools, tests, docs and project artwork) + is licensed under **GPL-3.0-only**. +- Patches to upstream components are licensed under the licence of the upstream + file(s) they modify. If a patch touches files under different licences, each + hunk keeps the licence of its target file. +- By submitting a contribution, you confirm that you have the right to submit it + and agree to license it under the applicable terms above. + +## Developer Certificate of Origin + +Sign off every commit: + +```sh +git commit -s -m "area: short description" +``` + +The sign-off certifies the Developer Certificate of Origin 1.1 +(): you created the contribution or have the +right to submit it under the project's licence, and you understand that the +contribution and its sign-off are public and may be redistributed. + +## Checks + +Run the source audit and the relevant tests before opening a pull request: + +```sh +python3 scripts/validate.py +python3 -m unittest discover -s tests -p 'test_*.py' +``` + +The audit checks tracked source, rendered Python/shell and the pinned patch +entries. Do not bypass it; fix the source or document the exception in the +pull request. + +## Pull requests + +- Keep changes focused and describe the compatibility problem and the fix. +- Mention any change that touches platform identity, sandbox/AMFI probes, + anti-cheat or account risk. Do not describe it as stealth, evasion, spoofing + or an anti-cheat bypass. +- If you change a patch, verify that it still applies to the pinned revision. +- If you change a user-facing variable, keep the old name as a deprecated alias + for at least one release. + +This is not legal advice. See [`docs/INTEROPERABILITY.md`](docs/INTEROPERABILITY.md). diff --git a/NOTICE.md b/NOTICE.md index 759c3aa..9fafa34 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -6,10 +6,14 @@ The manifest points to public upstream source repositories. This repository incl Some compatibility tools refer to public API names and constants of the user's installed client. The client itself is not redistributed. Installation downloads from Riot's distribution service directly. +## Historical material + +Earlier revisions of this repository included a third-party game logo and an embedded gameplay clip. Both were removed from the current tree; the current tree uses an original project mark and contains no gameplay media. Git history and third-party forks may still contain those earlier revisions. They are not part of the current project, are not licensed by this project, and should not be reused. + ## Trademarks League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. This project is an unofficial compatibility effort and is not affiliated with, endorsed by, or sponsored by Riot Games. -See [compliance and terminology guidance](docs/COMPLIANCE.md) for scope and review expectations. +See [compliance and terminology guidance](docs/COMPLIANCE.md) for scope and review expectations, and [interoperability and compliance scope](docs/INTEROPERABILITY.md) for the project's practices on interoperability, copyright, access controls and trademarks. Neither document is legal advice. diff --git a/README.md b/README.md index 6dc7f33..4879bf9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ This is a development snapshot, not a finished launcher. Riot and League clients - Reusable server receive buffers and Darwin allocator hints translated to Linux lazy reclamation. - Build/install/launch wrappers, atomic incremental installers and standalone regression probes. -Game files, Riot installers, account databases, tokens, local prefixes, captures and compiled binaries are not included. Install the game manually through Riot. The normal launcher does not enable an Electron inspector or start UI automation helpers. The launcher clears `DYLD_INSERT_LIBRARIES` and retains Darling runtime library/framework overrides; this is not a claim of approval by Riot. +Game files, Riot installers, account databases, tokens, local prefixes, captures and compiled binaries are not included. Install the game manually through Riot. The normal launcher does not enable an Electron inspector or start UI automation helpers; the opt-in window-management helpers described below are off by default and do not patch client binaries. The launcher clears `DYLD_INSERT_LIBRARIES` and retains Darling runtime library/framework overrides; this is not a claim of approval by Riot. ## Requirements @@ -154,7 +154,7 @@ for the measured effect and caveats. ## Testing -Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. This project ships no client-side automation, inspection or profiling helpers. +Start with standalone regression checks, then play a match manually. See [testing](docs/TESTING.md). Do not attach a debugger, query client APIs, inject diagnostic hooks or automate client/game input. The project includes standalone compatibility probes and the opt-in host-side window-management helpers described above, but no client injection hooks, client API automation or input automation; the normal launcher enables none of them. FPS counters, game presentation requests and physical display refresh are different measurements. Record average and p99 frame times, the active scene, graphics settings, charger/power state and whether the game stayed focused. See [current status](docs/STATUS.md). @@ -179,12 +179,16 @@ scripts/ portable entry points upstream/manifest.json exact base revisions and patch ownership patches/ current source changes, one patch per repository tools/ runtime templates, installers and standalone regression probes -docs/ setup, testing, status and licensing notes +docs/ setup, testing, status, compliance and interoperability notes build/ local sources, outputs and rendered tools (ignored) ``` Darling/upstream components retain their existing licenses; see [third-party notes](NOTICE.md). Original integration tooling is distributed under GPL-3.0; patches retain the licensing of their target files. +## Interoperability and compliance + +This project is an unofficial interoperability effort. It distributes source patches for Darling and original tooling, not the Riot client, game code, assets or accounts. The normal launcher does not patch the client, game, CEF or anti-cheat. Users obtain the client from Riot and are responsible for complying with Riot's terms; account restrictions are possible. See [interoperability and compliance scope](docs/INTEROPERABILITY.md) and [compliance guidance](docs/COMPLIANCE.md). Neither document is legal advice. + ## Trademarks League of Legends and Riot Games are trademarks or registered trademarks of Riot Games, Inc. @@ -192,8 +196,7 @@ This project is an unofficial compatibility effort and is not affiliated with, e sponsored by Riot Games. No game client, game assets, or account data are distributed in this repository. -See [compliance and terminology guidance](docs/COMPLIANCE.md) for the project's scope and -review expectations. +See [compliance and terminology guidance](docs/COMPLIANCE.md) and [interoperability and compliance scope](docs/INTEROPERABILITY.md) for the project's scope and review expectations. ## Donations diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md index 7fe57b1..a242d16 100644 --- a/docs/COMPLIANCE.md +++ b/docs/COMPLIANCE.md @@ -2,14 +2,20 @@ This project is an unofficial compatibility layer for running the macOS League of Legends client on Linux through Darling. It is not affiliated with, endorsed by, or sponsored by Riot -Games, Inc. +Games, Inc. See [interoperability and compliance scope](INTEROPERABILITY.md) for the project's +practices on interoperability, copyright, access controls and trademarks. Neither document is +legal advice. ## Scope - The project is about compatibility: making the client run, render and behave correctly on Linux. -- It does not aim to circumvent DRM, anti-cheat, licensing, telemetry, or other platform - security controls, and it does not distribute a game client, game assets, or account data. +- It does not aim to circumvent DRM, anti-cheat, licensing, telemetry, authentication, or other + platform security controls, and it does not distribute a game client, game assets, or account + data. +- It does not forge authentication tokens or server-side integrity attestations. The synthetic + platform identity is local compatibility metadata, not an Apple-issued identifier or a Riot + credential. - Do not describe or present the project as a tool for evading detection, preventing bans, bypassing DRM, or defeating anti-cheat. Do not promise that an account will not be banned. @@ -27,6 +33,18 @@ Technical facts can be described precisely without framing the work as evasion. touches a client check or a platform identity, say what it does and why it is needed for compatibility. +## Known local limitations + +- Darling has no macOS MAC/Seatbelt/AMFI framework. The compatibility layer reports the local + `___mac_syscall` probe as satisfied so Chromium/CEF can start. This does **not** implement a + sandbox and does not make the guest secure. Treat the guest as unsandboxed and do not run + untrusted content. It does not interact with Riot's servers or bypass a Riot control. +- Platform-identity values generated by `tools/configure-darling-platform.py` are synthetic and + local. They are not Apple-issued identifiers, credentials or authentication tokens; do not use + them with Apple services or to impersonate real hardware. +- The opt-in close/hide helpers only manage local client windows while a match runs. They do not + patch client binaries, CEF, anti-cheat or DRM, and they are off by default. + ## Assets and third-party material - Do not add or link third-party game assets: logos, icons, fonts, screenshots, gameplay @@ -34,6 +52,9 @@ compatibility. - Project icons and artwork must be original. Forks must use their own name and icon; see the trademark notice in `README.md` and `NOTICE.md`. - Do not commit local prefixes, logs, tokens, captures, or compiled client/runtime binaries. +- Keep upstream copyright, licence and attribution notices. +- Donations are voluntary and support the free tooling. They do not sell the game, a licence, + access or priority. ## Pull requests and issues @@ -43,7 +64,11 @@ compatibility. scope accurate, not about hiding technical details. - Contributions that add third-party assets or present the project as a DRM/anti-cheat circumvention tool are out of scope for this repository. +- Sign off commits under the Developer Certificate of Origin; see + [`CONTRIBUTING.md`](../CONTRIBUTING.md). By contributing, you confirm that you have the right + to submit the work and license it under the project's terms. ## Note -This document describes project scope and review expectations. It is not legal advice. +This document describes project scope and review expectations. It is not legal advice. See +[interoperability and compliance scope](INTEROPERABILITY.md) and [`NOTICE.md`](../NOTICE.md). diff --git a/docs/INTEROPERABILITY.md b/docs/INTEROPERABILITY.md new file mode 100644 index 0000000..e6c75e3 --- /dev/null +++ b/docs/INTEROPERABILITY.md @@ -0,0 +1,171 @@ +# Interoperability and compliance scope + +> This document explains how the project stays within interoperability and +> responsible-use practices. It is **not legal advice**. Software, contract and +> platform rules are fact-specific and differ by country. Consult qualified +> counsel before distributing, modifying or using this project. + +## 1. Purpose + +`darling-lol` is an unofficial interoperability effort. It applies source +patches to [Darling](https://github.com/darlinghq/darling), a free-software +macOS compatibility layer, so that the official macOS Riot client can run on +x86-64 Linux. The user obtains the client from Riot and signs in through Riot's +own client and services. + +The project is not a game, a game server, an account service, a launcher for +pirated content, or a substitute for Riot's platform. It does not provide Riot +accounts, authentication, matchmaking, payments, telemetry or game servers. + +## 2. What we distribute and what we do not + +We keep the project on the interoperability side by distributing only: + +- source patches for Darling, a free-software compatibility layer; +- original integration, build and test tooling; +- documentation and licence notices. + +We do **not** distribute, bundle, mirror or sublicense: + +- the Riot client or game binaries; +- game assets, art, audio, video, fonts or data; +- account databases, tokens or credentials; +- Riot installers or installers for pirated content. + +The user obtains the client from Riot and installs it through Riot's own +installer. The project does not provide accounts, authentication, matchmaking, +payments, telemetry or game servers, and it does not sell the game, a licence, +access or priority. + +## 3. Platform identity + +Darling needs macOS-like platform metadata. `tools/configure-darling-platform.py` +can generate synthetic serial numbers, UUIDs and related fields, or import +values supplied by the user, so that the guest environment is internally +consistent. + +These values are **local compatibility metadata**: + +- they are not Apple-issued identifiers, credentials or authentication tokens; +- they are not used with Apple services and should not be used to impersonate + real hardware; +- they are not sent to Riot as authentication; the official client authenticates + through Riot's own flow. + +See `docs/PLATFORM_IDENTITY.md` for the schema, validation rules and warnings. + +## 4. Local sandbox/AMFI probe + +Darling has no macOS MAC, Seatbelt or AMFI framework. Chromium/CEF, which the +Riot client embeds, probes `___mac_syscall`. An `ENOSYS` response is treated as +fatal and prevents CEF helper processes from starting. The compatibility layer +therefore reports the local probe as satisfied. The relevant code and comments +are in `patches/xnu.patch` (`mac_syscall.c`). + +This compatibility shim: + +- does not implement a sandbox and does not make the guest secure; +- does not interact with Riot's servers or alter Riot's account, payment or + server-side integrity flows; +- does not grant access to Riot's content or services. + +Treat the guest as **unsandboxed**. Do not run untrusted content inside it. This +is a local compatibility limitation, not a claim of security. See +`docs/COMPLIANCE.md` for the project's terminology and scope guidance. + +## 5. Copyright and licences + +We keep the project's copyright and licensing position clear: + +- the game, client, code and assets remain Riot's; we do not reproduce, store, + distribute or commercialise them; +- we distribute patches to free-software Darling and original tooling, not a + copy of the game or the upstream runtime; +- each patch keeps the licence and notices of the upstream file it modifies; + original tooling is GPL-3.0-only; +- we keep the upstream licence and attribution notices in the patched files and + describe the project's licensing position in `NOTICE.md`; +- we do not remove copyright or licence notices from patched files; +- we review good-faith licensing and removal requests. + +We do not sell the game, a licence, access or priority. Donations are voluntary +and support development of the free tooling; they do not purchase the game or +any Riot service. + +## 6. Access, authentication and integrity controls + +The project is designed for compatibility, not for defeating access, +authentication, payment or integrity controls: + +- we do not provide tools or features whose purpose is to defeat those controls; +- we do not forge authentication tokens, session data, account data or + server-side integrity attestations; +- we do not proxy or emulate Riot accounts, matchmaking, payments, telemetry or + game servers; +- we do not patch Riot's client or game binaries, CEF or anti-cheat; +- we do not automate gameplay input or provide cheats; +- the opt-in close/hide helper only manages local client windows while a match + runs; it is off by default and does not change the client, CEF or anti-cheat. + +The synthetic platform identity in §3 is local metadata; it is not a credential +and is not used to authenticate. + +## 7. User agreements and account risk + +Use of the project is at your own risk. You must: + +- own a legitimate Riot account and obtain the client from Riot; +- comply with Riot's terms and user agreement; +- not cheat, exploit, automate gameplay or use the project to gain an unfair + advantage; +- accept that Riot does not support this environment and may suspend or ban + accounts, including accounts used only for testing. Do not use a main or + ranked account. + +The project does not authorise any violation of Riot's terms. If you do not +agree, do not use the project. Account restrictions are a real risk and are +documented in the README announcement. + +## 8. Trademarks and presentation + +League of Legends and Riot Games are trademarks or registered trademarks of Riot +Games, Inc. The names are used only to describe the compatibility target +(nominative use). The project: + +- does not use Riot logos, game art or other Riot brand assets; +- uses an original project mark; +- is not affiliated, endorsed or sponsored by Riot Games; +- asks forks to use their own name and icon and not to imply endorsement. + +See `NOTICE.md` and `docs/COMPLIANCE.md`. + +## 9. Historical material + +Earlier revisions of the repository included a third-party game logo and an +embedded gameplay clip. They were removed from the current tree; the current +tree uses an original project mark and contains no gameplay media. Git history +and third-party forks may still contain those earlier revisions. They are not +part of the current project, are not licensed by this project, and should not be +reused. The project will review good-faith removal requests. + +## 10. Notices and contact + +For a licensing, trademark or interoperability question, contact the maintainer +using the address in `README.md`, or open an issue in the repository. Include: + +- the repository URL, file or commit at issue; +- the right or licence you assert; +- the action you request; +- a way to contact you. + +For a formal copyright notice, use the hosting platform's copyright notice +process. This document is not a designation of an agent for any platform and +does not replace that process. + +## 11. No legal advice and no warranty + +This document is a technical and scope statement, not legal advice. The project +is provided "as is", without warranty of any kind. Nothing here guarantees that +a particular use is lawful in a particular country, that Riot will not restrict +an account, or that a court or regulator will accept the project's position. If +you need legal certainty, consult a lawyer qualified in your country. diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index f8be827..95a791d 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -9,6 +9,12 @@ does not issue authentic Apple identities or change CPU, GPU, or security metadata. The overrides are for application compatibility; they are not intended to bypass anti-cheat, telemetry, licensing, or platform security controls. +> **Synthetic, local metadata.** These values are not Apple-issued identifiers, +> credentials or authentication tokens. Do not use them with Apple services or +> to impersonate real hardware. They are not sent to Riot as authentication; the +> official client authenticates through Riot's own flow. See the +> [interoperability and compliance scope](INTEROPERABILITY.md). + Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing `darling-smbios.conf` without changing the selected prefix: From 56285686e82dbdbbef25dd3385dd0409a3c43fbd Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 13:43:50 -0600 Subject: [PATCH 52/54] docs: add the project repository URL Link the project repository in the interoperability scope so readers can distinguish it from the upstream Darling repository. Signed-off-by: 3AvisD --- docs/INTEROPERABILITY.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/INTEROPERABILITY.md b/docs/INTEROPERABILITY.md index e6c75e3..aee79ac 100644 --- a/docs/INTEROPERABILITY.md +++ b/docs/INTEROPERABILITY.md @@ -7,9 +7,10 @@ ## 1. Purpose -`darling-lol` is an unofficial interoperability effort. It applies source -patches to [Darling](https://github.com/darlinghq/darling), a free-software -macOS compatibility layer, so that the official macOS Riot client can run on +`darling-lol` () is an unofficial +interoperability effort. It applies source patches to +[Darling](https://github.com/darlinghq/darling), a free-software macOS +compatibility layer, so that the official macOS Riot client can run on x86-64 Linux. The user obtains the client from Riot and signs in through Riot's own client and services. From 2fcf44eaaf8e6cd5a74c0025053c654baf2be17c Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 14:03:16 -0600 Subject: [PATCH 53/54] docs: apply review wording corrections Distinguish the opt-in hide helper (local window management) from the opt-in close helper (terminates eligible League client UI processes), and list the tracked project artwork in the distribution scope. Scope the platform-identity statement to repository-owned code: the repository does not pass the identifiers as Riot authentication data, but the values are guest-visible through IOKit and the official client's later handling is outside the repository's scope. Use US spelling for licence consistently. Signed-off-by: 3AvisD --- CONTRIBUTING.md | 14 +++++++------- docs/COMPLIANCE.md | 12 +++++++----- docs/INTEROPERABILITY.md | 32 ++++++++++++++++++-------------- docs/PLATFORM_IDENTITY.md | 6 ++++-- 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9aa077..2ad1cf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,16 +12,16 @@ interoperability effort; contributions must keep it in that scope. - Do not add DRM/anti-cheat/authentication circumvention, telemetry spoofing, credential handling, or gameplay automation. Changes that present the project as an evasion tool are out of scope. -- Keep existing copyright, licence and attribution notices. New third-party code - or assets need a licence and provenance note in the pull request. +- Keep existing copyright, license and attribution notices. New third-party code + or assets need a license and provenance note in the pull request. -## Licence of contributions +## License of contributions - Original integration tooling (scripts, tools, tests, docs and project artwork) is licensed under **GPL-3.0-only**. -- Patches to upstream components are licensed under the licence of the upstream - file(s) they modify. If a patch touches files under different licences, each - hunk keeps the licence of its target file. +- Patches to upstream components are licensed under the license of the upstream + file(s) they modify. If a patch touches files under different licenses, each + hunk keeps the license of its target file. - By submitting a contribution, you confirm that you have the right to submit it and agree to license it under the applicable terms above. @@ -35,7 +35,7 @@ git commit -s -m "area: short description" The sign-off certifies the Developer Certificate of Origin 1.1 (): you created the contribution or have the -right to submit it under the project's licence, and you understand that the +right to submit it under the project's license, and you understand that the contribution and its sign-off are public and may be redistributed. ## Checks diff --git a/docs/COMPLIANCE.md b/docs/COMPLIANCE.md index a242d16..13006c0 100644 --- a/docs/COMPLIANCE.md +++ b/docs/COMPLIANCE.md @@ -15,7 +15,8 @@ legal advice. data. - It does not forge authentication tokens or server-side integrity attestations. The synthetic platform identity is local compatibility metadata, not an Apple-issued identifier or a Riot - credential. + credential. This repository does not pass it as Riot authentication data; the official + client's later handling of guest-visible values is outside this repository's scope. - Do not describe or present the project as a tool for evading detection, preventing bans, bypassing DRM, or defeating anti-cheat. Do not promise that an account will not be banned. @@ -42,8 +43,9 @@ compatibility. - Platform-identity values generated by `tools/configure-darling-platform.py` are synthetic and local. They are not Apple-issued identifiers, credentials or authentication tokens; do not use them with Apple services or to impersonate real hardware. -- The opt-in close/hide helpers only manage local client windows while a match runs. They do not - patch client binaries, CEF, anti-cheat or DRM, and they are off by default. +- The opt-in hide helper manages local client windows while a match runs. The opt-in close helper + terminates eligible League client UI processes. Neither helper patches client binaries, CEF, + anti-cheat or DRM, and both helpers are off by default. ## Assets and third-party material @@ -52,8 +54,8 @@ compatibility. - Project icons and artwork must be original. Forks must use their own name and icon; see the trademark notice in `README.md` and `NOTICE.md`. - Do not commit local prefixes, logs, tokens, captures, or compiled client/runtime binaries. -- Keep upstream copyright, licence and attribution notices. -- Donations are voluntary and support the free tooling. They do not sell the game, a licence, +- Keep upstream copyright, license and attribution notices. +- Donations are voluntary and support the free tooling. They do not sell the game, a license, access or priority. ## Pull requests and issues diff --git a/docs/INTEROPERABILITY.md b/docs/INTEROPERABILITY.md index aee79ac..379e749 100644 --- a/docs/INTEROPERABILITY.md +++ b/docs/INTEROPERABILITY.md @@ -24,7 +24,8 @@ We keep the project on the interoperability side by distributing only: - source patches for Darling, a free-software compatibility layer; - original integration, build and test tooling; -- documentation and licence notices. +- project artwork; +- documentation and license notices. We do **not** distribute, bundle, mirror or sublicense: @@ -35,7 +36,7 @@ We do **not** distribute, bundle, mirror or sublicense: The user obtains the client from Riot and installs it through Riot's own installer. The project does not provide accounts, authentication, matchmaking, -payments, telemetry or game servers, and it does not sell the game, a licence, +payments, telemetry or game servers, and it does not sell the game, a license, access or priority. ## 3. Platform identity @@ -50,8 +51,10 @@ These values are **local compatibility metadata**: - they are not Apple-issued identifiers, credentials or authentication tokens; - they are not used with Apple services and should not be used to impersonate real hardware; -- they are not sent to Riot as authentication; the official client authenticates - through Riot's own flow. +- this repository does not pass them to Riot as authentication data. The values + are exposed to guest applications through IOKit, and the official client's + later handling of them is outside this repository's scope. The official client + authenticates through Riot's own flow. See `docs/PLATFORM_IDENTITY.md` for the schema, validation rules and warnings. @@ -74,7 +77,7 @@ Treat the guest as **unsandboxed**. Do not run untrusted content inside it. This is a local compatibility limitation, not a claim of security. See `docs/COMPLIANCE.md` for the project's terminology and scope guidance. -## 5. Copyright and licences +## 5. Copyright and licenses We keep the project's copyright and licensing position clear: @@ -82,14 +85,14 @@ We keep the project's copyright and licensing position clear: distribute or commercialise them; - we distribute patches to free-software Darling and original tooling, not a copy of the game or the upstream runtime; -- each patch keeps the licence and notices of the upstream file it modifies; +- each patch keeps the license and notices of the upstream file it modifies; original tooling is GPL-3.0-only; -- we keep the upstream licence and attribution notices in the patched files and +- we keep the upstream license and attribution notices in the patched files and describe the project's licensing position in `NOTICE.md`; -- we do not remove copyright or licence notices from patched files; +- we do not remove copyright or license notices from patched files; - we review good-faith licensing and removal requests. -We do not sell the game, a licence, access or priority. Donations are voluntary +We do not sell the game, a license, access or priority. Donations are voluntary and support development of the free tooling; they do not purchase the game or any Riot service. @@ -105,11 +108,12 @@ authentication, payment or integrity controls: game servers; - we do not patch Riot's client or game binaries, CEF or anti-cheat; - we do not automate gameplay input or provide cheats; -- the opt-in close/hide helper only manages local client windows while a match - runs; it is off by default and does not change the client, CEF or anti-cheat. +- the opt-in hide helper manages local client windows while a match runs; the + opt-in close helper terminates eligible League client UI processes. Both are + off by default and do not change client binaries, CEF or anti-cheat. -The synthetic platform identity in §3 is local metadata; it is not a credential -and is not used to authenticate. +The synthetic platform identity in §3 is local metadata; it is not a credential. +This repository does not pass it as Riot authentication data. ## 7. User agreements and account risk @@ -155,7 +159,7 @@ For a licensing, trademark or interoperability question, contact the maintainer using the address in `README.md`, or open an issue in the repository. Include: - the repository URL, file or commit at issue; -- the right or licence you assert; +- the right or license you assert; - the action you request; - a way to contact you. diff --git a/docs/PLATFORM_IDENTITY.md b/docs/PLATFORM_IDENTITY.md index 95a791d..0657aae 100644 --- a/docs/PLATFORM_IDENTITY.md +++ b/docs/PLATFORM_IDENTITY.md @@ -11,8 +11,10 @@ to bypass anti-cheat, telemetry, licensing, or platform security controls. > **Synthetic, local metadata.** These values are not Apple-issued identifiers, > credentials or authentication tokens. Do not use them with Apple services or -> to impersonate real hardware. They are not sent to Riot as authentication; the -> official client authenticates through Riot's own flow. See the +> to impersonate real hardware. This repository does not pass them to Riot as +> authentication data; the values are exposed to guest applications through +> IOKit, and the official client's later handling of them is outside this +> repository's scope. See the > [interoperability and compliance scope](INTEROPERABILITY.md). Preview an OpenCore `config.plist`, a flat GenSMBIOS JSON file, or an existing From adae1b8bed2f3e1924be3cfe9382a09240773709 Mon Sep 17 00:00:00 2001 From: 3AvisD Date: Fri, 23 Oct 2026 14:13:18 -0600 Subject: [PATCH 54/54] docs: clarify the source-audit scope State that scripts/validate.py checks revision format, patch existence and patch parsing but does not verify applicability to pinned revisions. Point patch changes at the separate prepare.py --check-only workflow. Signed-off-by: 3AvisD --- CONTRIBUTING.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ad1cf8..33f6e72 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,9 +47,10 @@ python3 scripts/validate.py python3 -m unittest discover -s tests -p 'test_*.py' ``` -The audit checks tracked source, rendered Python/shell and the pinned patch -entries. Do not bypass it; fix the source or document the exception in the -pull request. +The audit checks tracked source, rendered Python/shell, pinned revision format, +patch existence and patch parsing. It does not check patch applicability to the +pinned revisions. Do not bypass it; fix the source or document the exception in +the pull request. ## Pull requests @@ -57,7 +58,9 @@ pull request. - Mention any change that touches platform identity, sandbox/AMFI probes, anti-cheat or account risk. Do not describe it as stealth, evasion, spoofing or an anti-cheat bypass. -- If you change a patch, verify that it still applies to the pinned revision. +- If you change a patch, run `python3 scripts/prepare.py --check-only` against + an existing pinned source checkout to verify that it applies to the pinned + revision. - If you change a user-facing variable, keep the old name as a deprecated alias for at least one release.