From 3e885421b6af82bc70e9c613ca5ddb17ae1e7311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 16:38:02 -0500 Subject: [PATCH 1/8] Test signed Android APKs and packaged desktop binaries through native input --- .github/workflows/distribution-checks.yml | 182 ++++++++++++++++++++++ tests/test_packaged_game.py | 60 +++++++ tools/check_android_apk.py | 118 ++++++++++++++ tools/check_desktop_binary.py | 141 +++++++++++++++++ tools/check_packaged_game.py | 38 +++++ tools/fetch_release_package.py | 62 ++++++++ 6 files changed, 601 insertions(+) create mode 100644 .github/workflows/distribution-checks.yml create mode 100644 tests/test_packaged_game.py create mode 100644 tools/check_android_apk.py create mode 100644 tools/check_desktop_binary.py create mode 100644 tools/check_packaged_game.py create mode 100644 tools/fetch_release_package.py diff --git a/.github/workflows/distribution-checks.yml b/.github/workflows/distribution-checks.yml new file mode 100644 index 0000000..6dd3e49 --- /dev/null +++ b/.github/workflows/distribution-checks.yml @@ -0,0 +1,182 @@ +name: Distribution checks + +on: + pull_request: + paths: + - '.github/workflows/distribution-checks.yml' + - 'tools/check_*' + - 'tools/fetch_release_package.py' + - 'tests/test_packaged_game.py' + workflow_dispatch: + inputs: + source_commit: + description: Commit used to build the release + required: true + type: string + +permissions: + contents: read + +env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SOURCE_COMMIT: ${{ inputs.source_commit || github.event.pull_request.base.sha || github.sha }} + ALSOFT_DRIVERS: 'null' + +jobs: + linux: + name: Linux ${{ matrix.kind }} binary + runs-on: ubuntu-22.04 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - kind: AppImage + asset: 'Light-Engine-{version}-x86_64.AppImage' + - kind: portable + asset: 'Light-Engine-{version}-linux-x86_64.tar.gz' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install native GUI test dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq xvfb xdotool libgl1 libopengl0 libegl1 libasound2 + python3 -m pip install Pillow==11.3.0 python-xlib==0.33 + - name: Download and verify the built release asset + env: + ASSET: ${{ matrix.asset }} + run: python3 tools/fetch_release_package.py "$ASSET" .ci/package --commit "$SOURCE_COMMIT" + - name: Verify shipped source and run the packaged binary + env: + PACKAGE_KIND: ${{ matrix.kind }} + APPIMAGE_EXTRACT_AND_RUN: '1' + run: | + set -euo pipefail + if [ "$PACKAGE_KIND" = AppImage ]; then + chmod +x .ci/package + (cd .ci && ./package --appimage-extract > extraction.log) + GAME=.ci/squashfs-root/game.love + LAUNCHER="$PWD/.ci/package" + else + tar -xzf .ci/package -C .ci + GAME=$(find .ci -name game.love -print -quit) + LAUNCHER="$PWD/$(dirname "$GAME")/light-engine.sh" + fi + python3 tools/check_packaged_game.py "$GAME" + xvfb-run -a python3 tools/check_desktop_binary.py \ + --output "test-results/distribution-linux-$PACKAGE_KIND" "$LAUNCHER" + - name: Upload native screenshots and diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: distribution-linux-${{ matrix.kind }} + path: test-results/ + retention-days: 14 + + windows: + name: Windows ${{ matrix.arch }} binary + runs-on: windows-latest + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + arch: [x64, x86] + env: + GALLIUM_DRIVER: llvmpipe + LIBGL_ALWAYS_SOFTWARE: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Install screenshot support + run: python -m pip install Pillow==11.3.0 + - name: Download and verify the built release asset + env: + PACKAGE_ARCH: ${{ matrix.arch }} + run: python tools/fetch_release_package.py "Light-Engine-{version}-windows-$env:PACKAGE_ARCH.zip" .ci/package.zip --commit "$env:SOURCE_COMMIT" + - name: Extract and verify the fused game executable + run: | + python -c "import zipfile; zipfile.ZipFile('.ci/package.zip').extractall('.ci/distribution')" + python tools/check_packaged_game.py ".ci/distribution/Light Engine/Light Engine.exe" + - name: Install a software OpenGL renderer + env: + PACKAGE_ARCH: ${{ matrix.arch }} + run: | + Invoke-WebRequest https://github.com/pal1000/mesa-dist-win/releases/download/25.0.7/mesa3d-25.0.7-release-msvc.7z -OutFile .ci/mesa.7z + 7z x -y .ci/mesa.7z "-o.ci/mesa" + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $Mesa = Get-ChildItem .ci/mesa -Recurse -Filter opengl32.dll | Where-Object { $_.Directory.Name -eq $env:PACKAGE_ARCH } | Select-Object -First 1 + if (-not $Mesa) { throw "Mesa architecture missing: $env:PACKAGE_ARCH" } + Copy-Item (Join-Path $Mesa.Directory.FullName '*.dll') '.ci/distribution/Light Engine' + "SDL_OPENGL_LIBRARY=$((Resolve-Path '.ci/distribution/Light Engine/opengl32.dll').Path)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + - name: Run the packaged game through native input + env: + PACKAGE_ARCH: ${{ matrix.arch }} + run: python tools/check_desktop_binary.py --output "test-results/distribution-windows-$env:PACKAGE_ARCH" ".ci/distribution/Light Engine/Light Engine.exe" + - name: Exercise the real desktop companion windows + if: matrix.arch == 'x64' + run: | + python -m pip install -r separate-addons/screen-overlay/desktop/requirements.txt + python -m unittest discover -s separate-addons/screen-overlay/desktop/tests -p test_gui.py + - name: Upload native screenshots and diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: distribution-windows-${{ matrix.arch }} + path: test-results/ + retention-days: 14 + + android: + name: Android ${{ matrix.kind }} signed APK + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - kind: universal + suffix: android + abi: arm64-v8a + - kind: arm64 + suffix: android-arm64-v8a + abi: arm64-v8a + - kind: armv7 + suffix: android-armeabi-v7a + abi: armeabi-v7a + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Download and verify the signed release APK + env: + APK_SUFFIX: ${{ matrix.suffix }} + run: | + python3 tools/fetch_release_package.py "Light-Engine-{version}-$APK_SUFFIX.apk" .ci/game.apk --commit "$SOURCE_COMMIT" + python3 tools/check_packaged_game.py .ci/game.apk --android + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run the ARM release using Android native translation + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + target: google_apis + arch: x86_64 + profile: pixel_2 + emulator-boot-timeout: 600 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot -camera-back none -memory 4096 + script: python3 tools/check_android_apk.py .ci/game.apk --abi ${{ matrix.abi }} --output test-results/distribution-android-${{ matrix.kind }} + - name: Upload emulator screenshots and logcat + if: always() + uses: actions/upload-artifact@v4 + with: + name: distribution-android-${{ matrix.kind }} + path: test-results/ + retention-days: 14 diff --git a/tests/test_packaged_game.py b/tests/test_packaged_game.py new file mode 100644 index 0000000..475646f --- /dev/null +++ b/tests/test_packaged_game.py @@ -0,0 +1,60 @@ +"""Check shipped bytes for ZIP, fused executable and direct APK asset layouts.""" +import importlib.util +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +import zipfile + +TOOLS = Path(__file__).resolve().parents[1] / 'tools' +sys.path.insert(0, str(TOOLS)) +spec = importlib.util.spec_from_file_location('check_packaged_game', TOOLS / 'check_packaged_game.py') +checker = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checker) + + +class PackagedGameTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + (self.root / 'main.lua').write_bytes(b'function love.load() end\n') + (self.root / 'assets').mkdir() + (self.root / 'assets/pixel.png').write_bytes(b'original\x00bytes') + + def tearDown(self): + self.temp.cleanup() + + def verify(self, package, android=False): + with patch.object(checker, 'ROOT', str(self.root)), \ + patch.object(checker, 'collect_files', return_value=['main.lua', 'assets/pixel.png']): + checker.verify(package, android) + + def package(self, name, prefix='', changed=False, junk=False): + path = self.root / name + with zipfile.ZipFile(path, 'w') as archive: + archive.writestr(prefix + 'main.lua', (self.root / 'main.lua').read_bytes()) + archive.writestr(prefix + 'assets/pixel.png', b'changed' if changed else (self.root / 'assets/pixel.png').read_bytes()) + if junk: + archive.writestr(prefix + '__pycache__/cached.pyc', b'cache') + return path + + def test_zip_fused_executable_and_apk_preserve_runtime_bytes(self): + archive = self.package('game.love') + self.verify(archive) + fused = self.root / 'game.exe' + fused.write_bytes(b'MZ' + b'\x00' * 126 + archive.read_bytes()) + self.verify(fused) + self.verify(self.package('game.apk', prefix='assets/'), android=True) + + def test_changed_assets_are_rejected(self): + with self.assertRaisesRegex(ValueError, 'different bytes for assets/pixel.png'): + self.verify(self.package('changed.love', changed=True)) + + def test_development_cache_is_rejected(self): + with self.assertRaisesRegex(ValueError, 'development files'): + self.verify(self.package('dirty.love', junk=True)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/check_android_apk.py b/tools/check_android_apk.py new file mode 100644 index 0000000..7039dd3 --- /dev/null +++ b/tools/check_android_apk.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Install and exercise the signed release APK on a running Android emulator.""" +import argparse +from pathlib import Path +import re +import subprocess +import time + +PACKAGE = 'com.zyn.lightengine' +LAUNCHER = PACKAGE + '/org.love2d.android.StoragePermissionActivity' + + +def adb(*args, check=True, timeout=60, binary=False): + result = subprocess.run(['adb', *args], capture_output=True, text=not binary, + timeout=timeout, check=check) + return result.stdout + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('apk', type=Path) + parser.add_argument('--abi', required=True, choices=('arm64-v8a', 'armeabi-v7a')) + parser.add_argument('--output', required=True, type=Path) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + properties = adb('shell', 'getprop') + (args.output / 'device-properties.txt').write_text(properties) + if args.abi not in adb('shell', 'getprop', 'ro.product.cpu.abilist').strip().split(','): + raise SystemExit('Emulator does not advertise the requested ARM ABI: ' + args.abi) + adb('root') + adb('wait-for-device') + adb('install', '-r', '--abi', args.abi, str(args.apk), timeout=300) + adb('shell', 'pm', 'clear', PACKAGE) + adb('logcat', '-c') + diagnostic = None + + def screenshot(name): + (args.output / (name + '.png')).write_bytes(adb('exec-out', 'screencap', '-p', binary=True)) + + def key(value): + adb('shell', 'input', 'keyevent', value) + + def wait_state(state, timeout=150): + nonlocal diagnostic + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not adb('shell', 'pidof', PACKAGE, check=False).strip(): + raise RuntimeError('Release APK process exited while waiting for ' + state) + if not diagnostic: + found = adb('shell', 'find /data/data/' + PACKAGE + ' /sdcard/Android/data/' + PACKAGE + + ' -name engine.log 2>/dev/null', check=False) + diagnostic = next((p for p in found.splitlines() if p.endswith('/diagnostics/engine.log')), None) + if diagnostic: + text = adb('shell', 'cat', diagnostic, check=False) + (args.output / 'engine.log').write_text(text) + error = adb('shell', 'cat', diagnostic.replace('engine.log', 'last-error.txt'), check=False) + if error.strip(): + raise RuntimeError(error) + if text.splitlines() and ('state=' + state) in text.splitlines()[-1]: + print('ANDROID STATE: ' + state, flush=True) + screenshot(state) + return + time.sleep(2) + raise RuntimeError('Release APK did not enter ' + state) + + try: + adb('shell', 'am', 'start', '-W', '-n', LAUNCHER) + time.sleep(3) + screenshot('storage-permission') + adb('shell', 'appops', 'set', PACKAGE, 'MANAGE_EXTERNAL_STORAGE', 'allow') + adb('shell', 'am', 'force-stop', PACKAGE) + adb('shell', 'am', 'start', '-W', '-n', LAUNCHER) + wait_state('TitleState') + key('KEYCODE_ENTER') + time.sleep(1) + key('KEYCODE_ENTER') + wait_state('MainMenuState') + key('KEYCODE_DPAD_DOWN') + time.sleep(0.3) + key('KEYCODE_ENTER') + wait_state('FreeplayState') + key('KEYCODE_ENTER') + wait_state('PlayState') + for _ in range(3): + for value in ('KEYCODE_A', 'KEYCODE_S', 'KEYCODE_W', 'KEYCODE_D'): + key(value) + time.sleep(0.2) + key('KEYCODE_P') + time.sleep(2) + screenshot('paused') + key('KEYCODE_ENTER') + time.sleep(3) + screenshot('resumed') + key('KEYCODE_HOME') + time.sleep(2) + adb('shell', 'am', 'start', '-W', '-n', PACKAGE + '/org.love2d.android.GameActivity') + time.sleep(3) + screenshot('foreground-restored') + if not adb('shell', 'pidof', PACKAGE).strip(): + raise RuntimeError('Release APK did not survive background/foreground') + log = adb('logcat', '-d') + if re.search(r'ANR in com\.zyn\.lightengine|(?:Fatal signal|FATAL EXCEPTION)[\s\S]{0,500}com\.zyn\.lightengine', log): + raise RuntimeError('Android reported an application crash or ANR') + if diagnostic: + error = adb('shell', 'cat', diagnostic.replace('engine.log', 'last-error.txt'), check=False) + if error.strip(): + raise RuntimeError(error) + print('App-private storage: ' + adb('shell', 'du', '-sk', '/data/data/' + PACKAGE + '/files', check=False).strip()) + print('ANDROID APK PASSED: ' + args.abi + ', signed install, storage, native menus, song, input, pause/resume and foreground restore') + finally: + (args.output / 'logcat.txt').write_text(adb('logcat', '-d', check=False)) + screenshot('final') + adb('shell', 'am', 'force-stop', PACKAGE, check=False) + adb('uninstall', PACKAGE, check=False) + + +if __name__ == '__main__': + main() diff --git a/tools/check_desktop_binary.py b/tools/check_desktop_binary.py new file mode 100644 index 0000000..ab7c421 --- /dev/null +++ b/tools/check_desktop_binary.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Exercise a packaged desktop game through native keyboard and window events.""" +import argparse +import ctypes +import os +from pathlib import Path +import subprocess +import time + +from PIL import ImageGrab + + +class Desktop: + def __init__(self): + self.windows = os.name == 'nt' + self.window = None + + def focus(self): + if self.windows: + user = ctypes.windll.user32 + user.GetWindowTextW.argtypes = [ctypes.c_void_p, ctypes.c_wchar_p, ctypes.c_int] + user.IsWindowVisible.argtypes = [ctypes.c_void_p] + user.SetForegroundWindow.argtypes = [ctypes.c_void_p] + user.PostMessageW.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_size_t, ctypes.c_ssize_t] + found = [] + callback_type = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p) + def visit(window, _): + title = ctypes.create_unicode_buffer(512) + user.GetWindowTextW(window, title, len(title)) + if user.IsWindowVisible(window) and title.value.startswith('Light Engine'): + found.append(window) + return True + user.EnumWindows(callback_type(visit), 0) + if not found: + raise RuntimeError('Native game window was not created') + self.window = found[0] + user.SetForegroundWindow(self.window) + else: + result = subprocess.check_output(['xdotool', 'search', '--onlyvisible', '--name', '^Light Engine'], text=True) + self.window = result.splitlines()[-1] + subprocess.run(['xdotool', 'windowfocus', self.window], check=True) + + def key(self, key): + self.focus() + if self.windows: + value = {'Return': 0x0D, 'Down': 0x28, 'BackSpace': 0x08}.get(key, ord(key.upper()[0])) + ctypes.windll.user32.keybd_event(value, 0, 0, 0) + time.sleep(0.1) + ctypes.windll.user32.keybd_event(value, 0, 2, 0) + else: + subprocess.run(['xdotool', 'key', '--clearmodifiers', key], check=True) + + def close(self): + if self.windows: + ctypes.windll.user32.PostMessageW(self.window, 0x0010, 0, 0) + else: + from Xlib import display, protocol + connection = display.Display() + window = connection.create_resource_object('window', int(self.window)) + event = protocol.event.ClientMessage(window=window, + client_type=connection.intern_atom('WM_PROTOCOLS'), + data=(32, [connection.intern_atom('WM_DELETE_WINDOW'), 0, 0, 0, 0])) + window.send_event(event, event_mask=0) + connection.flush() + connection.close() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, required=True) + parser.add_argument('command', nargs=argparse.REMAINDER) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + if os.name == 'nt': + saves = Path(os.environ['APPDATA']) / 'LOVE' / 'com.zyn.lightengine' + else: + saves = Path(os.environ.get('XDG_DATA_HOME', str(Path.home() / '.local/share'))) / 'love' / 'com.zyn.lightengine' + diagnostic = saves / 'diagnostics' / 'engine.log' + errors = saves / 'diagnostics' / 'last-error.txt' + if errors.exists(): + raise SystemExit('Use a clean runner profile for packaged game checks') + desktop = Desktop() + with (args.output / 'stdout.log').open('w', encoding='utf-8') as output: + process = subprocess.Popen(args.command, stdout=output, stderr=subprocess.STDOUT) + def wait_state(state, timeout=90): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError('Packaged game exited early: ' + str(process.returncode)) + if errors.exists(): + raise RuntimeError(errors.read_text(errors='replace')) + if diagnostic.exists() and ('state=' + state) in diagnostic.read_text(errors='replace').splitlines()[-1]: + print('NATIVE STATE: ' + state, flush=True) + desktop.focus() + ImageGrab.grab().save(args.output / (state + '.png')) + return + time.sleep(1) + raise RuntimeError('Packaged game did not enter ' + state) + try: + wait_state('TitleState') + desktop.key('Return') + time.sleep(1) + desktop.key('Return') + wait_state('MainMenuState') + desktop.key('Down') + time.sleep(0.3) + desktop.key('Return') + wait_state('FreeplayState') + desktop.key('Return') + wait_state('PlayState') + for _ in range(4): + for key in ('a', 's', 'w', 'd'): + desktop.key(key) + time.sleep(0.15) + desktop.key('p') + time.sleep(2) + ImageGrab.grab().save(args.output / 'paused.png') + desktop.key('Return') + time.sleep(3) + ImageGrab.grab().save(args.output / 'resumed.png') + if errors.exists(): + raise RuntimeError(errors.read_text(errors='replace')) + desktop.close() + code = process.wait(timeout=20) + if code != 0: + raise RuntimeError('Packaged game quit with exit ' + str(code)) + if (saves / 'diagnostics/session.active').exists(): + raise RuntimeError('Packaged game did not finish diagnostics cleanly') + print('DESKTOP BINARY PASSED: native menus, song, input, pause/resume and clean shutdown') + finally: + if diagnostic.exists(): + (args.output / 'engine.log').write_bytes(diagnostic.read_bytes()) + if errors.exists(): + (args.output / 'last-error.txt').write_bytes(errors.read_bytes()) + if process.poll() is None: + process.kill() + process.wait(timeout=10) + + +if __name__ == '__main__': + main() diff --git a/tools/check_packaged_game.py b/tools/check_packaged_game.py new file mode 100644 index 0000000..add540a --- /dev/null +++ b/tools/check_packaged_game.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Compare a distributed game archive or APK with the checked-out runtime.""" +import argparse +import hashlib +from pathlib import Path +import zipfile + +from make_game_love import ROOT, collect_files + + +def verify(package, android=False): + prefix = 'assets/' if android else '' + with zipfile.ZipFile(package) as archive: + expected = collect_files() + for name in expected: + source = Path(ROOT, name).read_bytes() + try: + shipped = archive.read(prefix + name) + except KeyError: + raise ValueError('Package is missing ' + name) from None + if hashlib.sha256(source).digest() != hashlib.sha256(shipped).digest(): + raise ValueError('Package has different bytes for ' + name) + junk = [name for name in archive.namelist() if '__pycache__' in name.split('/') or name.endswith(('.pyc', '.idsig'))] + if junk: + raise ValueError('Package contains development files: ' + ', '.join(junk)) + print(f'PACKAGED GAME VERIFIED: {len(expected)} runtime files match the source checkout') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('package', type=Path) + parser.add_argument('--android', action='store_true') + args = parser.parse_args() + verify(args.package, args.android) + + +if __name__ == '__main__': + main() diff --git a/tools/fetch_release_package.py b/tools/fetch_release_package.py new file mode 100644 index 0000000..1fdede7 --- /dev/null +++ b/tools/fetch_release_package.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Download a release asset and verify its recorded digest and source commit.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time + + +def api(path): + result = subprocess.run(['gh', 'api', path], capture_output=True, text=True, timeout=40) + if result.returncode: + return None + return json.loads(result.stdout) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('asset') + parser.add_argument('output', type=Path) + parser.add_argument('--commit', required=True) + parser.add_argument('--timeout', type=int, default=1800) + args = parser.parse_args() + repository = os.environ['GITHUB_REPOSITORY'] + source = (Path(__file__).resolve().parents[1] / 'project.lua').read_text() + import re + version = re.search(r'^\s*version\s*=\s*"([^"\n]+)"', source, re.MULTILINE).group(1) + name = args.asset.replace('{version}', version) + deadline = time.monotonic() + args.timeout + asset = None + while time.monotonic() < deadline: + releases = api(f'repos/{repository}/releases?per_page=30') or [] + release = next((r for r in releases if r['tag_name'] == 'v' + version), None) + if release: + if release['target_commitish'] != args.commit: + raise SystemExit('Release does not belong to the expected source commit: ' + release['target_commitish']) + asset = next((a for a in release['assets'] if a['name'] == name and a['state'] == 'uploaded'), None) + if asset: + break + print('Waiting for release asset: ' + name, flush=True) + time.sleep(20) + if not asset: + raise SystemExit('Release asset was not produced: ' + name) + digest = asset.get('digest', '') + if not digest.startswith('sha256:'): + raise SystemExit('Release asset has no SHA-256 digest') + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('wb') as handle: + subprocess.run(['gh', 'api', '-H', 'Accept: application/octet-stream', + f'repos/{repository}/releases/assets/{asset["id"]}'], stdout=handle, check=True, timeout=600) + with args.output.open('rb') as handle: + actual = hashlib.file_digest(handle, 'sha256').hexdigest() + if actual != digest.split(':', 1)[1]: + args.output.unlink() + raise SystemExit('Downloaded asset failed its SHA-256 check') + print(json.dumps({'asset': name, 'sourceCommit': args.commit, 'sha256': actual, 'bytes': args.output.stat().st_size}), flush=True) + + +if __name__ == '__main__': + main() From b414b491d28eae4f20ecf12a8dc0e22834c5ef75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 16:45:08 -0500 Subject: [PATCH 2/8] Read fused save paths and verify Android process continuity --- tests/test_packaged_game.py | 6 ++++++ tools/check_android_apk.py | 15 +++++++++++---- tools/check_desktop_binary.py | 11 ++++++++--- tools/check_packaged_game.py | 6 ++++++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/tests/test_packaged_game.py b/tests/test_packaged_game.py index 475646f..f90bfb4 100644 --- a/tests/test_packaged_game.py +++ b/tests/test_packaged_game.py @@ -55,6 +55,12 @@ def test_development_cache_is_rejected(self): with self.assertRaisesRegex(ValueError, 'development files'): self.verify(self.package('dirty.love', junk=True)) + def test_android_aapt_excludes_editor_directories(self): + apk = self.package('android.love', prefix='assets/') + with patch.object(checker, 'ROOT', str(self.root)), \ + patch.object(checker, 'collect_files', return_value=['main.lua', 'assets/pixel.png', 'loxel/.vscode/settings.json']): + checker.verify(apk, android=True) + if __name__ == '__main__': unittest.main() diff --git a/tools/check_android_apk.py b/tools/check_android_apk.py index 7039dd3..310b41d 100644 --- a/tools/check_android_apk.py +++ b/tools/check_android_apk.py @@ -40,7 +40,7 @@ def screenshot(name): def key(value): adb('shell', 'input', 'keyevent', value) - def wait_state(state, timeout=150): + def wait_state(state, timeout=150, previous_log=None): nonlocal diagnostic deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -56,7 +56,7 @@ def wait_state(state, timeout=150): error = adb('shell', 'cat', diagnostic.replace('engine.log', 'last-error.txt'), check=False) if error.strip(): raise RuntimeError(error) - if text.splitlines() and ('state=' + state) in text.splitlines()[-1]: + if text != previous_log and text.splitlines() and ('state=' + state) in text.splitlines()[-1]: print('ANDROID STATE: ' + state, flush=True) screenshot(state) return @@ -91,10 +91,16 @@ def wait_state(state, timeout=150): key('KEYCODE_ENTER') time.sleep(3) screenshot('resumed') + original_pid = adb('shell', 'pidof', PACKAGE).strip() + previous_log = adb('shell', 'cat', diagnostic) key('KEYCODE_HOME') time.sleep(2) + if adb('shell', 'pidof', PACKAGE, check=False).strip() != original_pid: + raise RuntimeError('Android killed the game while backgrounded') adb('shell', 'am', 'start', '-W', '-n', PACKAGE + '/org.love2d.android.GameActivity') - time.sleep(3) + if adb('shell', 'pidof', PACKAGE, check=False).strip() != original_pid: + raise RuntimeError('Foregrounding restarted the game process') + wait_state('PlayState', previous_log=previous_log) screenshot('foreground-restored') if not adb('shell', 'pidof', PACKAGE).strip(): raise RuntimeError('Release APK did not survive background/foreground') @@ -106,7 +112,8 @@ def wait_state(state, timeout=150): if error.strip(): raise RuntimeError(error) print('App-private storage: ' + adb('shell', 'du', '-sk', '/data/data/' + PACKAGE + '/files', check=False).strip()) - print('ANDROID APK PASSED: ' + args.abi + ', signed install, storage, native menus, song, input, pause/resume and foreground restore') + print('ANDROID APK PASSED: ' + args.abi + ', signed install, storage, input-driven menus, song loading and process/state continuity') + print('MANUAL REVIEW REQUIRED: gameplay input and pause/resume screenshots') finally: (args.output / 'logcat.txt').write_text(adb('logcat', '-d', check=False)) screenshot('final') diff --git a/tools/check_desktop_binary.py b/tools/check_desktop_binary.py index ab7c421..4ff5689 100644 --- a/tools/check_desktop_binary.py +++ b/tools/check_desktop_binary.py @@ -72,9 +72,9 @@ def main(): args = parser.parse_args() args.output.mkdir(parents=True, exist_ok=True) if os.name == 'nt': - saves = Path(os.environ['APPDATA']) / 'LOVE' / 'com.zyn.lightengine' + saves = Path(os.environ['APPDATA']) / 'com.zyn.lightengine' else: - saves = Path(os.environ.get('XDG_DATA_HOME', str(Path.home() / '.local/share'))) / 'love' / 'com.zyn.lightengine' + saves = Path(os.environ.get('XDG_DATA_HOME', str(Path.home() / '.local/share'))) / 'com.zyn.lightengine' diagnostic = saves / 'diagnostics' / 'engine.log' errors = saves / 'diagnostics' / 'last-error.txt' if errors.exists(): @@ -126,8 +126,13 @@ def wait_state(state, timeout=90): raise RuntimeError('Packaged game quit with exit ' + str(code)) if (saves / 'diagnostics/session.active').exists(): raise RuntimeError('Packaged game did not finish diagnostics cleanly') - print('DESKTOP BINARY PASSED: native menus, song, input, pause/resume and clean shutdown') + print('DESKTOP BINARY PASSED: input-driven menu navigation, song loading and clean shutdown') + print('MANUAL REVIEW REQUIRED: gameplay input and pause/resume screenshots') finally: + try: + ImageGrab.grab().save(args.output / 'final.png') + except OSError as error: + print('Final screenshot unavailable: ' + str(error)) if diagnostic.exists(): (args.output / 'engine.log').write_bytes(diagnostic.read_bytes()) if errors.exists(): diff --git a/tools/check_packaged_game.py b/tools/check_packaged_game.py index add540a..66dc06c 100644 --- a/tools/check_packaged_game.py +++ b/tools/check_packaged_game.py @@ -12,6 +12,12 @@ def verify(package, android=False): prefix = 'assets/' if android else '' with zipfile.ZipFile(package) as archive: expected = collect_files() + if android: + # Android's default AAPT asset policy excludes hidden paths and + # directories beginning with an underscore from the APK. + expected = [name for name in expected if not any(part.startswith('.') for part in Path(name).parts) + and not any(part.startswith('_') for part in Path(name).parts[:-1]) + and not name.endswith('~')] for name in expected: source = Path(ROOT, name).read_bytes() try: From 95abc0abb6ce506d46fa6169642c504c6efd7938 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 16:56:36 -0500 Subject: [PATCH 3/8] Handle native key scancodes and Android storage settings during package tests --- tools/check_android_apk.py | 14 ++++++++++---- tools/check_desktop_binary.py | 13 +++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/tools/check_android_apk.py b/tools/check_android_apk.py index 310b41d..b0c1ea3 100644 --- a/tools/check_android_apk.py +++ b/tools/check_android_apk.py @@ -43,9 +43,14 @@ def key(value): def wait_state(state, timeout=150, previous_log=None): nonlocal diagnostic deadline = time.monotonic() + timeout + seen_process = previous_log is not None while time.monotonic() < deadline: if not adb('shell', 'pidof', PACKAGE, check=False).strip(): - raise RuntimeError('Release APK process exited while waiting for ' + state) + if seen_process: + raise RuntimeError('Release APK process exited while waiting for ' + state) + time.sleep(2) + continue + seen_process = True if not diagnostic: found = adb('shell', 'find /data/data/' + PACKAGE + ' /sdcard/Android/data/' + PACKAGE + ' -name engine.log 2>/dev/null', check=False) @@ -67,9 +72,10 @@ def wait_state(state, timeout=150, previous_log=None): adb('shell', 'am', 'start', '-W', '-n', LAUNCHER) time.sleep(3) screenshot('storage-permission') - adb('shell', 'appops', 'set', PACKAGE, 'MANAGE_EXTERNAL_STORAGE', 'allow') - adb('shell', 'am', 'force-stop', PACKAGE) - adb('shell', 'am', 'start', '-W', '-n', LAUNCHER) + adb('shell', 'appops', 'set', '--uid', PACKAGE, 'MANAGE_EXTERNAL_STORAGE', 'allow') + key('KEYCODE_BACK') + time.sleep(1) + adb('shell', 'am', 'start', '-W', '-a', 'android.intent.action.MAIN', '-n', LAUNCHER) wait_state('TitleState') key('KEYCODE_ENTER') time.sleep(1) diff --git a/tools/check_desktop_binary.py b/tools/check_desktop_binary.py index 4ff5689..d1db57b 100644 --- a/tools/check_desktop_binary.py +++ b/tools/check_desktop_binary.py @@ -44,11 +44,16 @@ def key(self, key): self.focus() if self.windows: value = {'Return': 0x0D, 'Down': 0x28, 'BackSpace': 0x08}.get(key, ord(key.upper()[0])) - ctypes.windll.user32.keybd_event(value, 0, 0, 0) - time.sleep(0.1) - ctypes.windll.user32.keybd_event(value, 0, 2, 0) + user = ctypes.windll.user32 + scan = user.MapVirtualKeyW(value, 0) + extended = 1 if key == 'Down' else 0 + user.keybd_event(value, scan, extended, 0) + time.sleep(0.25) + user.keybd_event(value, scan, extended | 2, 0) else: - subprocess.run(['xdotool', 'key', '--clearmodifiers', key], check=True) + subprocess.run(['xdotool', 'keydown', '--clearmodifiers', key], check=True) + time.sleep(0.25) + subprocess.run(['xdotool', 'keyup', key], check=True) def close(self): if self.windows: From d1167242fd355279d6e6389c347a3208b4783ba4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 17:08:31 -0500 Subject: [PATCH 4/8] Exercise the signed overlay companion on Android 15 --- .github/workflows/distribution-checks.yml | 38 ++++++ tools/check_android_overlay.py | 149 ++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tools/check_android_overlay.py diff --git a/.github/workflows/distribution-checks.yml b/.github/workflows/distribution-checks.yml index 6dd3e49..3d0ee60 100644 --- a/.github/workflows/distribution-checks.yml +++ b/.github/workflows/distribution-checks.yml @@ -17,6 +17,10 @@ on: permissions: contents: read +concurrency: + group: distribution-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SOURCE_COMMIT: ${{ inputs.source_commit || github.event.pull_request.base.sha || github.sha }} @@ -180,3 +184,37 @@ jobs: name: distribution-android-${{ matrix.kind }} path: test-results/ retention-days: 14 + + android-overlay: + name: Android 15 overlay companion APK + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Download and verify the signed companion APK + run: python3 tools/fetch_release_package.py Light-Engine-screen-overlay-android.apk .ci/overlay.apk --commit "$SOURCE_COMMIT" + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Exercise consent and native overlay windows + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_2 + emulator-boot-timeout: 600 + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -no-snapshot -camera-back none -memory 4096 + script: python3 tools/check_android_overlay.py .ci/overlay.apk --output test-results/distribution-android-overlay + - name: Upload companion screenshots and logcat + if: always() + uses: actions/upload-artifact@v4 + with: + name: distribution-android-overlay + path: test-results/ + retention-days: 14 diff --git a/tools/check_android_overlay.py b/tools/check_android_overlay.py new file mode 100644 index 0000000..3363246 --- /dev/null +++ b/tools/check_android_overlay.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Exercise the published image-overlay APK through Android's real UI.""" +import argparse +import base64 +from pathlib import Path +import re +import shlex +import struct +import subprocess +import time +from urllib.parse import urlencode +import xml.etree.ElementTree as ET +import zlib + +PACKAGE = 'com.zyn.lightengine.overlay' +ACTIVITY = PACKAGE + '/.OverlayActivity' +TOKEN = 'ab' * 32 + + +def adb(*args, check=True, binary=False): + return subprocess.run(['adb', *args], capture_output=True, text=not binary, + timeout=60, check=check).stdout + + +def image(): + def chunk(kind, data): + return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data)) + pixels = (b'\x00' + b'\x22\xcc\x66\xff' * 16) * 16 + return b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', 16, 16, 8, 6, 0, 0, 0)) \ + + chunk(b'IDAT', zlib.compress(pixels)) + chunk(b'IEND', b'') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('apk', type=Path) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + args.output.mkdir(parents=True, exist_ok=True) + adb('install', '-r', str(args.apk)) + adb('shell', 'pm', 'clear', PACKAGE) + adb('logcat', '-c') + + def screenshot(name): + (args.output / (name + '.png')).write_bytes(adb('exec-out', 'screencap', '-p', binary=True)) + + def nodes(): + adb('shell', 'uiautomator', 'dump', '/sdcard/overlay-window.xml', check=False) + text = adb('shell', 'cat', '/sdcard/overlay-window.xml', check=False) + try: + return list(ET.fromstring(text).iter('node')) + except ET.ParseError: + return [] + + def find(text, timeout=30): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for node in nodes(): + if text in (node.get('text'), node.get('content-desc')): + return node + time.sleep(0.5) + raise RuntimeError('Android UI did not expose: ' + text) + + def center(node): + x1, y1, x2, y2 = map(int, re.findall(r'\d+', node.get('bounds'))) + return (x1 + x2) // 2, (y1 + y2) // 2 + + def tap(text): + x, y = center(find(text)) + adb('shell', 'input', 'tap', str(x), str(y)) + + def request(hide=False, token=TOKEN): + fields = {'token': token} + if not hide: + fields.update(image=base64.urlsafe_b64encode(image()).decode().rstrip('='), + x=100, y=200, width=300, height=200, label='Runtime PNG check') + uri = 'lightengine-overlay://' + ('hide' if hide else 'show') + '?' + urlencode(fields) + adb('shell', 'am start -W -a android.intent.action.VIEW -n ' + ACTIVITY + ' -d ' + shlex.quote(uri)) + + def active(): + return any(n.get('content-desc') == 'Close image overlay' for n in nodes()) + + def wait_closed(): + deadline = time.monotonic() + 20 + while time.monotonic() < deadline: + services = adb('shell', 'dumpsys', 'activity', 'services', PACKAGE) + if not active() and 'ServiceRecord{' not in services: + return + time.sleep(0.5) + raise RuntimeError('Android overlay or foreground service did not stop') + + try: + adb('shell', 'am', 'start', '-W', '-n', ACTIVITY) + time.sleep(1) + if not adb('shell', 'pidof', PACKAGE, check=False).strip(): + raise RuntimeError('Overlay APK did not launch') + screenshot('launcher') + request() + find('Show this image?') + screenshot('consent') + tap('Cancel') + wait_closed() + + adb('shell', 'appops', 'set', '--uid', PACKAGE, 'SYSTEM_ALERT_WINDOW', 'allow') + request() + find('Show this image?') + if active(): + raise RuntimeError('Existing permission bypassed image consent') + tap('Show image') + find('Close image overlay') + adb('shell', 'input', 'keyevent', 'KEYCODE_HOME') + close = find('Close image overlay') + screenshot('above-home') + x, y = center(close) + adb('shell', 'input', 'swipe', str(x - 100), str(y + 80), str(x + 100), str(y + 180), '600') + moved = center(find('Close image overlay')) + if moved == (x, y): + raise RuntimeError('Dragging did not move the native overlay window') + screenshot('dragged') + tap('Close image overlay') + wait_closed() + screenshot('closed') + + request() + tap('Show image') + find('Close image overlay') + request(hide=True, token='cd' * 32) + find('Close image overlay') + request(hide=True) + wait_closed() + + request() + tap('Show image') + find('Close image overlay') + adb('shell', 'appops', 'set', '--uid', PACKAGE, 'SYSTEM_ALERT_WINDOW', 'deny') + wait_closed() + screenshot('permission-revoked') + log = adb('logcat', '-d') + if re.search(r'ANR in com\.zyn\.lightengine\.overlay|(?:Fatal signal|FATAL EXCEPTION)[\s\S]{0,500}com\.zyn\.lightengine\.overlay', log): + raise RuntimeError('Android reported an overlay crash or ANR') + print('ANDROID OVERLAY PASSED: signed install, consent/cancel, display above home, dragging, close, token-scoped hide and permission revocation') + finally: + (args.output / 'logcat.txt').write_text(adb('logcat', '-d', check=False)) + (args.output / 'window.txt').write_text(adb('shell', 'dumpsys', 'window', 'windows', check=False)) + adb('shell', 'am', 'force-stop', PACKAGE, check=False) + adb('uninstall', PACKAGE, check=False) + + +if __name__ == '__main__': + main() From 5fe72256fc9acb14dab4d0c0afc8b61493931b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 17:09:32 -0500 Subject: [PATCH 5/8] Match Android dialog button labels across platform text styles --- tools/check_android_overlay.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/check_android_overlay.py b/tools/check_android_overlay.py index 3363246..97d4708 100644 --- a/tools/check_android_overlay.py +++ b/tools/check_android_overlay.py @@ -55,7 +55,8 @@ def find(text, timeout=30): deadline = time.monotonic() + timeout while time.monotonic() < deadline: for node in nodes(): - if text in (node.get('text'), node.get('content-desc')): + if any(value.casefold() == text.casefold() for value in + (node.get('text', ''), node.get('content-desc', ''))): return node time.sleep(0.5) raise RuntimeError('Android UI did not expose: ' + text) From c5bbd22ee424237c032f75b24ed67cd33fa86904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 17:11:20 -0500 Subject: [PATCH 6/8] Keep test windows visible and verify Android shared-storage folders --- tools/check_android_apk.py | 5 +++++ tools/check_desktop_binary.py | 9 +++++++++ 2 files changed, 14 insertions(+) diff --git a/tools/check_android_apk.py b/tools/check_android_apk.py index b0c1ea3..74326e0 100644 --- a/tools/check_android_apk.py +++ b/tools/check_android_apk.py @@ -31,6 +31,8 @@ def main(): adb('wait-for-device') adb('install', '-r', '--abi', args.abi, str(args.apk), timeout=300) adb('shell', 'pm', 'clear', PACKAGE) + adb('shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0') + adb('shell', 'settings', 'put', 'system', 'user_rotation', '1') adb('logcat', '-c') diagnostic = None @@ -77,6 +79,9 @@ def wait_state(state, timeout=150, previous_log=None): time.sleep(1) adb('shell', 'am', 'start', '-W', '-a', 'android.intent.action.MAIN', '-n', LAUNCHER) wait_state('TitleState') + root = '/sdcard/Android/media/' + PACKAGE + for name in ('.nomedia', 'mods', 'addons', 'saves'): + adb('shell', 'test', '-e', root + '/' + name) key('KEYCODE_ENTER') time.sleep(1) key('KEYCODE_ENTER') diff --git a/tools/check_desktop_binary.py b/tools/check_desktop_binary.py index fa3bc22..29a28a4 100644 --- a/tools/check_desktop_binary.py +++ b/tools/check_desktop_binary.py @@ -2,6 +2,7 @@ """Exercise a packaged desktop game through native keyboard and window events.""" import argparse import ctypes +from ctypes import wintypes import os from pathlib import Path import subprocess @@ -34,6 +35,14 @@ def visit(window, _): if not found: raise RuntimeError('Native game window was not created') self.window = found[0] + bounds = wintypes.RECT() + user.GetWindowRect.argtypes = [ctypes.c_void_p, ctypes.POINTER(wintypes.RECT)] + user.MoveWindow.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int] + user.GetWindowRect(self.window, ctypes.byref(bounds)) + width, height = user.GetSystemMetrics(0), user.GetSystemMetrics(1) + if bounds.left < 0 or bounds.top < 0 or bounds.right > width or bounds.bottom > height: + user.MoveWindow(self.window, 20, 20, min(bounds.right - bounds.left, width - 40), + min(bounds.bottom - bounds.top, height - 80), True) user.SetForegroundWindow(self.window) else: result = subprocess.check_output(['xdotool', 'search', '--onlyvisible', '--name', '^Light Engine'], text=True) From 781427f99616cfb7ca3d7a24d19b5f126b6a2cef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 17:22:00 -0500 Subject: [PATCH 7/8] Protect published assets and inspect native nonfocusable overlay windows --- .github/workflows/release.yml | 17 +++++++++++ tests/test_release_state.py | 33 ++++++++++++++++++++ tools/check_android_overlay.py | 56 ++++++++++++++++++++++------------ tools/release_state.py | 45 +++++++++++++++++++++++++++ 4 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 tests/test_release_state.py create mode 100644 tools/release_state.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 770887a..5ff4218 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,6 +50,7 @@ jobs: tag: ${{ steps.meta.outputs.tag }} release_name: ${{ steps.meta.outputs.release_name }} android_version_code: ${{ steps.meta.outputs.android_version_code }} + build_release: ${{ steps.published.outputs.build_release }} steps: - name: Checkout uses: actions/checkout@v4 @@ -92,7 +93,16 @@ jobs: print(f"Release {version}; Android versionCode {code}") PYCODE + - name: Check whether this version is already published + id: published + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.meta.outputs.tag }} + run: | + python3 tools/release_state.py --repository "$GITHUB_REPOSITORY" --tag "$TAG" + - name: Build release notes from CHANGELOG.md + if: steps.published.outputs.build_release == 'true' run: | set -euo pipefail VERSION="${{ steps.meta.outputs.version }}" @@ -122,6 +132,7 @@ jobs: cat notes.md - name: Generate icons from art/logo.png + if: steps.published.outputs.build_release == 'true' run: | set -euo pipefail if command -v magick >/dev/null 2>&1; then @@ -147,9 +158,11 @@ jobs: find inputs -type f | sort - name: Package the optional addon separately + if: steps.published.outputs.build_release == 'true' run: python3 tools/make_addon_release.py Light-Engine-screen-overlay-addon.zip - name: Open draft release + if: steps.published.outputs.build_release == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ steps.meta.outputs.tag }} @@ -167,6 +180,7 @@ jobs: gh release upload "$TAG" Light-Engine-screen-overlay-addon.zip --clobber - name: Upload build inputs + if: steps.published.outputs.build_release == 'true' uses: actions/upload-artifact@v4 with: name: build-inputs @@ -177,6 +191,7 @@ jobs: build-windows: name: Windows ${{ matrix.arch }} needs: prepare + if: needs.prepare.outputs.build_release == 'true' runs-on: windows-latest strategy: fail-fast: false @@ -317,6 +332,7 @@ jobs: build-linux: name: Linux ${{ matrix.kind }} needs: prepare + if: needs.prepare.outputs.build_release == 'true' runs-on: ubuntu-latest strategy: fail-fast: false @@ -455,6 +471,7 @@ jobs: build-android: name: Android needs: prepare + if: needs.prepare.outputs.build_release == 'true' runs-on: ubuntu-latest steps: - name: Checkout diff --git a/tests/test_release_state.py b/tests/test_release_state.py new file mode 100644 index 0000000..1fc6e02 --- /dev/null +++ b/tests/test_release_state.py @@ -0,0 +1,33 @@ +"""Published packages must survive failed or ambiguous GitHub lookups.""" +import importlib.util +from pathlib import Path +import unittest + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location('release_state', ROOT / 'tools/release_state.py') +release_state = importlib.util.module_from_spec(spec) +spec.loader.exec_module(release_state) + + +class ReleaseStateTests(unittest.TestCase): + def test_published_release_cannot_be_rebuilt(self): + self.assertFalse(release_state.should_build(0, 'HTTP/2.0 200 OK\n\n{"draft":false}')) + + def test_draft_can_resume_and_confirmed_absence_can_build(self): + self.assertTrue(release_state.should_build(0, 'HTTP/2.0 200 OK\r\n\r\n{"draft":true}')) + self.assertTrue(release_state.should_build(1, 'HTTP/2.0 404 Not Found\n\n{"message":"Not Found"}')) + + def test_api_and_network_errors_cannot_overwrite_a_release(self): + for response in ('', 'HTTP/2.0 401 Unauthorized\n\n{}', + 'HTTP/2.0 403 Forbidden\n\n{}', 'HTTP/2.0 503 Service Unavailable\n\n{}'): + with self.subTest(response=response), self.assertRaises(RuntimeError): + release_state.should_build(1, response) + + def test_incomplete_metadata_is_not_permission_to_build(self): + for body in ('{}', '{"draft":null}', '{"draft":"false"}', 'proxy error'): + with self.subTest(body=body), self.assertRaises(RuntimeError): + release_state.should_build(0, 'HTTP/2.0 200 OK\n\n' + body) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/check_android_overlay.py b/tools/check_android_overlay.py index 97d4708..8a00829 100644 --- a/tools/check_android_overlay.py +++ b/tools/check_android_overlay.py @@ -44,12 +44,11 @@ def screenshot(name): (args.output / (name + '.png')).write_bytes(adb('exec-out', 'screencap', '-p', binary=True)) def nodes(): - adb('shell', 'uiautomator', 'dump', '/sdcard/overlay-window.xml', check=False) - text = adb('shell', 'cat', '/sdcard/overlay-window.xml', check=False) - try: - return list(ET.fromstring(text).iter('node')) - except ET.ParseError: - return [] + adb('shell', 'rm', '-f', '/sdcard/overlay-window.xml') + adb('shell', 'uiautomator', 'dump', '/sdcard/overlay-window.xml') + adb('shell', 'test', '-s', '/sdcard/overlay-window.xml') + text = adb('shell', 'cat', '/sdcard/overlay-window.xml') + return list(ET.fromstring(text).iter('node')) def find(text, timeout=30): deadline = time.monotonic() + timeout @@ -77,14 +76,31 @@ def request(hide=False, token=TOKEN): uri = 'lightengine-overlay://' + ('hide' if hide else 'show') + '?' + urlencode(fields) adb('shell', 'am start -W -a android.intent.action.VIEW -n ' + ACTIVITY + ' -d ' + shlex.quote(uri)) - def active(): - return any(n.get('content-desc') == 'Close image overlay' for n in nodes()) + def overlay_bounds(): + windows = adb('shell', 'dumpsys', 'window', 'windows') + for block in re.split(r'\n\s*Window #\d+', windows): + if ('package=' + PACKAGE) not in block or 'ty=APPLICATION_OVERLAY' not in block: + continue + if 'mHasSurface=true' not in block or 'isVisible=true' not in block or 'mDrawState=HAS_DRAWN' not in block: + continue + frame = re.search(r'\bframe=\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]', block) + if frame: + return tuple(map(int, frame.groups())) + + def wait_visible(): + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + bounds = overlay_bounds() + if bounds: + return bounds + time.sleep(0.5) + raise RuntimeError('Android did not draw the native overlay window') def wait_closed(): deadline = time.monotonic() + 20 while time.monotonic() < deadline: services = adb('shell', 'dumpsys', 'activity', 'services', PACKAGE) - if not active() and 'ServiceRecord{' not in services: + if not overlay_bounds() and 'ServiceRecord{' not in services: return time.sleep(0.5) raise RuntimeError('Android overlay or foreground service did not stop') @@ -104,34 +120,34 @@ def wait_closed(): adb('shell', 'appops', 'set', '--uid', PACKAGE, 'SYSTEM_ALERT_WINDOW', 'allow') request() find('Show this image?') - if active(): + if overlay_bounds(): raise RuntimeError('Existing permission bypassed image consent') tap('Show image') - find('Close image overlay') + wait_visible() adb('shell', 'input', 'keyevent', 'KEYCODE_HOME') - close = find('Close image overlay') + bounds = wait_visible() screenshot('above-home') - x, y = center(close) - adb('shell', 'input', 'swipe', str(x - 100), str(y + 80), str(x + 100), str(y + 180), '600') - moved = center(find('Close image overlay')) - if moved == (x, y): + x, y = bounds[0] + 30, (bounds[1] + bounds[3]) // 2 + adb('shell', 'input', 'swipe', str(x), str(y), str(x + 200), str(y + 100), '600') + moved = wait_visible() + if moved[:2] == bounds[:2]: raise RuntimeError('Dragging did not move the native overlay window') screenshot('dragged') - tap('Close image overlay') + adb('shell', 'input', 'tap', str(moved[2] - 16), str(moved[1] + 16)) wait_closed() screenshot('closed') request() tap('Show image') - find('Close image overlay') + wait_visible() request(hide=True, token='cd' * 32) - find('Close image overlay') + wait_visible() request(hide=True) wait_closed() request() tap('Show image') - find('Close image overlay') + wait_visible() adb('shell', 'appops', 'set', '--uid', PACKAGE, 'SYSTEM_ALERT_WINDOW', 'deny') wait_closed() screenshot('permission-revoked') diff --git a/tools/release_state.py b/tools/release_state.py new file mode 100644 index 0000000..6f85b59 --- /dev/null +++ b/tools/release_state.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Decide whether a release can be built without replacing published packages.""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess + + +def should_build(returncode, response): + headers, separator, body = response.replace('\r\n', '\n').partition('\n\n') + status = re.match(r'HTTP/\S+\s+(\d{3})\b', headers) + if not status: + raise RuntimeError('GitHub did not return an HTTP status; release state is unknown') + code = int(status.group(1)) + if code == 404: + return True + if returncode or code != 200: + raise RuntimeError('Cannot verify release state: HTTP ' + str(code)) + try: + release = json.loads(body) if separator else None + except json.JSONDecodeError as error: + raise RuntimeError('GitHub returned invalid release metadata') from error + if not isinstance(release, dict) or type(release.get('draft')) is not bool: + raise RuntimeError('GitHub did not identify whether the release is a draft') + return release['draft'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--repository', required=True) + parser.add_argument('--tag', required=True) + args = parser.parse_args() + result = subprocess.run(['gh', 'api', '--include', + f'repos/{args.repository}/releases/tags/{args.tag}'], + capture_output=True, text=True, timeout=45) + build = should_build(result.returncode, result.stdout) + with Path(os.environ['GITHUB_OUTPUT']).open('a', encoding='utf-8') as output: + output.write('build_release=' + str(build).lower() + '\n') + print('Release can be built.' if build else 'Release is already published; change project.lua version to publish new binaries.') + + +if __name__ == '__main__': + main() From 968af59c158e1cd940915b02ebc0aa34bb71f635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=8A=B1=3A=20=CC=97=CC=80S=CF=83=E1=83=A7M=CE=B1?= =?UTF-8?q?=E1=83=A7=C6=88=CF=83=CA=9F=E0=BF=90?= Date: Sun, 4 Oct 2026 17:35:00 -0500 Subject: [PATCH 8/8] Capture the native Android consent dialog and failed input coordinates --- tools/check_android_overlay.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tools/check_android_overlay.py b/tools/check_android_overlay.py index 8a00829..cd60e49 100644 --- a/tools/check_android_overlay.py +++ b/tools/check_android_overlay.py @@ -48,6 +48,7 @@ def nodes(): adb('shell', 'uiautomator', 'dump', '/sdcard/overlay-window.xml') adb('shell', 'test', '-s', '/sdcard/overlay-window.xml') text = adb('shell', 'cat', '/sdcard/overlay-window.xml') + (args.output / 'ui-last.xml').write_text(text) return list(ET.fromstring(text).iter('node')) def find(text, timeout=30): @@ -66,6 +67,8 @@ def center(node): def tap(text): x, y = center(find(text)) + print('Tap ' + text + ': ' + str((x, y)), flush=True) + screenshot('before-' + text.lower().replace(' ', '-')) adb('shell', 'input', 'tap', str(x), str(y)) def request(hide=False, token=TOKEN): @@ -156,6 +159,7 @@ def wait_closed(): raise RuntimeError('Android reported an overlay crash or ANR') print('ANDROID OVERLAY PASSED: signed install, consent/cancel, display above home, dragging, close, token-scoped hide and permission revocation') finally: + screenshot('final') (args.output / 'logcat.txt').write_text(adb('logcat', '-d', check=False)) (args.output / 'window.txt').write_text(adb('shell', 'dumpsys', 'window', 'windows', check=False)) adb('shell', 'am', 'force-stop', PACKAGE, check=False)