#!/usr/bin/env python3 """Exercise the packaged viewer through real XDG/GTK file chooser portals. Only private Xvfb/Openbox or headless Sway desktops and generated files are used. No mocked portal replies, Qt test hooks, or user desktop are involved. """ import argparse import hashlib import json import os from pathlib import Path import re import shutil import signal import subprocess import sys import tempfile import time import uuid ROOT = Path(__file__).resolve().parents[2] def sha(path): with Path(path).open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def wait_for(probe, description, seconds=15): deadline = time.monotonic() + seconds while time.monotonic() < deadline: value = probe() if value: return value time.sleep(.1) raise RuntimeError('Timed out: ' + description) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--platform', choices=['xcb', 'wayland'], required=True) parser.add_argument('--output', type=Path, required=True) parser.add_argument('--private-session', action='store_true', help=argparse.SUPPRESS) args = parser.parse_args() out = args.output.resolve() if not args.private_session: if out.exists(): parser.error('Choose a new evidence directory') with tempfile.TemporaryDirectory(prefix='docview-portal-') as temporary: private = Path(temporary) env = {key: value for key, value in os.environ.items() if key in ['HOME', 'PATH', 'USER', 'LOGNAME', 'LANG']} env.update(XDG_RUNTIME_DIR=str(private), XDG_CONFIG_HOME=str(private / 'config'), XDG_STATE_HOME=str(private / 'state'), XDG_CACHE_HOME=str(private / 'cache'), XDG_DATA_HOME=str(private / 'data'), XDG_CURRENT_DESKTOP='DocViewTest', QT_QPA_PLATFORM=args.platform, QT_QPA_PLATFORMTHEME='xdgdesktopportal', QT_USE_PORTAL='1', QT_QUICK_BACKEND='software', QTWEBENGINE_CHROMIUM_FLAGS='--disable-gpu', QT_SCALE_FACTOR='1', QT_FONT_DPI='96', LANG='C.UTF-8', NO_AT_BRIDGE='1') config = private / 'config/xdg-desktop-portal'; config.mkdir(parents=True) (config / 'portals.conf').write_text('[preferred]\ndefault=gtk;\norg.freedesktop.impl.portal.FileChooser=gtk;\n') bus = private / 'bus.conf' bus.write_text('sessionEXTERNAL' f'unix:tmpdir={private}' '' '') display = ['xvfb-run', '-a', '-s', '-screen 0 1280x960x24 -dpi 96 -nolisten tcp'] if args.platform == 'xcb' else [] return subprocess.run(['dbus-run-session', '--config-file', str(bus), '--', *display, sys.executable, str(Path(__file__).resolve()), *sys.argv[1:], '--private-session'], env=env).returncode out.mkdir(parents=True, exist_ok=False) (out / 'runner-at-start.py').write_bytes(Path(__file__).read_bytes()) env = dict(os.environ, DOCVIEW_PORTAL_CHILD_ID=str(uuid.uuid4())) tag = ('DOCVIEW_PORTAL_CHILD_ID=' + env['DOCVIEW_PORTAL_CHILD_ID']).encode() processes, streams, commands, cases = [], [], [], [] identities = {name: sha(Path('/opt/docview/bin') / name) for name in ['docview', 'docview-pdf-worker', 'docview-archive-worker']} report = {'success': False, 'platform': args.platform, 'executables': identities, 'launcherSha256': sha('/usr/bin/docview'), 'runnerSha256': sha(Path(__file__)), 'packageManifestSha256': sha('/opt/docview/share/doc/docview/package-manifest.json'), 'scope': 'Actual packaged app and Ubuntu XDG/GTK file chooser on a private virtual desktop; no physical input or full desktop-session certification', 'osRelease': Path('/etc/os-release').read_text(), 'cases': cases} def run(command, checked=True, timeout=8): result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=timeout) commands.append({'command': command, 'exitCode': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr}) if checked and result.returncode: raise RuntimeError(str(command) + ': ' + result.stderr) return result def start(name, command): stream = (out / (name + '.log')).open('w'); streams.append(stream) process = subprocess.Popen(command, env=env, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True) processes.append(process) return process def wayland_keyboard(): # wtype constructs its map from all arguments before sending input. # Keep the same keysym-to-keycode mapping across the keeper and every # invocation, including GTK's startup map. Release-only entries declare # keys without typing them; actual input follows this prefix. command = ['wtype'] for symbol in ['Escape', 'Return', 'Shift_L', 'Tab'] + [ 'U%04X' % ord(char) for char in ''.join(chr(n) for n in range(32, 127)) + '日本語選択資料']: command += ['-p', symbol] return command + ['-s', '150'] def keys(*values): if args.platform == 'xcb': run(['xdotool', 'key', '--clearmodifiers', '--delay', '90', *values]) else: command = wayland_keyboard() for value in values: parts = value.split('+'); modifiers = {'ctrl':'ctrl', 'shift':'shift', 'alt':'alt'} for part in parts[:-1]: command += ['-M', modifiers[part.lower()]] command += ['-s', '40', '-P', parts[-1], '-s', '40', '-p', parts[-1]] for part in reversed(parts[:-1]): command += ['-m', modifiers[part.lower()]] command += ['-s', '90'] run(command) def text(value): if args.platform == 'xcb': run(['xdotool', 'type', '--clearmodifiers', '--delay', '12', value]) else: run(wayland_keyboard() + ['-d', '12', value]) time.sleep(.3) # Wait for the other process to consume the final key. def windows(): if args.platform == 'xcb': found = run(['xdotool', 'search', '--onlyvisible', '--name', '.'], checked=False) result = [] for identifier in found.stdout.split(): owner = run(['xdotool', 'getwindowpid', identifier], checked=False) if owner.returncode: continue title = run(['xdotool', 'getwindowname', identifier]).stdout.strip() result.append({'id': identifier, 'pid': int(owner.stdout), 'name': title}) return result tree = json.loads(run(['swaymsg', '-s', env['SWAYSOCK'], '-t', 'get_tree']).stdout) result = [] def visit(node): if node.get('pid'): result.append({key: node.get(key) for key in ['id', 'pid', 'name', 'app_id', 'focused']}) for child in node.get('nodes', []) + node.get('floating_nodes', []): visit(child) visit(tree) return result def focused(pid): if args.platform == 'xcb': value = run(['xdotool', 'getwindowfocus', 'getwindowpid'], checked=False) return value.returncode == 0 and value.stdout.strip() == str(pid) return any(row['pid'] == pid and row.get('focused') for row in windows()) def capture(name): command = ['scrot', str(out / (name + '.png'))] if args.platform == 'xcb' else ['grim', '-o', 'HEADLESS-1', str(out / (name + '.png'))] run(command) state_file = Path(env['XDG_STATE_HOME']) / 'docview/state.json' def state_entry(path): try: value = json.loads(state_file.read_text()) return next((row for row in value['documents'] if row['identity']['canonicalPath'] == str(path) and row['location']), None) except (OSError, ValueError): return None try: report['packages'] = run(['dpkg-query', '-W', 'docview', 'xdg-desktop-portal', 'xdg-desktop-portal-gtk', 'openbox', 'xdotool', 'sway', 'wtype', 'grim', 'scrot']).stdout if args.platform == 'xcb': env['GDK_BACKEND'] = 'x11' start('openbox', ['openbox', '--sm-disable']) wait_for(lambda: run(['xprop', '-root', '_NET_SUPPORTING_WM_CHECK'], checked=False).stdout.find('window id') >= 0, 'X11 window manager') else: runtime = Path(env['XDG_RUNTIME_DIR']); config = runtime / 'sway.conf' config.write_text('xwayland disable\nswaybg_command -\nswaynag_command -\noutput HEADLESS-1 mode 1280x960\n' 'for_window [app_id=".*"] floating enable\ndefault_border none\ndefault_floating_border none\n' 'focus_follows_mouse no\nmouse_warping none\n') env.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman', WLR_HEADLESS_OUTPUTS='1', GDK_BACKEND='wayland', XDG_SESSION_TYPE='wayland') start('sway', ['sway', '-c', str(config)]) wait_for(lambda: list(runtime.glob('sway-ipc.*.sock')) and any(p.is_socket() for p in runtime.glob('wayland-*')), 'Wayland compositor') env['WAYLAND_DISPLAY'] = next(p.name for p in runtime.glob('wayland-*') if p.is_socket()) env['SWAYSOCK'] = str(next(runtime.glob('sway-ipc.*.sock'))) start('keyboard', wayland_keyboard() + ['-s', '300000', '-k', 'Shift_L']) wait_for(lambda: any(row.get('type') == 'keyboard' for row in json.loads(run(['swaymsg', '-t', 'get_inputs']).stdout)), 'persistent virtual keyboard') start('dconf', ['/usr/libexec/dconf-service']) start('permission-store', ['/usr/libexec/xdg-permission-store']) start('documents', ['/usr/libexec/xdg-document-portal']) backend = start('gtk-portal', ['/usr/libexec/xdg-desktop-portal-gtk']) start('desktop-portal', ['/usr/libexec/xdg-desktop-portal', '--verbose']) query = ['gdbus', 'call', '--session', '--dest', 'org.freedesktop.portal.Desktop', '--object-path', '/org/freedesktop/portal/desktop', '--method', 'org.freedesktop.DBus.Properties.Get', 'org.freedesktop.portal.FileChooser', 'version'] wait_for(lambda: run(query, checked=False, timeout=3).returncode == 0, 'real file chooser portal', 30) report['portalVersion'] = run(query).stdout.strip() monitor = start('portal-messages', ['stdbuf', '-oL', '-eL', 'dbus-monitor', '--session', "interface='org.freedesktop.portal.FileChooser'", "interface='org.freedesktop.portal.Request'"]) documents = out / '選択 資料'; documents.mkdir() shutil.copytree(ROOT / 'tests/fixtures/web/html', documents / 'web') selections = [] for kind, source, destination in [ ('pdf', ROOT / 'tests/fixtures/pdf/extended/japanese-layout.pdf', documents / '日本語 j k 123 #%.pdf'), ('html', documents / 'web/index.html', documents / 'web/日本語 j k 123 #%.html'), ('htmlzip', ROOT / 'tests/fixtures/web/html-book.zip', documents / '日本語 j k 123 #%.zip'), ('epub', ROOT / 'tests/fixtures/web/reflow-rtl.epub', documents / '日本語 j k 123 #%.epub')]: shutil.copyfile(source, destination) selections.append((kind, destination, sha(destination))) report['documents'] = [{'format': kind, 'path': str(path), 'sha256': digest} for kind, path, digest in selections] config = Path(env['XDG_CONFIG_HOME']) / 'docview/config.toml'; config.parent.mkdir(parents=True) config.write_text('schema_version=1\n[privacy]\nremember_position=true\nrecent_documents=10\n') app = start('docview', ['/usr/bin/docview', '--config', str(config)]) wait_for(lambda: any(row['pid'] == app.pid for row in windows()) and focused(app.pid), 'initial viewer focus') time.sleep(.5) # Qt queries the portal version asynchronously at startup. # Exercise cancellation through the real GTK window before any document opens. keys('ctrl+o') popup = wait_for(lambda: next((row for row in windows() if row['pid'] == backend.pid), None), 'GTK cancel dialog') wait_for(lambda: focused(backend.pid), 'GTK dialog focus') keys('ctrl+l'); text('j k 123 ?'); capture('cancel-dialog') escape_count = 0 # GTK can consume the first Escape to dismiss its completion popup. for _ in range(3): keys('Escape'); escape_count += 1; time.sleep(.3) if not any(row['pid'] == backend.pid for row in windows()): break wait_for(lambda: not any(row['pid'] == backend.pid for row in windows()) and focused(app.pid), 'focus restored after cancel') assert not state_file.exists() or not json.loads(state_file.read_text())['documents'] cases.append({'case': 'cancel', 'backendWindow': popup, 'focusRestored': True, 'documentOpened': False, 'escapeKeyCount': escape_count}) for kind, path, digest in selections: keys('ctrl+o') popup = wait_for(lambda: next((row for row in windows() if row['pid'] == backend.pid), None), 'GTK ' + kind + ' dialog') wait_for(lambda: focused(backend.pid), 'GTK file entry focus') keys('ctrl+l'); text(str(path)); capture(kind + '-dialog') keys('Return') wait_for(lambda: not any(row['pid'] == backend.pid for row in windows()) and focused(app.pid), 'viewer focus after ' + kind) entry = wait_for(lambda: state_entry(path), 'presented and persisted ' + kind, 20) title = wait_for(lambda: next((row['name'] for row in windows() if row['pid'] == app.pid and path.name in row['name']), None), kind + ' window title') if kind == 'pdf': assert entry['location']['kind'] == 'pdf' text('G') entry = wait_for(lambda: (value if (value := state_entry(path)) and value['location'].get('pageIndex') == 1 else None), 'PDF keyboard navigation after dialog', 15) else: assert entry['location'].get('resourcePath') if kind == 'epub': assert entry['location'].get('spineIdref') capture(kind + '-opened') assert sha(path) == digest cases.append({'case': kind, 'backendWindow': popup, 'windowTitle': title, 'savedPresentedLocation': entry['location'], 'focusRestored': True, 'originalUnchanged': True}) text(':q'); keys('Return'); app.wait(timeout=10) assert app.returncode == 0 time.sleep(.2) messages = (out / 'portal-messages.log').read_text() responses = re.findall(r'interface=org.freedesktop.portal.Request; member=Response\n\s+uint32 (\d+)', messages) assert len(re.findall(r'interface=org.freedesktop.portal.FileChooser; member=OpenFile', messages)) == 5 # GTK 1.15.1 maps Escape's GTK_RESPONSE_DELETE_EVENT to portal code 2; # code 1 is reserved for its explicit Cancel button. Both reject in Qt. # See upstream src/filechooser.c:file_chooser_response. assert responses == ['2', '0', '0', '0', '0'], responses for _, path, _ in selections: assert path.as_uri() in messages, str(path) report['portalResponses'] = responses report['actualGtkBackendPid'] = backend.pid report['applicationExitCode'] = app.returncode report['success'] = True except Exception as error: report['error'] = str(error) if 'app' in locals() and app.poll() is None: try: capture('failure') except Exception as capture_error: report['failureCaptureError'] = str(capture_error) finally: report['commands'] = commands for process in reversed(processes): if process.poll() is None: try: os.killpg(process.pid, signal.SIGTERM) except ProcessLookupError: pass for process in reversed(processes): try: process.wait(timeout=3) except subprocess.TimeoutExpired: try: os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass process.wait(timeout=3) for stream in streams: stream.close() remaining = [] for directory in Path('/proc').glob('[0-9]*'): try: if int(directory.name) != os.getpid() and tag in (directory / 'environ').read_bytes().split(b'\0'): remaining.append(int(directory.name)) except (OSError, ValueError): pass report['remainingTaggedChildren'] = remaining for pid in remaining: try: os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass report['executablesUnchanged'] = all(sha(Path('/opt/docview/bin') / name) == digest for name, digest in identities.items()) report['success'] = report['success'] and report['executablesUnchanged'] and not remaining if state_file.exists(): shutil.copyfile(state_file, out / 'generated-document-state.json') (out / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') print(json.dumps({key: report.get(key) for key in ['success', 'platform', 'error', 'portalResponses']})) return 0 if report['success'] else 1 if __name__ == '__main__': raise SystemExit(main())