From d88eead898ad7e11c039aa505cf430040a49b714 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:52:29 -0400 Subject: [PATCH 01/12] Fix atomic writes and exhausted download cleanup on Windows --- src/riftlift/util.py | 260 ++++++++++++++++++++++--------------------- 1 file changed, 131 insertions(+), 129 deletions(-) diff --git a/src/riftlift/util.py b/src/riftlift/util.py index 90fcb0f..adcd9d6 100644 --- a/src/riftlift/util.py +++ b/src/riftlift/util.py @@ -1,133 +1,135 @@ -from __future__ import annotations - -import hashlib -import os -import shutil -import subprocess -import tempfile -import time -import urllib.error -import urllib.request -from collections.abc import Iterable -from pathlib import Path - -from . import __version__ - - -class RiftLiftError(RuntimeError): - """A concise, user-actionable RiftLift failure.""" - - -def read_limited(stream: object, maximum: int, label: str) -> bytes: - """Read a response-like stream without trusting its declared length.""" - headers = getattr(stream, "headers", {}) - content_length = headers.get("Content-Length") - try: - declared = int(content_length) if content_length is not None else None - except (TypeError, ValueError): - declared = None - limit_mib = maximum // (1024 * 1024) - if declared is not None and declared > maximum: - raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") - payload = stream.read(maximum + 1) # type: ignore[attr-defined] - if len(payload) > maximum: - raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") - return payload - - -def atomic_write_bytes(target: Path, payload: bytes, mode: int = 0o600) -> None: - """Atomically replace *target* using a unique file in the same directory.""" - target.parent.mkdir(parents=True, exist_ok=True) - descriptor, name = tempfile.mkstemp(prefix=f".{target.name}-", dir=target.parent) - temporary = Path(name) - try: - os.fchmod(descriptor, mode) +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +from collections.abc import Iterable +from pathlib import Path + +from . import __version__ + + +class RiftLiftError(RuntimeError): + """A concise, user-actionable RiftLift failure.""" + + +def read_limited(stream: object, maximum: int, label: str) -> bytes: + """Read a response-like stream without trusting its declared length.""" + headers = getattr(stream, "headers", {}) + content_length = headers.get("Content-Length") + try: + declared = int(content_length) if content_length is not None else None + except (TypeError, ValueError): + declared = None + limit_mib = maximum // (1024 * 1024) + if declared is not None and declared > maximum: + raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") + payload = stream.read(maximum + 1) # type: ignore[attr-defined] + if len(payload) > maximum: + raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") + return payload + + +def atomic_write_bytes(target: Path, payload: bytes, mode: int = 0o600) -> None: + """Atomically replace *target* using a unique file in the same directory.""" + target.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix=f".{target.name}-", dir=target.parent) + temporary = Path(name) + try: with os.fdopen(descriptor, "wb") as stream: + if os.name != "nt": + os.fchmod(stream.fileno(), mode) stream.write(payload) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, target) - finally: - temporary.unlink(missing_ok=True) - - -def atomic_write_text(target: Path, value: str, mode: int = 0o600) -> None: - atomic_write_bytes(target, value.encode("utf-8"), mode) - - -def command(name: str) -> str: - value = shutil.which(name) - if not value: - raise RiftLiftError(f"required command is missing: {name}") - return value - - -def installed_command(name: str) -> Path: - """Find a RiftLift entry point installed on PATH or in the XDG bin directory.""" - if value := shutil.which(name): - return Path(value) - configured_bin_home = os.environ.get("XDG_BIN_HOME") - bin_home = Path(configured_bin_home).expanduser() if configured_bin_home else None - if bin_home is None or not bin_home.is_absolute(): - bin_home = Path.home() / ".local/bin" - target = bin_home / name - if target.is_file(): - return target - raise RiftLiftError( - f"RiftLift's {name!r} command was not found on PATH or in {bin_home}" - ) - - -def run( - arguments: Iterable[str | os.PathLike[str]], **kwargs: object -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [os.fspath(value) for value in arguments], check=True, text=True, **kwargs - ) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def download(url: str, target: Path, expected_sha256: str = "") -> Path: - target.parent.mkdir(parents=True, exist_ok=True) - if target.is_file() and (not expected_sha256 or sha256(target) == expected_sha256): - return target - with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream: - temporary = Path(stream.name) - request = urllib.request.Request( - url, headers={"User-Agent": f"RiftLift/{__version__}"} - ) - for attempt in range(4): - stream.seek(0) - stream.truncate() - try: - with urllib.request.urlopen(request, timeout=60) as response: - shutil.copyfileobj(response, stream) - break - except (OSError, urllib.error.URLError, TimeoutError) as error: + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + +def atomic_write_text(target: Path, value: str, mode: int = 0o600) -> None: + atomic_write_bytes(target, value.encode("utf-8"), mode) + + +def command(name: str) -> str: + value = shutil.which(name) + if not value: + raise RiftLiftError(f"required command is missing: {name}") + return value + + +def installed_command(name: str) -> Path: + """Find a RiftLift entry point installed on PATH or in the XDG bin directory.""" + if value := shutil.which(name): + return Path(value) + configured_bin_home = os.environ.get("XDG_BIN_HOME") + bin_home = Path(configured_bin_home).expanduser() if configured_bin_home else None + if bin_home is None or not bin_home.is_absolute(): + bin_home = Path.home() / ".local/bin" + target = bin_home / name + if target.is_file(): + return target + raise RiftLiftError( + f"RiftLift's {name!r} command was not found on PATH or in {bin_home}" + ) + + +def run( + arguments: Iterable[str | os.PathLike[str]], **kwargs: object +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [os.fspath(value) for value in arguments], check=True, text=True, **kwargs + ) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def download(url: str, target: Path, expected_sha256: str = "") -> Path: + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_file() and (not expected_sha256 or sha256(target) == expected_sha256): + return target + with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream: + temporary = Path(stream.name) + request = urllib.request.Request( + url, headers={"User-Agent": f"RiftLift/{__version__}"} + ) + for attempt in range(4): + stream.seek(0) + stream.truncate() + try: + with urllib.request.urlopen(request, timeout=60) as response: + shutil.copyfileobj(response, stream) + break + except (OSError, urllib.error.URLError, TimeoutError) as error: if attempt == 3: + stream.close() temporary.unlink(missing_ok=True) - raise RiftLiftError( - f"could not download {target.name} after 4 attempts: {error}" - ) from error - time.sleep(2**attempt) - actual = sha256(temporary) - if expected_sha256 and actual != expected_sha256: - temporary.unlink(missing_ok=True) - raise RiftLiftError( - f"checksum mismatch for {target.name}: expected {expected_sha256}, got {actual}" - ) - temporary.chmod(0o644) - temporary.replace(target) - return target - - -def linux_to_windows(path: Path) -> str: - absolute = path.expanduser().resolve() - return "Z:" + str(absolute).replace("/", "\\") + raise RiftLiftError( + f"could not download {target.name} after 4 attempts: {error}" + ) from error + time.sleep(2**attempt) + actual = sha256(temporary) + if expected_sha256 and actual != expected_sha256: + temporary.unlink(missing_ok=True) + raise RiftLiftError( + f"checksum mismatch for {target.name}: expected {expected_sha256}, got {actual}" + ) + temporary.chmod(0o644) + temporary.replace(target) + return target + + +def linux_to_windows(path: Path) -> str: + absolute = path.expanduser().resolve() + return "Z:" + str(absolute).replace("/", "\\") From fc42c48589735a32a49016b71d75d32e8423050c Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:54:05 -0400 Subject: [PATCH 02/12] Add experimental native Windows CLI, library GUI, and host tests --- .gitignore | 24 +- src/riftlift/cli.py | 540 ++++++++++++++++++----------------- src/riftlift/config.py | 450 +++++++++++++++-------------- src/riftlift/gui.py | 42 +-- src/riftlift/windows.py | 279 ++++++++++++++++++ src/riftlift/windows_gui.py | 146 ++++++++++ tests/test_windows_native.py | 114 ++++++++ 7 files changed, 1082 insertions(+), 513 deletions(-) create mode 100644 src/riftlift/windows.py create mode 100644 src/riftlift/windows_gui.py create mode 100644 tests/test_windows_native.py diff --git a/.gitignore b/.gitignore index 3c36753..16d907a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,15 @@ -__pycache__/ -*.egg-info/ -.pytest_cache/ -.venv/ -build/ -dist/ -out/ - -# Runtime artifacts written by launched games in the repository working tree. -logs/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.venv/ +build/ +dist/ +out/ + +# Runtime artifacts written by launched games in the repository working tree. +logs/ logConfig.xml + +# Native Windows install and local verification output. +portable/ +*.local.log diff --git a/src/riftlift/cli.py b/src/riftlift/cli.py index 7ef1233..bfc1973 100644 --- a/src/riftlift/cli.py +++ b/src/riftlift/cli.py @@ -1,267 +1,273 @@ -from __future__ import annotations - -import argparse -import sys - -from meta_pcvr_downloader.api import MetaApiError -from meta_pcvr_downloader.auth import AuthenticationError -from meta_pcvr_downloader.download import DownloadError - -from . import __version__ -from .auth import complete_login, login -from .config import Game, Paths, games -from .doctor import doctor -from .launch import launch -from .library import add, add_local -from .metadata import populate_game_metadata -from .playtime import playtime, playtime_label -from .runtime import setup -from .steam import sync_with_restart -from .steam_oculus import steam_oculus_game, steam_oculus_games -from .util import RiftLiftError - - -def parser() -> argparse.ArgumentParser: - root = argparse.ArgumentParser( - prog="riftlift", - description="Run owned Meta Rift games on Linux OpenXR/Monado.", - ) - root.add_argument("--version", action="version", version=f"%(prog)s {__version__}") - commands = root.add_subparsers(dest="command", required=True) - - commands.add_parser("gui", help="open the RiftLift desktop app") - - setup_command = commands.add_parser( - "setup", help="install/update the shared compatibility stack" - ) - setup_command.add_argument( - "--login", action="store_true", help="start browser-backed Meta sign-in" - ) - commands.add_parser( - "login", help="sign in to Meta through an isolated default-browser window" - ) - callback = commands.add_parser("callback", help=argparse.SUPPRESS) - callback.add_argument("url", nargs="?", help=argparse.SUPPRESS) - - add_command = commands.add_parser( - "add", help="download an owned Rift game and add it to Steam" - ) - add_command.add_argument("app", help="Meta Rift store URL or numeric app ID") - add_command.add_argument( - "--build", help="specific version, version code, or binary ID" - ) - add_command.add_argument( - "--executable", help="override the manifest launch executable" - ) - add_command.add_argument( - "--arguments", help="override the manifest launch arguments" - ) - add_command.add_argument( - "--jobs", - type=int, - choices=range(1, 33), - metavar="1-32", - help="download workers (default: adapts to available CPUs)", - ) - add_command.add_argument( - "--no-steam", action="store_true", help="download without updating Steam" - ) - - local_command = commands.add_parser( - "add-local", help="add an existing Windows VR game to RiftLift" - ) - local_command.add_argument("executable", help="path to the game's .exe file") - local_command.add_argument("--name", help="library name (default: executable name)") - local_command.add_argument( - "--root", help="game folder containing the executable (default: its folder)" - ) - local_command.add_argument("--arguments", help="launch arguments") - local_command.add_argument( - "--app-key", help="Oculus application key (advanced; normally unnecessary)" - ) - local_command.add_argument("--artwork", help="cover image file") - local_command.add_argument("--game-version", default="", help="displayed version") - local_command.add_argument( - "--no-steam", action="store_true", help="register without updating Steam" - ) - - launch_command = commands.add_parser("launch", help="launch an installed game") - launch_command.add_argument("slug") - launch_command.add_argument("arguments", nargs=argparse.REMAINDER) - steam_launch = commands.add_parser( - "launch-steam", help="launch an installed Steam Oculus XR game" - ) - steam_launch.add_argument("app_id") - steam_launch.add_argument("steam_command", nargs=argparse.REMAINDER) - commands.add_parser( - "steam-oculus-ids", help="list installed Steam games needing RiftLift" - ) - commands.add_parser("list", help="list installed RiftLift games") - commands.add_parser( - "steam-sync", help="safely synchronize all RiftLift games into Steam" - ) - metadata_command = commands.add_parser( - "metadata", help="fetch artwork and catalog metadata" - ) - metadata_command.add_argument( - "slug", nargs="?", help="one installed game (default: all)" - ) - metadata_command.add_argument( - "--refresh", action="store_true", help="refresh cached catalog data and artwork" - ) - doctor_command = commands.add_parser( - "doctor", help="create a shareable runtime and recent-launch diagnostic report" - ) - doctor_command.add_argument( - "--no-paste", - action="store_true", - help="print locally without creating a public paste", - ) - return root - - -def _run_gui(_paths: Paths, _arguments: argparse.Namespace) -> int: - from .gui import main as gui_main - - return gui_main() - - -def _run_setup(paths: Paths, arguments: argparse.Namespace) -> int: - setup(paths) - print("RiftLift compatibility stack is ready.") - return login(paths) if arguments.login else 0 - - -def _run_login(paths: Paths, _arguments: argparse.Namespace) -> int: - return login(paths) - - -def _run_callback(paths: Paths, arguments: argparse.Namespace) -> int: - return complete_login(paths, arguments.url or "") - - -def _run_add(paths: Paths, arguments: argparse.Namespace) -> int: - game = add( - paths, - arguments.app, - build_selector=arguments.build, - executable=arguments.executable, - arguments=arguments.arguments, - jobs=arguments.jobs, - ) - print(f"Installed {game.name} as {game.slug}.") - if not arguments.no_steam: - print(f"Added to Steam ({sync_with_restart(paths)}).") - return 0 - - -def _run_add_local(paths: Paths, arguments: argparse.Namespace) -> int: - game = add_local( - paths, - arguments.executable, - name=arguments.name, - root=arguments.root, - arguments=arguments.arguments, - app_key=arguments.app_key, - artwork=arguments.artwork, - version=arguments.game_version, - ) - print(f"Added local game {game.name} as {game.slug}.") - if not arguments.no_steam: - print(f"Added to Steam ({sync_with_restart(paths)}).") - return 0 - - -def _run_steam_launch(paths: Paths, arguments: argparse.Namespace) -> int: - from .steam_oculus import game_from_steam_command - - if arguments.steam_command in (["-h"], ["--help"]): - parser().parse_args(["launch-steam", "--help"]) - discovered = steam_oculus_game(arguments.app_id) - return launch( - paths, game_from_steam_command(discovered, arguments.steam_command), [] - ) - - -def _run_launch(paths: Paths, arguments: argparse.Namespace) -> int: - if arguments.arguments in (["-h"], ["--help"]): - parser().parse_args(["launch", "--help"]) - return launch(paths, Game.load(paths, arguments.slug), arguments.arguments) - - -def _run_steam_oculus_ids(_paths: Paths, _arguments: argparse.Namespace) -> int: - for game in steam_oculus_games(): - print(game.app_id) - return 0 - - -def _run_list(paths: Paths, _arguments: argparse.Namespace) -> int: - installed = games(paths) - if not installed: - print( - "No games installed. Use 'riftlift add STORE_URL' or " - "'riftlift add-local GAME.exe'." - ) - for game in installed: - played = playtime_label(playtime(paths, game.slug)) - print(f"{game.slug:<36} {game.name} {game.version} [{played}]") - return 0 - - -def _run_metadata(paths: Paths, arguments: argparse.Namespace) -> int: - installed = [Game.load(paths, arguments.slug)] if arguments.slug else games(paths) - for game in installed: - populate_game_metadata(paths, game, refresh=arguments.refresh) - print(f"Updated metadata for {game.name}.") - return 0 - - -def _run_steam_sync(paths: Paths, _arguments: argparse.Namespace) -> int: - print(sync_with_restart(paths)) - return 0 - - -def _run_doctor(paths: Paths, arguments: argparse.Namespace) -> int: - return doctor(paths, paste=not arguments.no_paste) - - -def run(arguments: argparse.Namespace) -> int: - handlers = { - "gui": _run_gui, - "setup": _run_setup, - "login": _run_login, - "callback": _run_callback, - "add": _run_add, - "add-local": _run_add_local, - "launch": _run_launch, - "launch-steam": _run_steam_launch, - "steam-oculus-ids": _run_steam_oculus_ids, - "list": _run_list, - "steam-sync": _run_steam_sync, - "metadata": _run_metadata, - "doctor": _run_doctor, - } - return handlers[arguments.command](Paths.defaults(), arguments) - - -def main(argv: list[str] | None = None) -> int: - values = list(sys.argv[1:] if argv is None else argv) - try: - return run(parser().parse_args(values)) - except KeyboardInterrupt: - print("\nCancelled.", file=sys.stderr) - return 130 - except ( - AuthenticationError, - DownloadError, - MetaApiError, - RiftLiftError, - ValueError, - OSError, - ) as error: - print(f"error: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) +from __future__ import annotations + +import argparse +import os +import sys + +if os.name != "nt": + from meta_pcvr_downloader.api import MetaApiError + from meta_pcvr_downloader.auth import AuthenticationError + from meta_pcvr_downloader.download import DownloadError + + from . import __version__ + from .auth import complete_login, login + from .config import Game, Paths, games + from .doctor import doctor + from .launch import launch + from .library import add, add_local + from .metadata import populate_game_metadata + from .playtime import playtime, playtime_label + from .runtime import setup + from .steam import sync_with_restart + from .steam_oculus import steam_oculus_game, steam_oculus_games + from .util import RiftLiftError + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser( + prog="riftlift", + description="Run owned Meta Rift games on Linux OpenXR/Monado.", + ) + root.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + commands = root.add_subparsers(dest="command", required=True) + + commands.add_parser("gui", help="open the RiftLift desktop app") + + setup_command = commands.add_parser( + "setup", help="install/update the shared compatibility stack" + ) + setup_command.add_argument( + "--login", action="store_true", help="start browser-backed Meta sign-in" + ) + commands.add_parser( + "login", help="sign in to Meta through an isolated default-browser window" + ) + callback = commands.add_parser("callback", help=argparse.SUPPRESS) + callback.add_argument("url", nargs="?", help=argparse.SUPPRESS) + + add_command = commands.add_parser( + "add", help="download an owned Rift game and add it to Steam" + ) + add_command.add_argument("app", help="Meta Rift store URL or numeric app ID") + add_command.add_argument( + "--build", help="specific version, version code, or binary ID" + ) + add_command.add_argument( + "--executable", help="override the manifest launch executable" + ) + add_command.add_argument( + "--arguments", help="override the manifest launch arguments" + ) + add_command.add_argument( + "--jobs", + type=int, + choices=range(1, 33), + metavar="1-32", + help="download workers (default: adapts to available CPUs)", + ) + add_command.add_argument( + "--no-steam", action="store_true", help="download without updating Steam" + ) + + local_command = commands.add_parser( + "add-local", help="add an existing Windows VR game to RiftLift" + ) + local_command.add_argument("executable", help="path to the game's .exe file") + local_command.add_argument("--name", help="library name (default: executable name)") + local_command.add_argument( + "--root", help="game folder containing the executable (default: its folder)" + ) + local_command.add_argument("--arguments", help="launch arguments") + local_command.add_argument( + "--app-key", help="Oculus application key (advanced; normally unnecessary)" + ) + local_command.add_argument("--artwork", help="cover image file") + local_command.add_argument("--game-version", default="", help="displayed version") + local_command.add_argument( + "--no-steam", action="store_true", help="register without updating Steam" + ) + + launch_command = commands.add_parser("launch", help="launch an installed game") + launch_command.add_argument("slug") + launch_command.add_argument("arguments", nargs=argparse.REMAINDER) + steam_launch = commands.add_parser( + "launch-steam", help="launch an installed Steam Oculus XR game" + ) + steam_launch.add_argument("app_id") + steam_launch.add_argument("steam_command", nargs=argparse.REMAINDER) + commands.add_parser( + "steam-oculus-ids", help="list installed Steam games needing RiftLift" + ) + commands.add_parser("list", help="list installed RiftLift games") + commands.add_parser( + "steam-sync", help="safely synchronize all RiftLift games into Steam" + ) + metadata_command = commands.add_parser( + "metadata", help="fetch artwork and catalog metadata" + ) + metadata_command.add_argument( + "slug", nargs="?", help="one installed game (default: all)" + ) + metadata_command.add_argument( + "--refresh", action="store_true", help="refresh cached catalog data and artwork" + ) + doctor_command = commands.add_parser( + "doctor", help="create a shareable runtime and recent-launch diagnostic report" + ) + doctor_command.add_argument( + "--no-paste", + action="store_true", + help="print locally without creating a public paste", + ) + return root + + +def _run_gui(_paths: Paths, _arguments: argparse.Namespace) -> int: + from .gui import main as gui_main + + return gui_main() + + +def _run_setup(paths: Paths, arguments: argparse.Namespace) -> int: + setup(paths) + print("RiftLift compatibility stack is ready.") + return login(paths) if arguments.login else 0 + + +def _run_login(paths: Paths, _arguments: argparse.Namespace) -> int: + return login(paths) + + +def _run_callback(paths: Paths, arguments: argparse.Namespace) -> int: + return complete_login(paths, arguments.url or "") + + +def _run_add(paths: Paths, arguments: argparse.Namespace) -> int: + game = add( + paths, + arguments.app, + build_selector=arguments.build, + executable=arguments.executable, + arguments=arguments.arguments, + jobs=arguments.jobs, + ) + print(f"Installed {game.name} as {game.slug}.") + if not arguments.no_steam: + print(f"Added to Steam ({sync_with_restart(paths)}).") + return 0 + + +def _run_add_local(paths: Paths, arguments: argparse.Namespace) -> int: + game = add_local( + paths, + arguments.executable, + name=arguments.name, + root=arguments.root, + arguments=arguments.arguments, + app_key=arguments.app_key, + artwork=arguments.artwork, + version=arguments.game_version, + ) + print(f"Added local game {game.name} as {game.slug}.") + if not arguments.no_steam: + print(f"Added to Steam ({sync_with_restart(paths)}).") + return 0 + + +def _run_steam_launch(paths: Paths, arguments: argparse.Namespace) -> int: + from .steam_oculus import game_from_steam_command + + if arguments.steam_command in (["-h"], ["--help"]): + parser().parse_args(["launch-steam", "--help"]) + discovered = steam_oculus_game(arguments.app_id) + return launch( + paths, game_from_steam_command(discovered, arguments.steam_command), [] + ) + + +def _run_launch(paths: Paths, arguments: argparse.Namespace) -> int: + if arguments.arguments in (["-h"], ["--help"]): + parser().parse_args(["launch", "--help"]) + return launch(paths, Game.load(paths, arguments.slug), arguments.arguments) + + +def _run_steam_oculus_ids(_paths: Paths, _arguments: argparse.Namespace) -> int: + for game in steam_oculus_games(): + print(game.app_id) + return 0 + + +def _run_list(paths: Paths, _arguments: argparse.Namespace) -> int: + installed = games(paths) + if not installed: + print( + "No games installed. Use 'riftlift add STORE_URL' or " + "'riftlift add-local GAME.exe'." + ) + for game in installed: + played = playtime_label(playtime(paths, game.slug)) + print(f"{game.slug:<36} {game.name} {game.version} [{played}]") + return 0 + + +def _run_metadata(paths: Paths, arguments: argparse.Namespace) -> int: + installed = [Game.load(paths, arguments.slug)] if arguments.slug else games(paths) + for game in installed: + populate_game_metadata(paths, game, refresh=arguments.refresh) + print(f"Updated metadata for {game.name}.") + return 0 + + +def _run_steam_sync(paths: Paths, _arguments: argparse.Namespace) -> int: + print(sync_with_restart(paths)) + return 0 + + +def _run_doctor(paths: Paths, arguments: argparse.Namespace) -> int: + return doctor(paths, paste=not arguments.no_paste) + + +def run(arguments: argparse.Namespace) -> int: + handlers = { + "gui": _run_gui, + "setup": _run_setup, + "login": _run_login, + "callback": _run_callback, + "add": _run_add, + "add-local": _run_add_local, + "launch": _run_launch, + "launch-steam": _run_steam_launch, + "steam-oculus-ids": _run_steam_oculus_ids, + "list": _run_list, + "steam-sync": _run_steam_sync, + "metadata": _run_metadata, + "doctor": _run_doctor, + } + return handlers[arguments.command](Paths.defaults(), arguments) + + +def main(argv: list[str] | None = None) -> int: + if os.name == "nt": + from .windows import main as windows_main + + return windows_main(argv) + values = list(sys.argv[1:] if argv is None else argv) + try: + return run(parser().parse_args(values)) + except KeyboardInterrupt: + print("\nCancelled.", file=sys.stderr) + return 130 + except ( + AuthenticationError, + DownloadError, + MetaApiError, + RiftLiftError, + ValueError, + OSError, + ) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/config.py b/src/riftlift/config.py index 2eaae17..f920652 100644 --- a/src/riftlift/config.py +++ b/src/riftlift/config.py @@ -1,218 +1,232 @@ -from __future__ import annotations - -import json -import os -import re -from dataclasses import asdict, dataclass, field, fields -from pathlib import Path -from typing import Any - -from .util import atomic_write_text - -_GAME_SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") - - -def _game_record(paths: Paths, slug: str) -> Path: - if _GAME_SLUG.fullmatch(slug) is None: - raise ValueError(f"invalid game slug: {slug!r}") - return paths.data / "games" / f"{slug}.json" - - -def _xdg(name: str, fallback: Path) -> Path: - value = os.environ.get(name) - if not value: - return fallback - path = Path(value).expanduser() - return path if path.is_absolute() else fallback - - -def xdg_data_home() -> Path: - """Return the freedesktop user data directory.""" - return _xdg("XDG_DATA_HOME", Path.home() / ".local/share") - - -def xdg_config_home() -> Path: - """Return the freedesktop user configuration directory.""" - return _xdg("XDG_CONFIG_HOME", Path.home() / ".config") - - -def xdg_cache_home() -> Path: - """Return the freedesktop user cache directory.""" - return _xdg("XDG_CACHE_HOME", Path.home() / ".cache") - - -def xdg_data_dirs() -> tuple[Path, ...]: - """Return absolute freedesktop system data directories in search order.""" - value = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share" - return tuple( - path - for item in value.split(":") - if item and (path := Path(item).expanduser()).is_absolute() - ) - - -@dataclass(slots=True) -class Paths: - data: Path - cache: Path - config: Path - games: Path - prefix: Path - tools: Path - - @classmethod - def defaults(cls) -> Paths: - home = Path.home() - data = xdg_data_home() / "riftlift" - cache = xdg_cache_home() / "riftlift" - config = xdg_config_home() / "riftlift" - games = Path(os.environ.get("RIFTLIFT_GAMES_DIR", home / "Games/RiftLift")) - return cls(data, cache, config, games, data / "compatdata", data / "tools") - - def create(self) -> None: - for path in ( - self.data, - self.cache, - self.config, - self.games, - self.prefix, - self.tools, - ): - path.mkdir(parents=True, exist_ok=True) - (self.data / "games").mkdir(exist_ok=True) - - -@dataclass(slots=True) -class Game: - slug: str - name: str - app_id: str - app_key: str - directory: str - executable: str - arguments: list[str] - version: str = "" - platform_shim: bool = True - platform_offline: bool = False - store_url: str = "" - description: str = "" - developer: str = "" - publisher: str = "" - genres: list[str] = field(default_factory=list) - artwork: dict[str, str] = field(default_factory=dict) - steam_app_id: int = 0 - source: str = "meta" - - def _validate_strings(self) -> None: - for field_name in ( - "slug", - "name", - "app_id", - "app_key", - "directory", - "executable", - "version", - "store_url", - "description", - "developer", - "publisher", - ): - if not isinstance(getattr(self, field_name), str): - raise ValueError(f"game {field_name} must be a string") - - def _validate_collections(self) -> None: - if not isinstance(self.arguments, list) or not all( - isinstance(value, str) for value in self.arguments - ): - raise ValueError("game arguments must be a list of strings") - if not isinstance(self.genres, list) or not all( - isinstance(value, str) for value in self.genres - ): - raise ValueError("game genres must be a list of strings") - if not isinstance(self.artwork, dict) or not all( - isinstance(key, str) and isinstance(value, str) - for key, value in self.artwork.items() - ): - raise ValueError("game artwork must map names to paths") - - def __post_init__(self) -> None: - self._validate_strings() - if _GAME_SLUG.fullmatch(self.slug) is None: - raise ValueError(f"invalid game slug: {self.slug!r}") - directory = Path(self.directory) - executable = Path(self.executable) - if not directory.is_absolute(): - raise ValueError("game directory must be an absolute path") - if executable.is_absolute() or ".." in executable.parts: - raise ValueError("game executable must stay inside its game directory") - if not self.executable or not executable.name: - raise ValueError("game executable cannot be empty") - self._validate_collections() - if self.source not in {"local", "meta", "steam"}: - raise ValueError(f"invalid game source: {self.source!r}") - if not isinstance(self.steam_app_id, int) or self.steam_app_id < 0: - raise ValueError("game Steam app ID must be a nonnegative integer") - - @property - def game_dir(self) -> Path: - return Path(self.directory) - - @property - def executable_path(self) -> Path: - return self.game_dir / self.executable - - def save(self, paths: Paths) -> Path: - paths.create() - target = _game_record(paths, self.slug) - atomic_write_text(target, json.dumps(asdict(self), indent=2) + "\n") - return target - - @classmethod - def load(cls, paths: Paths, slug: str) -> Game: - target = _game_record(paths, slug) - try: - value: Any = json.loads(target.read_text()) - except FileNotFoundError as error: - raise ValueError( - f"unknown game {slug!r}; add it to RiftLift first" - ) from error - except (OSError, json.JSONDecodeError, UnicodeError) as error: - raise ValueError(f"cannot read game record {target}: {error}") from error - if not isinstance(value, dict): - raise ValueError(f"game record is not a JSON object: {target}") - if "source" not in value: - value["source"] = ( - "steam" - if str(value.get("app_key", "")).startswith("steam.app.") - else "meta" - ) - allowed = {field.name for field in fields(cls)} - if unknown := sorted(value.keys() - allowed): - raise ValueError(f"game record contains unknown fields {unknown}: {target}") - try: - return cls(**value) - except (TypeError, ValueError) as error: - raise ValueError(f"invalid game record {target}: {error}") from error - - -def games(paths: Paths) -> list[Game]: - return [ - Game.load(paths, target.stem) - for target in sorted((paths.data / "games").glob("*.json")) - ] - - -def debug_logging_enabled(paths: Paths) -> bool: - return (paths.config / "debug-logging").is_file() - - -def set_debug_logging(paths: Paths, enabled: bool) -> None: - target = paths.config / "debug-logging" - if not enabled: - target.unlink(missing_ok=True) - return - paths.config.mkdir(parents=True, exist_ok=True) - descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, "w") as stream: - stream.write("1\n") - target.chmod(0o600) +from __future__ import annotations + +import json +import os +import re +from dataclasses import asdict, dataclass, field, fields +from pathlib import Path +from typing import Any + +from .util import atomic_write_text + +_GAME_SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") + + +def _game_record(paths: Paths, slug: str) -> Path: + if _GAME_SLUG.fullmatch(slug) is None: + raise ValueError(f"invalid game slug: {slug!r}") + return paths.data / "games" / f"{slug}.json" + + +def _xdg(name: str, fallback: Path) -> Path: + value = os.environ.get(name) + if not value: + return fallback + path = Path(value).expanduser() + return path if path.is_absolute() else fallback + + +def xdg_data_home() -> Path: + """Return the freedesktop user data directory.""" + return _xdg("XDG_DATA_HOME", Path.home() / ".local/share") + + +def xdg_config_home() -> Path: + """Return the freedesktop user configuration directory.""" + return _xdg("XDG_CONFIG_HOME", Path.home() / ".config") + + +def xdg_cache_home() -> Path: + """Return the freedesktop user cache directory.""" + return _xdg("XDG_CACHE_HOME", Path.home() / ".cache") + + +def xdg_data_dirs() -> tuple[Path, ...]: + """Return absolute freedesktop system data directories in search order.""" + value = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share" + return tuple( + path + for item in value.split(":") + if item and (path := Path(item).expanduser()).is_absolute() + ) + + +@dataclass(slots=True) +class Paths: + data: Path + cache: Path + config: Path + games: Path + prefix: Path + tools: Path + + @classmethod + def defaults(cls) -> Paths: + if os.name == "nt": + root = Path( + os.environ.get("RIFTLIFT_HOME") + or str(Path(os.environ["LOCALAPPDATA"]) / "RiftLift") + ) + root = root.expanduser().resolve() + return cls( + root / "data", + root / "cache", + root / "config", + root / "games", + root / "compatdata", + root / "tools", + ) + home = Path.home() + data = xdg_data_home() / "riftlift" + cache = xdg_cache_home() / "riftlift" + config = xdg_config_home() / "riftlift" + games = Path(os.environ.get("RIFTLIFT_GAMES_DIR", home / "Games/RiftLift")) + return cls(data, cache, config, games, data / "compatdata", data / "tools") + + def create(self) -> None: + for path in ( + self.data, + self.cache, + self.config, + self.games, + self.prefix, + self.tools, + ): + path.mkdir(parents=True, exist_ok=True) + (self.data / "games").mkdir(exist_ok=True) + + +@dataclass(slots=True) +class Game: + slug: str + name: str + app_id: str + app_key: str + directory: str + executable: str + arguments: list[str] + version: str = "" + platform_shim: bool = True + platform_offline: bool = False + store_url: str = "" + description: str = "" + developer: str = "" + publisher: str = "" + genres: list[str] = field(default_factory=list) + artwork: dict[str, str] = field(default_factory=dict) + steam_app_id: int = 0 + source: str = "meta" + + def _validate_strings(self) -> None: + for field_name in ( + "slug", + "name", + "app_id", + "app_key", + "directory", + "executable", + "version", + "store_url", + "description", + "developer", + "publisher", + ): + if not isinstance(getattr(self, field_name), str): + raise ValueError(f"game {field_name} must be a string") + + def _validate_collections(self) -> None: + if not isinstance(self.arguments, list) or not all( + isinstance(value, str) for value in self.arguments + ): + raise ValueError("game arguments must be a list of strings") + if not isinstance(self.genres, list) or not all( + isinstance(value, str) for value in self.genres + ): + raise ValueError("game genres must be a list of strings") + if not isinstance(self.artwork, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in self.artwork.items() + ): + raise ValueError("game artwork must map names to paths") + + def __post_init__(self) -> None: + self._validate_strings() + if _GAME_SLUG.fullmatch(self.slug) is None: + raise ValueError(f"invalid game slug: {self.slug!r}") + directory = Path(self.directory) + executable = Path(self.executable) + if not directory.is_absolute(): + raise ValueError("game directory must be an absolute path") + if executable.is_absolute() or ".." in executable.parts: + raise ValueError("game executable must stay inside its game directory") + if not self.executable or not executable.name: + raise ValueError("game executable cannot be empty") + self._validate_collections() + if self.source not in {"local", "meta", "steam"}: + raise ValueError(f"invalid game source: {self.source!r}") + if not isinstance(self.steam_app_id, int) or self.steam_app_id < 0: + raise ValueError("game Steam app ID must be a nonnegative integer") + + @property + def game_dir(self) -> Path: + return Path(self.directory) + + @property + def executable_path(self) -> Path: + return self.game_dir / self.executable + + def save(self, paths: Paths) -> Path: + paths.create() + target = _game_record(paths, self.slug) + atomic_write_text(target, json.dumps(asdict(self), indent=2) + "\n") + return target + + @classmethod + def load(cls, paths: Paths, slug: str) -> Game: + target = _game_record(paths, slug) + try: + value: Any = json.loads(target.read_text()) + except FileNotFoundError as error: + raise ValueError( + f"unknown game {slug!r}; add it to RiftLift first" + ) from error + except (OSError, json.JSONDecodeError, UnicodeError) as error: + raise ValueError(f"cannot read game record {target}: {error}") from error + if not isinstance(value, dict): + raise ValueError(f"game record is not a JSON object: {target}") + if "source" not in value: + value["source"] = ( + "steam" + if str(value.get("app_key", "")).startswith("steam.app.") + else "meta" + ) + allowed = {field.name for field in fields(cls)} + if unknown := sorted(value.keys() - allowed): + raise ValueError(f"game record contains unknown fields {unknown}: {target}") + try: + return cls(**value) + except (TypeError, ValueError) as error: + raise ValueError(f"invalid game record {target}: {error}") from error + + +def games(paths: Paths) -> list[Game]: + return [ + Game.load(paths, target.stem) + for target in sorted((paths.data / "games").glob("*.json")) + ] + + +def debug_logging_enabled(paths: Paths) -> bool: + return (paths.config / "debug-logging").is_file() + + +def set_debug_logging(paths: Paths, enabled: bool) -> None: + target = paths.config / "debug-logging" + if not enabled: + target.unlink(missing_ok=True) + return + paths.config.mkdir(parents=True, exist_ok=True) + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w") as stream: + stream.write("1\n") + target.chmod(0o600) diff --git a/src/riftlift/gui.py b/src/riftlift/gui.py index ed0bc18..b82966e 100644 --- a/src/riftlift/gui.py +++ b/src/riftlift/gui.py @@ -1,18 +1,24 @@ -"""Stable entry point for RiftLift's desktop application.""" - -from __future__ import annotations - - -def main() -> int: - try: - from .main_window import main as window_main - except ModuleNotFoundError as error: - if not error.name or not error.name.startswith("PySide6"): - raise - print(f"RiftLift's GUI needs Qt 6: {error}") - return 1 - return window_main() - - -if __name__ == "__main__": - raise SystemExit(main()) +"""Stable entry point for RiftLift's desktop application.""" + +from __future__ import annotations + + +def main() -> int: + import os + + if os.name == "nt": + from .windows_gui import main as windows_main + + return windows_main() + try: + from .main_window import main as window_main + except ModuleNotFoundError as error: + if not error.name or not error.name.startswith("PySide6"): + raise + print(f"RiftLift's GUI needs Qt 6: {error}") + return 1 + return window_main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/windows.py b/src/riftlift/windows.py new file mode 100644 index 0000000..d8e0b20 --- /dev/null +++ b/src/riftlift/windows.py @@ -0,0 +1,279 @@ +"""Experimental native Windows host; no Wine, Proton, or platform shim. + +The upstream native PE backends are used unchanged. A loaded DLL or successful +injection is not proof of headset rendering or game compatibility. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import subprocess +import sys +import urllib.request +import zipfile +from pathlib import Path, PurePosixPath + +from . import __version__ +from .config import Game, Paths, games +from .detection import is_pe64 +from .util import RiftLiftError + +RELEASE = "v0.10.2.2" +PAYLOAD_SHA256 = "90f9b1b5b26ba85a25ad2dcb3707b7a17540b0d40d310148dd98fa76c3a619eb" +PAYLOAD_URL = f"https://github.com/Villagers654/RiftLift/releases/download/{RELEASE}/riftlift-compat.zip" +FILES = { + "RiftLiftLauncher.exe", + "RiftLiftOpenXR64.dll", + "RiftLiftOpenVR64.dll", + "openvr_api64.dll", + "LICENSE", + "RIFTLIFT-LICENSE", +} + + +def runtime_dir(paths: Paths) -> Path: + return paths.tools / "windows-native" / RELEASE + + +def install_payload(paths: Paths, archive: Path | None = None) -> Path: + if archive: + payload = archive.read_bytes() + else: + with urllib.request.urlopen(PAYLOAD_URL, timeout=60) as response: + payload = response.read(32 * 1024 * 1024 + 1) + if hashlib.sha256(payload).hexdigest() != PAYLOAD_SHA256: + raise RiftLiftError("Native payload SHA256 mismatch") + target = runtime_dir(paths) + # Validate the complete archive before writing; never install the Linux + # platform-entitlement shim into a native Windows game. + with zipfile.ZipFile(io.BytesIO(payload)) as bundle: + names = {i.filename.replace("\\", "/") for i in bundle.infolist()} + if not FILES.issubset(names): + raise RiftLiftError("Native payload is missing required files") + selected = [] + for entry in bundle.infolist(): + name = entry.filename.replace("\\", "/") + relative = PurePosixPath(name) + if relative.is_absolute() or ".." in relative.parts or ":" in name: + raise RiftLiftError("Invalid native payload path") + if name in FILES or (name.startswith("Input/") and name.endswith(".json")): + selected.append((relative, bundle.read(entry))) + for relative, data in selected: + dest = target.joinpath(*relative.parts) + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(data) + return target + + +def active_openxr() -> Path | None: + if override := os.environ.get("XR_RUNTIME_JSON"): + return Path(override) + import winreg + + try: + with winreg.OpenKey( + winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Khronos\OpenXR\1", + 0, + winreg.KEY_READ | winreg.KEY_WOW64_64KEY, + ) as key: + return Path(winreg.QueryValueEx(key, "ActiveRuntime")[0]) + except OSError: + return None + + +def active_openvr() -> Path | None: + config = Path(os.environ["LOCALAPPDATA"]) / "openvr/openvrpaths.vrpath" + try: + roots = json.loads(config.read_text(encoding="utf-8"))["runtime"] + for root in roots: + candidate = Path(root) + if (candidate / "bin/win64/vrclient_x64.dll").is_file(): + return candidate + except (OSError, ValueError, KeyError, TypeError): + pass + return None + + +def runtime_ready(backend: str) -> bool: + target = active_openxr() if backend == "openxr" else active_openvr() + return bool( + target and (target.is_file() if backend == "openxr" else target.is_dir()) + ) + + +def doctor(paths: Paths) -> tuple[str, int]: + native = runtime_dir(paths) + payload_ok = all((native / name).is_file() for name in FILES) + xr, vr = active_openxr(), active_openvr() + installed = games(paths) + text = "\n".join( + [ + f"RiftLift {__version__}: experimental native Windows host", + f"Native payload: {'INSTALLED' if payload_ok else 'MISSING'} ({native})", + f"OpenXR manifest: {xr or 'NOT REGISTERED'}", + f"OpenVR runtime: {vr or 'NOT REGISTERED'}", + f"Registered games: {len(installed)}", + "Game entitlement: handled by each game's original Meta platform runtime", + "Headset rendering/input/audio: NOT VERIFIED", + "Diagnostics remain local; no paste is uploaded.", + ] + ) + return text, 0 if payload_ok and ( + runtime_ready("openxr") or runtime_ready("openvr") + ) else 2 + + +def add_local( + paths: Paths, + executable: str, + name: str | None = None, + root: str | None = None, + arguments: str | None = None, +) -> Game: + from .library import add_local as register + + if not is_pe64(Path(executable)): + raise RiftLiftError("Native Windows launcher requires an x64 PE executable") + game = register(paths, executable, name=name, root=root, arguments=arguments) + game.platform_shim = False + game.platform_offline = False + game.save(paths) + return game + + +def launch_command( + paths: Paths, game: Game, backend: str, extra: list[str] | None = None +) -> list[str]: + if backend not in {"openxr", "openvr"}: + raise RiftLiftError("Unknown native backend") + native = runtime_dir(paths) + required = ( + ["RiftLiftLauncher.exe", "RiftLiftOpenXR64.dll"] + if backend == "openxr" + else ["RiftLiftLauncher.exe", "RiftLiftOpenVR64.dll", "openvr_api64.dll"] + ) + if not all((native / name).is_file() for name in required): + raise RiftLiftError("Run RiftLift setup to install the native payload") + executable = game.executable_path.resolve() + if not executable.is_relative_to(game.game_dir.resolve()) or not is_pe64( + executable + ): + raise RiftLiftError( + "Game executable must be an existing x64 PE inside its game folder" + ) + return [ + str(native / "RiftLiftLauncher.exe"), + f"/{backend}", + "/wait", + "/cwd", + str(game.game_dir), + str(executable), + *game.arguments, + *(extra or []), + ] + + +def launch( + paths: Paths, + game: Game, + backend: str, + dry_run: bool = False, + extra: list[str] | None = None, +) -> int: + command = launch_command(paths, game, backend, extra) + if dry_run: + print(subprocess.list2cmdline(command)) + return 0 + if not runtime_ready(backend): + raise RiftLiftError( + f"Configure a Windows {backend} runtime and connect the headset first" + ) + # Do not write to game files or install the Linux platform shim. + log = paths.data / "logs" / f"{game.slug}.log" + log.parent.mkdir(parents=True, exist_ok=True) + with log.open("a", encoding="utf-8") as stream: + process = subprocess.run( + command, + cwd=game.game_dir, + stdout=stream, + stderr=subprocess.STDOUT, + check=False, + ) + print(f"Launcher exit: {process.returncode}; log: {log}") + return process.returncode + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="riftlift", description="Experimental native Windows RiftLift host" + ) + p.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__} (Windows experimental)", + ) + sub = p.add_subparsers(dest="command", required=True) + sub.add_parser("gui", help="open the Windows library") + setup = sub.add_parser("setup", help="install checksum-verified native x64 runtime") + setup.add_argument("--archive", type=Path) + doc = sub.add_parser("doctor", help="local Windows runtime checks") + doc.add_argument("--no-paste", action="store_true") + sub.add_parser("list") + add = sub.add_parser( + "add-local", help="reference an installed game without changing its files" + ) + add.add_argument("executable") + add.add_argument("--name") + add.add_argument("--root") + add.add_argument("--arguments") + launch_parser = sub.add_parser("launch") + launch_parser.add_argument("slug") + launch_parser.add_argument( + "--backend", choices=["openxr", "openvr"], default="openxr" + ) + launch_parser.add_argument("--dry-run", action="store_true") + return p + + +def main(argv: list[str] | None = None) -> int: + args = parser().parse_args(argv) + paths = Paths.defaults() + try: + if args.command == "gui": + from .windows_gui import main as gui + + return gui() + if args.command == "setup": + print(f"Native payload installed: {install_payload(paths, args.archive)}") + elif args.command == "doctor": + report, status = doctor(paths) + print(report) + return status + elif args.command == "list": + installed = games(paths) + print( + "\n".join(f"{g.slug}: {g.name}" for g in installed) + or "No games registered." + ) + elif args.command == "add-local": + game = add_local( + paths, args.executable, args.name, args.root, args.arguments + ) + print(f"Registered: {game.slug}") + elif args.command == "launch": + return launch( + paths, Game.load(paths, args.slug), args.backend, args.dry_run + ) + return 0 + except (OSError, ValueError, RiftLiftError, zipfile.BadZipFile) as error: + print(f"RiftLift: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/windows_gui.py b/src/riftlift/windows_gui.py new file mode 100644 index 0000000..a274f17 --- /dev/null +++ b/src/riftlift/windows_gui.py @@ -0,0 +1,146 @@ +"""Small native Windows library; runtime execution stays in a child process.""" + +from __future__ import annotations + +import sys + +from PySide6.QtCore import QProcess, Qt +from PySide6.QtWidgets import ( + QApplication, + QComboBox, + QFileDialog, + QHBoxLayout, + QLabel, + QListWidget, + QListWidgetItem, + QMainWindow, + QMessageBox, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from .config import Paths, games +from .windows import add_local, doctor + + +class Window(QMainWindow): + def __init__(self): + super().__init__() + self.paths = Paths.defaults() + self.setWindowTitle("RiftLift — Windows experimental") + self.resize(840, 580) + body = QWidget() + self.setCentralWidget(body) + layout = QVBoxLayout(body) + layout.addWidget(QLabel("RiftLift native Windows host • x64 OpenXR / OpenVR")) + layout.addWidget( + QLabel( + "Install games with Meta's PC app or the game's community installer, then add the .exe." + ) + ) + self.library = QListWidget() + layout.addWidget(self.library) + row = QHBoxLayout() + layout.addLayout(row) + for label, action in [ + ("Add installed game…", self.add_game), + ("Refresh", self.refresh), + ("System", self.system), + ]: + button = QPushButton(label) + button.clicked.connect(action) + row.addWidget(button) + self.backend = QComboBox() + self.backend.addItems(["openxr", "openvr"]) + row.addWidget(self.backend) + self.launch_button = QPushButton("Launch in VR") + self.launch_button.clicked.connect(self.launch_game) + row.addWidget(self.launch_button) + self.log = QTextEdit() + self.log.setReadOnly(True) + layout.addWidget(self.log) + self.child = QProcess(self) + self.child.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) + self.child.readyReadStandardOutput.connect(self.read_output) + self.child.finished.connect(self.finished) + self.child.errorOccurred.connect(self.process_error) + self.refresh() + self.system() + + def refresh(self): + self.library.clear() + try: + for game in games(self.paths): + item = QListWidgetItem(game.name) + item.setData(Qt.ItemDataRole.UserRole, game.slug) + self.library.addItem(item) + except (OSError, ValueError) as error: + QMessageBox.critical(self, "Library", str(error)) + + def system(self): + report, _ = doctor(self.paths) + self.log.setPlainText(report) + + def add_game(self): + filename, _ = QFileDialog.getOpenFileName( + self, "Select installed game", "", "Windows executable (*.exe)" + ) + if filename: + try: + game = add_local(self.paths, filename) + self.log.append( + f"Registered {game.name}; original game files unchanged." + ) + self.refresh() + except (OSError, ValueError, RuntimeError) as error: + QMessageBox.critical(self, "Add game", str(error)) + + def launch_game(self): + item = self.library.currentItem() + if not item or self.child.state() != QProcess.ProcessState.NotRunning: + return + self.launch_button.setEnabled(False) + self.child.start( + sys.executable, + [ + "-m", + "riftlift.cli", + "launch", + item.data(Qt.ItemDataRole.UserRole), + "--backend", + self.backend.currentText(), + ], + ) + + def read_output(self): + self.log.append( + bytes(self.child.readAllStandardOutput()).decode("utf-8", errors="replace") + ) + + def finished(self, code, _status): + self.launch_button.setEnabled(True) + self.log.append( + f"Process finished: {code}. Rendering requires an in-headset test." + ) + + def process_error(self, _error): + self.launch_button.setEnabled(True) + self.log.append(self.child.errorString()) + + def closeEvent(self, event): + if self.child.state() != QProcess.ProcessState.NotRunning: + QMessageBox.information( + self, "Game running", "Close the game before closing RiftLift." + ) + event.ignore() + else: + event.accept() + + +def main() -> int: + app = QApplication.instance() or QApplication(sys.argv) + window = Window() + window.show() + return app.exec() diff --git a/tests/test_windows_native.py b/tests/test_windows_native.py new file mode 100644 index 0000000..6eb2667 --- /dev/null +++ b/tests/test_windows_native.py @@ -0,0 +1,114 @@ +import sys +from pathlib import Path + +import pytest + +from riftlift import windows +from riftlift.config import Game, Paths +from riftlift.util import RiftLiftError, atomic_write_text + +pytestmark = pytest.mark.skipif( + sys.platform != "win32", reason="Native Windows host tests" +) + + +@pytest.fixture +def paths(tmp_path, monkeypatch): + monkeypatch.setenv("RIFTLIFT_HOME", str(tmp_path)) + return Paths.defaults() + + +def test_windows_paths_are_portable(paths, tmp_path): + assert paths.data == tmp_path / "data" + assert paths.config == tmp_path / "config" + + +def test_atomic_write_windows(tmp_path): + target = tmp_path / "atomic.txt" + atomic_write_text(target, "first") + atomic_write_text(target, "second") + assert target.read_text() == "second" + assert list(tmp_path.iterdir()) == [target] + + +def test_cli_help_has_native_backend(capsys): + from riftlift.cli import main + + with pytest.raises(SystemExit) as result: + main(["--help"]) + assert result.value.code == 0 + assert "native Windows" in capsys.readouterr().out + + +def test_add_local_preserves_game_binary(paths): + executable = Path(sys.executable) + before = executable.read_bytes() + game = windows.add_local(paths, str(executable), "Smoke probe") + assert Game.load(paths, game.slug).executable_path == executable + assert not game.platform_shim and not game.platform_offline + assert executable.read_bytes() == before + + +def test_add_local_rejects_non_pe(paths, tmp_path): + fake = tmp_path / "fake.exe" + fake.write_text("not a binary") + with pytest.raises(RiftLiftError, match="x64 PE"): + windows.add_local(paths, str(fake)) + + +def test_payload_checksum_failure_writes_nothing(paths, tmp_path): + fake = tmp_path / "fake.zip" + fake.write_bytes(b"bad") + with pytest.raises(RiftLiftError, match="SHA256"): + windows.install_payload(paths, fake) + assert not windows.runtime_dir(paths).exists() + + +def test_native_launch_builds_argv_without_shell_or_wine(paths): + game = windows.add_local(paths, sys.executable, "Probe", arguments='"two words"') + runtime = windows.runtime_dir(paths) + runtime.mkdir(parents=True) + for name in windows.FILES: + (runtime / name).touch() + argv = windows.launch_command(paths, game, "openxr", ["tail"]) + assert argv[1:4] == ["/openxr", "/wait", "/cwd"] + assert argv[-2:] == ["two words", "tail"] + assert "wine" not in argv and "proton" not in argv + assert "LibOVRPlatformImpl64_1.dll" not in " ".join(argv) + + +def test_missing_runtime_stops_before_launch(paths, monkeypatch): + game = windows.add_local(paths, sys.executable, "Probe") + runtime = windows.runtime_dir(paths) + runtime.mkdir(parents=True) + for name in windows.FILES: + (runtime / name).touch() + monkeypatch.setattr(windows, "runtime_ready", lambda backend: False) + monkeypatch.setattr( + windows.subprocess, "run", lambda *a, **k: pytest.fail("started process") + ) + with pytest.raises(RiftLiftError, match="connect the headset"): + windows.launch(paths, game, "openxr") + + +def test_doctor_missing_runtime_is_not_success(paths, monkeypatch): + monkeypatch.setattr(windows, "active_openxr", lambda: None) + monkeypatch.setattr(windows, "active_openvr", lambda: None) + report, code = windows.doctor(paths) + assert code == 2 + assert "NOT REGISTERED" in report + assert "NOT VERIFIED" in report + + +def test_gui_constructs_without_linux_imports(paths, monkeypatch): + monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") + from PySide6.QtWidgets import QApplication + + from riftlift.windows_gui import Window + + app = QApplication.instance() or QApplication([]) + window = Window() + assert "Windows" in window.windowTitle() + assert window.library.count() == 0 + window.close() + app.processEvents() From b8f78b95496d3e60ecb7b29c33202ac1b7705f99 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:55:02 -0400 Subject: [PATCH 03/12] Add Windows installer, launcher, CI, and explicit game-test status --- .github/workflows/windows-host.yml | 17 +++++++ RiftLift.cmd | 13 +++++ WINDOWS.md | 79 ++++++++++++++++++++++++++++++ install-windows.ps1 | 24 +++++++++ 4 files changed, 133 insertions(+) create mode 100644 .github/workflows/windows-host.yml create mode 100644 RiftLift.cmd create mode 100644 WINDOWS.md create mode 100644 install-windows.ps1 diff --git a/.github/workflows/windows-host.yml b/.github/workflows/windows-host.yml new file mode 100644 index 0000000..c679cea --- /dev/null +++ b/.github/workflows/windows-host.yml @@ -0,0 +1,17 @@ +name: Experimental Windows host +on: + push: + pull_request: +jobs: + windows-host: + runs-on: windows-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: python -m pip install -e . pytest + - run: python -m riftlift.cli --help + - run: python -m pytest tests/test_windows_native.py tests/test_util.py tests/test_library.py -q + env: + QT_QPA_PLATFORM: offscreen diff --git a/RiftLift.cmd b/RiftLift.cmd new file mode 100644 index 0000000..3df3ef4 --- /dev/null +++ b/RiftLift.cmd @@ -0,0 +1,13 @@ +@echo off +setlocal +set "RIFTLIFT_HOME=%~dp0portable" +if not exist "%~dp0.venv\Scripts\python.exe" ( + echo Run install-windows.ps1 first. + exit /b 1 +) +if "%~1"=="" ( + "%~dp0.venv\Scripts\python.exe" -m riftlift.cli gui +) else ( + "%~dp0.venv\Scripts\python.exe" -m riftlift.cli %* +) +exit /b %errorlevel% diff --git a/WINDOWS.md b/WINDOWS.md new file mode 100644 index 0000000..797f01e --- /dev/null +++ b/WINDOWS.md @@ -0,0 +1,79 @@ +# Experimental native Windows host + +This branch adds a native Windows entry point to RiftLift. It is **not yet a +verified Windows game-compatibility release**. The existing Linux host remains +the default on Linux. The native host reuses the upstream x64 PE launcher and +OpenXR/OpenVR backends from v0.10.2.2 without Wine, Proton, DXVK, or xrizer. + +## Install and launch + +Requirements: x64 Windows, Python 3.12, Git, a working native Windows OpenXR or +OpenVR headset runtime, and installed PC game files. + +```powershell +.\install-windows.ps1 +.\RiftLift.cmd +.\RiftLift.cmd doctor --no-paste +.\RiftLift.cmd add-local "C:\Games\Example\Game.exe" --name "Example" +.\RiftLift.cmd launch example --backend openxr --dry-run +.\RiftLift.cmd launch example --backend openxr +``` + +For SteamVR use `--backend openvr`. The selected runtime must already be installed +and configured; this installer does not change the system's active XR runtime. +Run games through their normal client once first to finish prerequisites and +account setup. The GUI lets you add installed executables, select a backend, view +local diagnostics, and launch a game. It keeps launch work off the UI thread. + +## Current boundaries + +- Native Windows sign-in, store downloading, Steam shortcut management, and + playtime tracking are not implemented in this experimental frontend. +- Install purchases with Meta's PC application before adding their executables. + Original platform authentication and entitlement checks remain in place. +- Game folders are referenced in place. No game executable or platform DLL is + replaced. The Linux compatibility platform shim is deliberately not installed. +- Only x64 executables are accepted. OpenXR registration and file checks are + preflight checks, not proof of a working headset session. +- `doctor` never uploads a paste. Exit 2 means the native payload/runtime + preconditions are incomplete; exit 0 still does not certify gameplay. +- Application state is under `portable/` with `RiftLift.cmd`; direct CLI use + defaults to `%LOCALAPPDATA%\RiftLift`. Set `RIFTLIFT_HOME` to override. +- Launch logs are under `portable/data/logs/`; the native launcher also writes + `%LOCALAPPDATA%\RiftLift\RiftLiftLauncher.txt`. + +## Game test matrix + +| Game | Windows result on initial host | +| --- | --- | +| Lone Echo | Pending installed PC game and configured headset | +| Stormland | Pending installed PC game and configured headset | +| Vader Immortal | Pending installed PC game, episode selection, and headset | +| Oculus First Contact | Pending installed PC game and configured headset | +| Echo VR | Pending community installation/account linking and headset | + +Echo VR community installation is separate from RiftLift. Start with the +[Echo VR Lounge community](https://discord.com/servers/echo-vr-lounge-779349159852769310) +and its current PC patching instructions; multiplayer account linking requires +the player's participation. Do not substitute a Quest APK for the Windows game. + +For each title, verify launch, both-eye rendering, tracking, controller mapping, +audio, menu interaction, and gameplay. Record native backend, headset connection, +game build, exit code, and logs. Injection success alone is not a game pass. + +## Tests and rollback + +```powershell +.\.venv\Scripts\python.exe -m pip install pytest +.\.venv\Scripts\python.exe -m pytest tests/test_windows_native.py tests/test_util.py tests/test_library.py -q +``` + +The Linux-focused full suite includes POSIX paths, permissions, and `fcntl` and +is not a Windows acceptance suite. Existing Linux CI continues to cover that +host; the added Windows job covers the native frontend and shared utilities. + +Close games and RiftLift before undoing the installation. The installation is +local to this checkout (`.venv/` and `portable/`); it does not register services, +change XR registry keys, replace game files, or alter another VR installation. +Keep `portable/` if you want to preserve the local library. Revert the branch's +commits to restore upstream source behavior. diff --git a/install-windows.ps1 b/install-windows.ps1 new file mode 100644 index 0000000..ec702b5 --- /dev/null +++ b/install-windows.ps1 @@ -0,0 +1,24 @@ +param([string]$PythonVersion = '3.12') +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { +if (-not (Test-Path '.venv/Scripts/python.exe')) { + & py "-$PythonVersion" -m venv .venv + if ($LASTEXITCODE -ne 0) { throw 'Python environment creation failed' } +} +& ./.venv/Scripts/python.exe -m pip install -e . +if ($LASTEXITCODE -ne 0) { throw 'RiftLift dependency installation failed' } +$env:RIFTLIFT_HOME = Join-Path $PSScriptRoot 'portable' +& ./.venv/Scripts/python.exe -m riftlift.cli setup +if ($LASTEXITCODE -ne 0) { throw 'Native runtime installation failed' } +& ./.venv/Scripts/python.exe -m riftlift.cli doctor --no-paste +if ($LASTEXITCODE -eq 2) { + Write-Host 'Runtime installed. Configure the headset/OpenXR runtime before game testing.' +} elseif ($LASTEXITCODE -ne 0) { + throw 'Windows diagnostics failed' +} + +} finally { + Pop-Location +} +exit 0 From d593294c30986660139e4c5dd2b911c432a9f25f Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:55:55 -0400 Subject: [PATCH 04/12] Normalize Windows checkout text to repository LF line endings --- .gitignore | 20 +- install-windows.ps1 | 48 ++-- src/riftlift/cli.py | 546 ++++++++++++++++++++--------------------- src/riftlift/config.py | 464 +++++++++++++++++----------------- src/riftlift/gui.py | 48 ++-- src/riftlift/util.py | 256 +++++++++---------- 6 files changed, 691 insertions(+), 691 deletions(-) diff --git a/.gitignore b/.gitignore index 16d907a..503bc4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,13 @@ -__pycache__/ -*.egg-info/ -.pytest_cache/ -.venv/ -build/ -dist/ -out/ - -# Runtime artifacts written by launched games in the repository working tree. -logs/ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.venv/ +build/ +dist/ +out/ + +# Runtime artifacts written by launched games in the repository working tree. +logs/ logConfig.xml # Native Windows install and local verification output. diff --git a/install-windows.ps1 b/install-windows.ps1 index ec702b5..02a144a 100644 --- a/install-windows.ps1 +++ b/install-windows.ps1 @@ -1,24 +1,24 @@ -param([string]$PythonVersion = '3.12') -$ErrorActionPreference = 'Stop' -Push-Location $PSScriptRoot -try { -if (-not (Test-Path '.venv/Scripts/python.exe')) { - & py "-$PythonVersion" -m venv .venv - if ($LASTEXITCODE -ne 0) { throw 'Python environment creation failed' } -} -& ./.venv/Scripts/python.exe -m pip install -e . -if ($LASTEXITCODE -ne 0) { throw 'RiftLift dependency installation failed' } -$env:RIFTLIFT_HOME = Join-Path $PSScriptRoot 'portable' -& ./.venv/Scripts/python.exe -m riftlift.cli setup -if ($LASTEXITCODE -ne 0) { throw 'Native runtime installation failed' } -& ./.venv/Scripts/python.exe -m riftlift.cli doctor --no-paste -if ($LASTEXITCODE -eq 2) { - Write-Host 'Runtime installed. Configure the headset/OpenXR runtime before game testing.' -} elseif ($LASTEXITCODE -ne 0) { - throw 'Windows diagnostics failed' -} - -} finally { - Pop-Location -} -exit 0 +param([string]$PythonVersion = '3.12') +$ErrorActionPreference = 'Stop' +Push-Location $PSScriptRoot +try { +if (-not (Test-Path '.venv/Scripts/python.exe')) { + & py "-$PythonVersion" -m venv .venv + if ($LASTEXITCODE -ne 0) { throw 'Python environment creation failed' } +} +& ./.venv/Scripts/python.exe -m pip install -e . +if ($LASTEXITCODE -ne 0) { throw 'RiftLift dependency installation failed' } +$env:RIFTLIFT_HOME = Join-Path $PSScriptRoot 'portable' +& ./.venv/Scripts/python.exe -m riftlift.cli setup +if ($LASTEXITCODE -ne 0) { throw 'Native runtime installation failed' } +& ./.venv/Scripts/python.exe -m riftlift.cli doctor --no-paste +if ($LASTEXITCODE -eq 2) { + Write-Host 'Runtime installed. Configure the headset/OpenXR runtime before game testing.' +} elseif ($LASTEXITCODE -ne 0) { + throw 'Windows diagnostics failed' +} + +} finally { + Pop-Location +} +exit 0 diff --git a/src/riftlift/cli.py b/src/riftlift/cli.py index bfc1973..bd5b7fa 100644 --- a/src/riftlift/cli.py +++ b/src/riftlift/cli.py @@ -1,273 +1,273 @@ -from __future__ import annotations - -import argparse -import os -import sys - -if os.name != "nt": - from meta_pcvr_downloader.api import MetaApiError - from meta_pcvr_downloader.auth import AuthenticationError - from meta_pcvr_downloader.download import DownloadError - - from . import __version__ - from .auth import complete_login, login - from .config import Game, Paths, games - from .doctor import doctor - from .launch import launch - from .library import add, add_local - from .metadata import populate_game_metadata - from .playtime import playtime, playtime_label - from .runtime import setup - from .steam import sync_with_restart - from .steam_oculus import steam_oculus_game, steam_oculus_games - from .util import RiftLiftError - - -def parser() -> argparse.ArgumentParser: - root = argparse.ArgumentParser( - prog="riftlift", - description="Run owned Meta Rift games on Linux OpenXR/Monado.", - ) - root.add_argument("--version", action="version", version=f"%(prog)s {__version__}") - commands = root.add_subparsers(dest="command", required=True) - - commands.add_parser("gui", help="open the RiftLift desktop app") - - setup_command = commands.add_parser( - "setup", help="install/update the shared compatibility stack" - ) - setup_command.add_argument( - "--login", action="store_true", help="start browser-backed Meta sign-in" - ) - commands.add_parser( - "login", help="sign in to Meta through an isolated default-browser window" - ) - callback = commands.add_parser("callback", help=argparse.SUPPRESS) - callback.add_argument("url", nargs="?", help=argparse.SUPPRESS) - - add_command = commands.add_parser( - "add", help="download an owned Rift game and add it to Steam" - ) - add_command.add_argument("app", help="Meta Rift store URL or numeric app ID") - add_command.add_argument( - "--build", help="specific version, version code, or binary ID" - ) - add_command.add_argument( - "--executable", help="override the manifest launch executable" - ) - add_command.add_argument( - "--arguments", help="override the manifest launch arguments" - ) - add_command.add_argument( - "--jobs", - type=int, - choices=range(1, 33), - metavar="1-32", - help="download workers (default: adapts to available CPUs)", - ) - add_command.add_argument( - "--no-steam", action="store_true", help="download without updating Steam" - ) - - local_command = commands.add_parser( - "add-local", help="add an existing Windows VR game to RiftLift" - ) - local_command.add_argument("executable", help="path to the game's .exe file") - local_command.add_argument("--name", help="library name (default: executable name)") - local_command.add_argument( - "--root", help="game folder containing the executable (default: its folder)" - ) - local_command.add_argument("--arguments", help="launch arguments") - local_command.add_argument( - "--app-key", help="Oculus application key (advanced; normally unnecessary)" - ) - local_command.add_argument("--artwork", help="cover image file") - local_command.add_argument("--game-version", default="", help="displayed version") - local_command.add_argument( - "--no-steam", action="store_true", help="register without updating Steam" - ) - - launch_command = commands.add_parser("launch", help="launch an installed game") - launch_command.add_argument("slug") - launch_command.add_argument("arguments", nargs=argparse.REMAINDER) - steam_launch = commands.add_parser( - "launch-steam", help="launch an installed Steam Oculus XR game" - ) - steam_launch.add_argument("app_id") - steam_launch.add_argument("steam_command", nargs=argparse.REMAINDER) - commands.add_parser( - "steam-oculus-ids", help="list installed Steam games needing RiftLift" - ) - commands.add_parser("list", help="list installed RiftLift games") - commands.add_parser( - "steam-sync", help="safely synchronize all RiftLift games into Steam" - ) - metadata_command = commands.add_parser( - "metadata", help="fetch artwork and catalog metadata" - ) - metadata_command.add_argument( - "slug", nargs="?", help="one installed game (default: all)" - ) - metadata_command.add_argument( - "--refresh", action="store_true", help="refresh cached catalog data and artwork" - ) - doctor_command = commands.add_parser( - "doctor", help="create a shareable runtime and recent-launch diagnostic report" - ) - doctor_command.add_argument( - "--no-paste", - action="store_true", - help="print locally without creating a public paste", - ) - return root - - -def _run_gui(_paths: Paths, _arguments: argparse.Namespace) -> int: - from .gui import main as gui_main - - return gui_main() - - -def _run_setup(paths: Paths, arguments: argparse.Namespace) -> int: - setup(paths) - print("RiftLift compatibility stack is ready.") - return login(paths) if arguments.login else 0 - - -def _run_login(paths: Paths, _arguments: argparse.Namespace) -> int: - return login(paths) - - -def _run_callback(paths: Paths, arguments: argparse.Namespace) -> int: - return complete_login(paths, arguments.url or "") - - -def _run_add(paths: Paths, arguments: argparse.Namespace) -> int: - game = add( - paths, - arguments.app, - build_selector=arguments.build, - executable=arguments.executable, - arguments=arguments.arguments, - jobs=arguments.jobs, - ) - print(f"Installed {game.name} as {game.slug}.") - if not arguments.no_steam: - print(f"Added to Steam ({sync_with_restart(paths)}).") - return 0 - - -def _run_add_local(paths: Paths, arguments: argparse.Namespace) -> int: - game = add_local( - paths, - arguments.executable, - name=arguments.name, - root=arguments.root, - arguments=arguments.arguments, - app_key=arguments.app_key, - artwork=arguments.artwork, - version=arguments.game_version, - ) - print(f"Added local game {game.name} as {game.slug}.") - if not arguments.no_steam: - print(f"Added to Steam ({sync_with_restart(paths)}).") - return 0 - - -def _run_steam_launch(paths: Paths, arguments: argparse.Namespace) -> int: - from .steam_oculus import game_from_steam_command - - if arguments.steam_command in (["-h"], ["--help"]): - parser().parse_args(["launch-steam", "--help"]) - discovered = steam_oculus_game(arguments.app_id) - return launch( - paths, game_from_steam_command(discovered, arguments.steam_command), [] - ) - - -def _run_launch(paths: Paths, arguments: argparse.Namespace) -> int: - if arguments.arguments in (["-h"], ["--help"]): - parser().parse_args(["launch", "--help"]) - return launch(paths, Game.load(paths, arguments.slug), arguments.arguments) - - -def _run_steam_oculus_ids(_paths: Paths, _arguments: argparse.Namespace) -> int: - for game in steam_oculus_games(): - print(game.app_id) - return 0 - - -def _run_list(paths: Paths, _arguments: argparse.Namespace) -> int: - installed = games(paths) - if not installed: - print( - "No games installed. Use 'riftlift add STORE_URL' or " - "'riftlift add-local GAME.exe'." - ) - for game in installed: - played = playtime_label(playtime(paths, game.slug)) - print(f"{game.slug:<36} {game.name} {game.version} [{played}]") - return 0 - - -def _run_metadata(paths: Paths, arguments: argparse.Namespace) -> int: - installed = [Game.load(paths, arguments.slug)] if arguments.slug else games(paths) - for game in installed: - populate_game_metadata(paths, game, refresh=arguments.refresh) - print(f"Updated metadata for {game.name}.") - return 0 - - -def _run_steam_sync(paths: Paths, _arguments: argparse.Namespace) -> int: - print(sync_with_restart(paths)) - return 0 - - -def _run_doctor(paths: Paths, arguments: argparse.Namespace) -> int: - return doctor(paths, paste=not arguments.no_paste) - - -def run(arguments: argparse.Namespace) -> int: - handlers = { - "gui": _run_gui, - "setup": _run_setup, - "login": _run_login, - "callback": _run_callback, - "add": _run_add, - "add-local": _run_add_local, - "launch": _run_launch, - "launch-steam": _run_steam_launch, - "steam-oculus-ids": _run_steam_oculus_ids, - "list": _run_list, - "steam-sync": _run_steam_sync, - "metadata": _run_metadata, - "doctor": _run_doctor, - } - return handlers[arguments.command](Paths.defaults(), arguments) - - -def main(argv: list[str] | None = None) -> int: - if os.name == "nt": - from .windows import main as windows_main - - return windows_main(argv) - values = list(sys.argv[1:] if argv is None else argv) - try: - return run(parser().parse_args(values)) - except KeyboardInterrupt: - print("\nCancelled.", file=sys.stderr) - return 130 - except ( - AuthenticationError, - DownloadError, - MetaApiError, - RiftLiftError, - ValueError, - OSError, - ) as error: - print(f"error: {error}", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) +from __future__ import annotations + +import argparse +import os +import sys + +if os.name != "nt": + from meta_pcvr_downloader.api import MetaApiError + from meta_pcvr_downloader.auth import AuthenticationError + from meta_pcvr_downloader.download import DownloadError + + from . import __version__ + from .auth import complete_login, login + from .config import Game, Paths, games + from .doctor import doctor + from .launch import launch + from .library import add, add_local + from .metadata import populate_game_metadata + from .playtime import playtime, playtime_label + from .runtime import setup + from .steam import sync_with_restart + from .steam_oculus import steam_oculus_game, steam_oculus_games + from .util import RiftLiftError + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser( + prog="riftlift", + description="Run owned Meta Rift games on Linux OpenXR/Monado.", + ) + root.add_argument("--version", action="version", version=f"%(prog)s {__version__}") + commands = root.add_subparsers(dest="command", required=True) + + commands.add_parser("gui", help="open the RiftLift desktop app") + + setup_command = commands.add_parser( + "setup", help="install/update the shared compatibility stack" + ) + setup_command.add_argument( + "--login", action="store_true", help="start browser-backed Meta sign-in" + ) + commands.add_parser( + "login", help="sign in to Meta through an isolated default-browser window" + ) + callback = commands.add_parser("callback", help=argparse.SUPPRESS) + callback.add_argument("url", nargs="?", help=argparse.SUPPRESS) + + add_command = commands.add_parser( + "add", help="download an owned Rift game and add it to Steam" + ) + add_command.add_argument("app", help="Meta Rift store URL or numeric app ID") + add_command.add_argument( + "--build", help="specific version, version code, or binary ID" + ) + add_command.add_argument( + "--executable", help="override the manifest launch executable" + ) + add_command.add_argument( + "--arguments", help="override the manifest launch arguments" + ) + add_command.add_argument( + "--jobs", + type=int, + choices=range(1, 33), + metavar="1-32", + help="download workers (default: adapts to available CPUs)", + ) + add_command.add_argument( + "--no-steam", action="store_true", help="download without updating Steam" + ) + + local_command = commands.add_parser( + "add-local", help="add an existing Windows VR game to RiftLift" + ) + local_command.add_argument("executable", help="path to the game's .exe file") + local_command.add_argument("--name", help="library name (default: executable name)") + local_command.add_argument( + "--root", help="game folder containing the executable (default: its folder)" + ) + local_command.add_argument("--arguments", help="launch arguments") + local_command.add_argument( + "--app-key", help="Oculus application key (advanced; normally unnecessary)" + ) + local_command.add_argument("--artwork", help="cover image file") + local_command.add_argument("--game-version", default="", help="displayed version") + local_command.add_argument( + "--no-steam", action="store_true", help="register without updating Steam" + ) + + launch_command = commands.add_parser("launch", help="launch an installed game") + launch_command.add_argument("slug") + launch_command.add_argument("arguments", nargs=argparse.REMAINDER) + steam_launch = commands.add_parser( + "launch-steam", help="launch an installed Steam Oculus XR game" + ) + steam_launch.add_argument("app_id") + steam_launch.add_argument("steam_command", nargs=argparse.REMAINDER) + commands.add_parser( + "steam-oculus-ids", help="list installed Steam games needing RiftLift" + ) + commands.add_parser("list", help="list installed RiftLift games") + commands.add_parser( + "steam-sync", help="safely synchronize all RiftLift games into Steam" + ) + metadata_command = commands.add_parser( + "metadata", help="fetch artwork and catalog metadata" + ) + metadata_command.add_argument( + "slug", nargs="?", help="one installed game (default: all)" + ) + metadata_command.add_argument( + "--refresh", action="store_true", help="refresh cached catalog data and artwork" + ) + doctor_command = commands.add_parser( + "doctor", help="create a shareable runtime and recent-launch diagnostic report" + ) + doctor_command.add_argument( + "--no-paste", + action="store_true", + help="print locally without creating a public paste", + ) + return root + + +def _run_gui(_paths: Paths, _arguments: argparse.Namespace) -> int: + from .gui import main as gui_main + + return gui_main() + + +def _run_setup(paths: Paths, arguments: argparse.Namespace) -> int: + setup(paths) + print("RiftLift compatibility stack is ready.") + return login(paths) if arguments.login else 0 + + +def _run_login(paths: Paths, _arguments: argparse.Namespace) -> int: + return login(paths) + + +def _run_callback(paths: Paths, arguments: argparse.Namespace) -> int: + return complete_login(paths, arguments.url or "") + + +def _run_add(paths: Paths, arguments: argparse.Namespace) -> int: + game = add( + paths, + arguments.app, + build_selector=arguments.build, + executable=arguments.executable, + arguments=arguments.arguments, + jobs=arguments.jobs, + ) + print(f"Installed {game.name} as {game.slug}.") + if not arguments.no_steam: + print(f"Added to Steam ({sync_with_restart(paths)}).") + return 0 + + +def _run_add_local(paths: Paths, arguments: argparse.Namespace) -> int: + game = add_local( + paths, + arguments.executable, + name=arguments.name, + root=arguments.root, + arguments=arguments.arguments, + app_key=arguments.app_key, + artwork=arguments.artwork, + version=arguments.game_version, + ) + print(f"Added local game {game.name} as {game.slug}.") + if not arguments.no_steam: + print(f"Added to Steam ({sync_with_restart(paths)}).") + return 0 + + +def _run_steam_launch(paths: Paths, arguments: argparse.Namespace) -> int: + from .steam_oculus import game_from_steam_command + + if arguments.steam_command in (["-h"], ["--help"]): + parser().parse_args(["launch-steam", "--help"]) + discovered = steam_oculus_game(arguments.app_id) + return launch( + paths, game_from_steam_command(discovered, arguments.steam_command), [] + ) + + +def _run_launch(paths: Paths, arguments: argparse.Namespace) -> int: + if arguments.arguments in (["-h"], ["--help"]): + parser().parse_args(["launch", "--help"]) + return launch(paths, Game.load(paths, arguments.slug), arguments.arguments) + + +def _run_steam_oculus_ids(_paths: Paths, _arguments: argparse.Namespace) -> int: + for game in steam_oculus_games(): + print(game.app_id) + return 0 + + +def _run_list(paths: Paths, _arguments: argparse.Namespace) -> int: + installed = games(paths) + if not installed: + print( + "No games installed. Use 'riftlift add STORE_URL' or " + "'riftlift add-local GAME.exe'." + ) + for game in installed: + played = playtime_label(playtime(paths, game.slug)) + print(f"{game.slug:<36} {game.name} {game.version} [{played}]") + return 0 + + +def _run_metadata(paths: Paths, arguments: argparse.Namespace) -> int: + installed = [Game.load(paths, arguments.slug)] if arguments.slug else games(paths) + for game in installed: + populate_game_metadata(paths, game, refresh=arguments.refresh) + print(f"Updated metadata for {game.name}.") + return 0 + + +def _run_steam_sync(paths: Paths, _arguments: argparse.Namespace) -> int: + print(sync_with_restart(paths)) + return 0 + + +def _run_doctor(paths: Paths, arguments: argparse.Namespace) -> int: + return doctor(paths, paste=not arguments.no_paste) + + +def run(arguments: argparse.Namespace) -> int: + handlers = { + "gui": _run_gui, + "setup": _run_setup, + "login": _run_login, + "callback": _run_callback, + "add": _run_add, + "add-local": _run_add_local, + "launch": _run_launch, + "launch-steam": _run_steam_launch, + "steam-oculus-ids": _run_steam_oculus_ids, + "list": _run_list, + "steam-sync": _run_steam_sync, + "metadata": _run_metadata, + "doctor": _run_doctor, + } + return handlers[arguments.command](Paths.defaults(), arguments) + + +def main(argv: list[str] | None = None) -> int: + if os.name == "nt": + from .windows import main as windows_main + + return windows_main(argv) + values = list(sys.argv[1:] if argv is None else argv) + try: + return run(parser().parse_args(values)) + except KeyboardInterrupt: + print("\nCancelled.", file=sys.stderr) + return 130 + except ( + AuthenticationError, + DownloadError, + MetaApiError, + RiftLiftError, + ValueError, + OSError, + ) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/config.py b/src/riftlift/config.py index f920652..ab610b5 100644 --- a/src/riftlift/config.py +++ b/src/riftlift/config.py @@ -1,232 +1,232 @@ -from __future__ import annotations - -import json -import os -import re -from dataclasses import asdict, dataclass, field, fields -from pathlib import Path -from typing import Any - -from .util import atomic_write_text - -_GAME_SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") - - -def _game_record(paths: Paths, slug: str) -> Path: - if _GAME_SLUG.fullmatch(slug) is None: - raise ValueError(f"invalid game slug: {slug!r}") - return paths.data / "games" / f"{slug}.json" - - -def _xdg(name: str, fallback: Path) -> Path: - value = os.environ.get(name) - if not value: - return fallback - path = Path(value).expanduser() - return path if path.is_absolute() else fallback - - -def xdg_data_home() -> Path: - """Return the freedesktop user data directory.""" - return _xdg("XDG_DATA_HOME", Path.home() / ".local/share") - - -def xdg_config_home() -> Path: - """Return the freedesktop user configuration directory.""" - return _xdg("XDG_CONFIG_HOME", Path.home() / ".config") - - -def xdg_cache_home() -> Path: - """Return the freedesktop user cache directory.""" - return _xdg("XDG_CACHE_HOME", Path.home() / ".cache") - - -def xdg_data_dirs() -> tuple[Path, ...]: - """Return absolute freedesktop system data directories in search order.""" - value = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share" - return tuple( - path - for item in value.split(":") - if item and (path := Path(item).expanduser()).is_absolute() - ) - - -@dataclass(slots=True) -class Paths: - data: Path - cache: Path - config: Path - games: Path - prefix: Path - tools: Path - - @classmethod - def defaults(cls) -> Paths: - if os.name == "nt": - root = Path( - os.environ.get("RIFTLIFT_HOME") - or str(Path(os.environ["LOCALAPPDATA"]) / "RiftLift") - ) - root = root.expanduser().resolve() - return cls( - root / "data", - root / "cache", - root / "config", - root / "games", - root / "compatdata", - root / "tools", - ) - home = Path.home() - data = xdg_data_home() / "riftlift" - cache = xdg_cache_home() / "riftlift" - config = xdg_config_home() / "riftlift" - games = Path(os.environ.get("RIFTLIFT_GAMES_DIR", home / "Games/RiftLift")) - return cls(data, cache, config, games, data / "compatdata", data / "tools") - - def create(self) -> None: - for path in ( - self.data, - self.cache, - self.config, - self.games, - self.prefix, - self.tools, - ): - path.mkdir(parents=True, exist_ok=True) - (self.data / "games").mkdir(exist_ok=True) - - -@dataclass(slots=True) -class Game: - slug: str - name: str - app_id: str - app_key: str - directory: str - executable: str - arguments: list[str] - version: str = "" - platform_shim: bool = True - platform_offline: bool = False - store_url: str = "" - description: str = "" - developer: str = "" - publisher: str = "" - genres: list[str] = field(default_factory=list) - artwork: dict[str, str] = field(default_factory=dict) - steam_app_id: int = 0 - source: str = "meta" - - def _validate_strings(self) -> None: - for field_name in ( - "slug", - "name", - "app_id", - "app_key", - "directory", - "executable", - "version", - "store_url", - "description", - "developer", - "publisher", - ): - if not isinstance(getattr(self, field_name), str): - raise ValueError(f"game {field_name} must be a string") - - def _validate_collections(self) -> None: - if not isinstance(self.arguments, list) or not all( - isinstance(value, str) for value in self.arguments - ): - raise ValueError("game arguments must be a list of strings") - if not isinstance(self.genres, list) or not all( - isinstance(value, str) for value in self.genres - ): - raise ValueError("game genres must be a list of strings") - if not isinstance(self.artwork, dict) or not all( - isinstance(key, str) and isinstance(value, str) - for key, value in self.artwork.items() - ): - raise ValueError("game artwork must map names to paths") - - def __post_init__(self) -> None: - self._validate_strings() - if _GAME_SLUG.fullmatch(self.slug) is None: - raise ValueError(f"invalid game slug: {self.slug!r}") - directory = Path(self.directory) - executable = Path(self.executable) - if not directory.is_absolute(): - raise ValueError("game directory must be an absolute path") - if executable.is_absolute() or ".." in executable.parts: - raise ValueError("game executable must stay inside its game directory") - if not self.executable or not executable.name: - raise ValueError("game executable cannot be empty") - self._validate_collections() - if self.source not in {"local", "meta", "steam"}: - raise ValueError(f"invalid game source: {self.source!r}") - if not isinstance(self.steam_app_id, int) or self.steam_app_id < 0: - raise ValueError("game Steam app ID must be a nonnegative integer") - - @property - def game_dir(self) -> Path: - return Path(self.directory) - - @property - def executable_path(self) -> Path: - return self.game_dir / self.executable - - def save(self, paths: Paths) -> Path: - paths.create() - target = _game_record(paths, self.slug) - atomic_write_text(target, json.dumps(asdict(self), indent=2) + "\n") - return target - - @classmethod - def load(cls, paths: Paths, slug: str) -> Game: - target = _game_record(paths, slug) - try: - value: Any = json.loads(target.read_text()) - except FileNotFoundError as error: - raise ValueError( - f"unknown game {slug!r}; add it to RiftLift first" - ) from error - except (OSError, json.JSONDecodeError, UnicodeError) as error: - raise ValueError(f"cannot read game record {target}: {error}") from error - if not isinstance(value, dict): - raise ValueError(f"game record is not a JSON object: {target}") - if "source" not in value: - value["source"] = ( - "steam" - if str(value.get("app_key", "")).startswith("steam.app.") - else "meta" - ) - allowed = {field.name for field in fields(cls)} - if unknown := sorted(value.keys() - allowed): - raise ValueError(f"game record contains unknown fields {unknown}: {target}") - try: - return cls(**value) - except (TypeError, ValueError) as error: - raise ValueError(f"invalid game record {target}: {error}") from error - - -def games(paths: Paths) -> list[Game]: - return [ - Game.load(paths, target.stem) - for target in sorted((paths.data / "games").glob("*.json")) - ] - - -def debug_logging_enabled(paths: Paths) -> bool: - return (paths.config / "debug-logging").is_file() - - -def set_debug_logging(paths: Paths, enabled: bool) -> None: - target = paths.config / "debug-logging" - if not enabled: - target.unlink(missing_ok=True) - return - paths.config.mkdir(parents=True, exist_ok=True) - descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(descriptor, "w") as stream: - stream.write("1\n") - target.chmod(0o600) +from __future__ import annotations + +import json +import os +import re +from dataclasses import asdict, dataclass, field, fields +from pathlib import Path +from typing import Any + +from .util import atomic_write_text + +_GAME_SLUG = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") + + +def _game_record(paths: Paths, slug: str) -> Path: + if _GAME_SLUG.fullmatch(slug) is None: + raise ValueError(f"invalid game slug: {slug!r}") + return paths.data / "games" / f"{slug}.json" + + +def _xdg(name: str, fallback: Path) -> Path: + value = os.environ.get(name) + if not value: + return fallback + path = Path(value).expanduser() + return path if path.is_absolute() else fallback + + +def xdg_data_home() -> Path: + """Return the freedesktop user data directory.""" + return _xdg("XDG_DATA_HOME", Path.home() / ".local/share") + + +def xdg_config_home() -> Path: + """Return the freedesktop user configuration directory.""" + return _xdg("XDG_CONFIG_HOME", Path.home() / ".config") + + +def xdg_cache_home() -> Path: + """Return the freedesktop user cache directory.""" + return _xdg("XDG_CACHE_HOME", Path.home() / ".cache") + + +def xdg_data_dirs() -> tuple[Path, ...]: + """Return absolute freedesktop system data directories in search order.""" + value = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share" + return tuple( + path + for item in value.split(":") + if item and (path := Path(item).expanduser()).is_absolute() + ) + + +@dataclass(slots=True) +class Paths: + data: Path + cache: Path + config: Path + games: Path + prefix: Path + tools: Path + + @classmethod + def defaults(cls) -> Paths: + if os.name == "nt": + root = Path( + os.environ.get("RIFTLIFT_HOME") + or str(Path(os.environ["LOCALAPPDATA"]) / "RiftLift") + ) + root = root.expanduser().resolve() + return cls( + root / "data", + root / "cache", + root / "config", + root / "games", + root / "compatdata", + root / "tools", + ) + home = Path.home() + data = xdg_data_home() / "riftlift" + cache = xdg_cache_home() / "riftlift" + config = xdg_config_home() / "riftlift" + games = Path(os.environ.get("RIFTLIFT_GAMES_DIR", home / "Games/RiftLift")) + return cls(data, cache, config, games, data / "compatdata", data / "tools") + + def create(self) -> None: + for path in ( + self.data, + self.cache, + self.config, + self.games, + self.prefix, + self.tools, + ): + path.mkdir(parents=True, exist_ok=True) + (self.data / "games").mkdir(exist_ok=True) + + +@dataclass(slots=True) +class Game: + slug: str + name: str + app_id: str + app_key: str + directory: str + executable: str + arguments: list[str] + version: str = "" + platform_shim: bool = True + platform_offline: bool = False + store_url: str = "" + description: str = "" + developer: str = "" + publisher: str = "" + genres: list[str] = field(default_factory=list) + artwork: dict[str, str] = field(default_factory=dict) + steam_app_id: int = 0 + source: str = "meta" + + def _validate_strings(self) -> None: + for field_name in ( + "slug", + "name", + "app_id", + "app_key", + "directory", + "executable", + "version", + "store_url", + "description", + "developer", + "publisher", + ): + if not isinstance(getattr(self, field_name), str): + raise ValueError(f"game {field_name} must be a string") + + def _validate_collections(self) -> None: + if not isinstance(self.arguments, list) or not all( + isinstance(value, str) for value in self.arguments + ): + raise ValueError("game arguments must be a list of strings") + if not isinstance(self.genres, list) or not all( + isinstance(value, str) for value in self.genres + ): + raise ValueError("game genres must be a list of strings") + if not isinstance(self.artwork, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in self.artwork.items() + ): + raise ValueError("game artwork must map names to paths") + + def __post_init__(self) -> None: + self._validate_strings() + if _GAME_SLUG.fullmatch(self.slug) is None: + raise ValueError(f"invalid game slug: {self.slug!r}") + directory = Path(self.directory) + executable = Path(self.executable) + if not directory.is_absolute(): + raise ValueError("game directory must be an absolute path") + if executable.is_absolute() or ".." in executable.parts: + raise ValueError("game executable must stay inside its game directory") + if not self.executable or not executable.name: + raise ValueError("game executable cannot be empty") + self._validate_collections() + if self.source not in {"local", "meta", "steam"}: + raise ValueError(f"invalid game source: {self.source!r}") + if not isinstance(self.steam_app_id, int) or self.steam_app_id < 0: + raise ValueError("game Steam app ID must be a nonnegative integer") + + @property + def game_dir(self) -> Path: + return Path(self.directory) + + @property + def executable_path(self) -> Path: + return self.game_dir / self.executable + + def save(self, paths: Paths) -> Path: + paths.create() + target = _game_record(paths, self.slug) + atomic_write_text(target, json.dumps(asdict(self), indent=2) + "\n") + return target + + @classmethod + def load(cls, paths: Paths, slug: str) -> Game: + target = _game_record(paths, slug) + try: + value: Any = json.loads(target.read_text()) + except FileNotFoundError as error: + raise ValueError( + f"unknown game {slug!r}; add it to RiftLift first" + ) from error + except (OSError, json.JSONDecodeError, UnicodeError) as error: + raise ValueError(f"cannot read game record {target}: {error}") from error + if not isinstance(value, dict): + raise ValueError(f"game record is not a JSON object: {target}") + if "source" not in value: + value["source"] = ( + "steam" + if str(value.get("app_key", "")).startswith("steam.app.") + else "meta" + ) + allowed = {field.name for field in fields(cls)} + if unknown := sorted(value.keys() - allowed): + raise ValueError(f"game record contains unknown fields {unknown}: {target}") + try: + return cls(**value) + except (TypeError, ValueError) as error: + raise ValueError(f"invalid game record {target}: {error}") from error + + +def games(paths: Paths) -> list[Game]: + return [ + Game.load(paths, target.stem) + for target in sorted((paths.data / "games").glob("*.json")) + ] + + +def debug_logging_enabled(paths: Paths) -> bool: + return (paths.config / "debug-logging").is_file() + + +def set_debug_logging(paths: Paths, enabled: bool) -> None: + target = paths.config / "debug-logging" + if not enabled: + target.unlink(missing_ok=True) + return + paths.config.mkdir(parents=True, exist_ok=True) + descriptor = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w") as stream: + stream.write("1\n") + target.chmod(0o600) diff --git a/src/riftlift/gui.py b/src/riftlift/gui.py index b82966e..0ce599d 100644 --- a/src/riftlift/gui.py +++ b/src/riftlift/gui.py @@ -1,24 +1,24 @@ -"""Stable entry point for RiftLift's desktop application.""" - -from __future__ import annotations - - -def main() -> int: - import os - - if os.name == "nt": - from .windows_gui import main as windows_main - - return windows_main() - try: - from .main_window import main as window_main - except ModuleNotFoundError as error: - if not error.name or not error.name.startswith("PySide6"): - raise - print(f"RiftLift's GUI needs Qt 6: {error}") - return 1 - return window_main() - - -if __name__ == "__main__": - raise SystemExit(main()) +"""Stable entry point for RiftLift's desktop application.""" + +from __future__ import annotations + + +def main() -> int: + import os + + if os.name == "nt": + from .windows_gui import main as windows_main + + return windows_main() + try: + from .main_window import main as window_main + except ModuleNotFoundError as error: + if not error.name or not error.name.startswith("PySide6"): + raise + print(f"RiftLift's GUI needs Qt 6: {error}") + return 1 + return window_main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/util.py b/src/riftlift/util.py index adcd9d6..475064a 100644 --- a/src/riftlift/util.py +++ b/src/riftlift/util.py @@ -1,135 +1,135 @@ -from __future__ import annotations - -import hashlib -import os -import shutil -import subprocess -import tempfile -import time -import urllib.error -import urllib.request -from collections.abc import Iterable -from pathlib import Path - -from . import __version__ - - -class RiftLiftError(RuntimeError): - """A concise, user-actionable RiftLift failure.""" - - -def read_limited(stream: object, maximum: int, label: str) -> bytes: - """Read a response-like stream without trusting its declared length.""" - headers = getattr(stream, "headers", {}) - content_length = headers.get("Content-Length") - try: - declared = int(content_length) if content_length is not None else None - except (TypeError, ValueError): - declared = None - limit_mib = maximum // (1024 * 1024) - if declared is not None and declared > maximum: - raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") - payload = stream.read(maximum + 1) # type: ignore[attr-defined] - if len(payload) > maximum: - raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") - return payload - - -def atomic_write_bytes(target: Path, payload: bytes, mode: int = 0o600) -> None: - """Atomically replace *target* using a unique file in the same directory.""" - target.parent.mkdir(parents=True, exist_ok=True) - descriptor, name = tempfile.mkstemp(prefix=f".{target.name}-", dir=target.parent) - temporary = Path(name) - try: +from __future__ import annotations + +import hashlib +import os +import shutil +import subprocess +import tempfile +import time +import urllib.error +import urllib.request +from collections.abc import Iterable +from pathlib import Path + +from . import __version__ + + +class RiftLiftError(RuntimeError): + """A concise, user-actionable RiftLift failure.""" + + +def read_limited(stream: object, maximum: int, label: str) -> bytes: + """Read a response-like stream without trusting its declared length.""" + headers = getattr(stream, "headers", {}) + content_length = headers.get("Content-Length") + try: + declared = int(content_length) if content_length is not None else None + except (TypeError, ValueError): + declared = None + limit_mib = maximum // (1024 * 1024) + if declared is not None and declared > maximum: + raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") + payload = stream.read(maximum + 1) # type: ignore[attr-defined] + if len(payload) > maximum: + raise RiftLiftError(f"{label} exceeds the {limit_mib} MiB limit") + return payload + + +def atomic_write_bytes(target: Path, payload: bytes, mode: int = 0o600) -> None: + """Atomically replace *target* using a unique file in the same directory.""" + target.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix=f".{target.name}-", dir=target.parent) + temporary = Path(name) + try: with os.fdopen(descriptor, "wb") as stream: if os.name != "nt": os.fchmod(stream.fileno(), mode) stream.write(payload) - stream.flush() - os.fsync(stream.fileno()) - os.replace(temporary, target) - finally: - temporary.unlink(missing_ok=True) - - -def atomic_write_text(target: Path, value: str, mode: int = 0o600) -> None: - atomic_write_bytes(target, value.encode("utf-8"), mode) - - -def command(name: str) -> str: - value = shutil.which(name) - if not value: - raise RiftLiftError(f"required command is missing: {name}") - return value - - -def installed_command(name: str) -> Path: - """Find a RiftLift entry point installed on PATH or in the XDG bin directory.""" - if value := shutil.which(name): - return Path(value) - configured_bin_home = os.environ.get("XDG_BIN_HOME") - bin_home = Path(configured_bin_home).expanduser() if configured_bin_home else None - if bin_home is None or not bin_home.is_absolute(): - bin_home = Path.home() / ".local/bin" - target = bin_home / name - if target.is_file(): - return target - raise RiftLiftError( - f"RiftLift's {name!r} command was not found on PATH or in {bin_home}" - ) - - -def run( - arguments: Iterable[str | os.PathLike[str]], **kwargs: object -) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [os.fspath(value) for value in arguments], check=True, text=True, **kwargs - ) - - -def sha256(path: Path) -> str: - digest = hashlib.sha256() - with path.open("rb") as stream: - for block in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(block) - return digest.hexdigest() - - -def download(url: str, target: Path, expected_sha256: str = "") -> Path: - target.parent.mkdir(parents=True, exist_ok=True) - if target.is_file() and (not expected_sha256 or sha256(target) == expected_sha256): - return target - with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream: - temporary = Path(stream.name) - request = urllib.request.Request( - url, headers={"User-Agent": f"RiftLift/{__version__}"} - ) - for attempt in range(4): - stream.seek(0) - stream.truncate() - try: - with urllib.request.urlopen(request, timeout=60) as response: - shutil.copyfileobj(response, stream) - break - except (OSError, urllib.error.URLError, TimeoutError) as error: + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + +def atomic_write_text(target: Path, value: str, mode: int = 0o600) -> None: + atomic_write_bytes(target, value.encode("utf-8"), mode) + + +def command(name: str) -> str: + value = shutil.which(name) + if not value: + raise RiftLiftError(f"required command is missing: {name}") + return value + + +def installed_command(name: str) -> Path: + """Find a RiftLift entry point installed on PATH or in the XDG bin directory.""" + if value := shutil.which(name): + return Path(value) + configured_bin_home = os.environ.get("XDG_BIN_HOME") + bin_home = Path(configured_bin_home).expanduser() if configured_bin_home else None + if bin_home is None or not bin_home.is_absolute(): + bin_home = Path.home() / ".local/bin" + target = bin_home / name + if target.is_file(): + return target + raise RiftLiftError( + f"RiftLift's {name!r} command was not found on PATH or in {bin_home}" + ) + + +def run( + arguments: Iterable[str | os.PathLike[str]], **kwargs: object +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [os.fspath(value) for value in arguments], check=True, text=True, **kwargs + ) + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def download(url: str, target: Path, expected_sha256: str = "") -> Path: + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_file() and (not expected_sha256 or sha256(target) == expected_sha256): + return target + with tempfile.NamedTemporaryFile(dir=target.parent, delete=False) as stream: + temporary = Path(stream.name) + request = urllib.request.Request( + url, headers={"User-Agent": f"RiftLift/{__version__}"} + ) + for attempt in range(4): + stream.seek(0) + stream.truncate() + try: + with urllib.request.urlopen(request, timeout=60) as response: + shutil.copyfileobj(response, stream) + break + except (OSError, urllib.error.URLError, TimeoutError) as error: if attempt == 3: stream.close() temporary.unlink(missing_ok=True) - raise RiftLiftError( - f"could not download {target.name} after 4 attempts: {error}" - ) from error - time.sleep(2**attempt) - actual = sha256(temporary) - if expected_sha256 and actual != expected_sha256: - temporary.unlink(missing_ok=True) - raise RiftLiftError( - f"checksum mismatch for {target.name}: expected {expected_sha256}, got {actual}" - ) - temporary.chmod(0o644) - temporary.replace(target) - return target - - -def linux_to_windows(path: Path) -> str: - absolute = path.expanduser().resolve() - return "Z:" + str(absolute).replace("/", "\\") + raise RiftLiftError( + f"could not download {target.name} after 4 attempts: {error}" + ) from error + time.sleep(2**attempt) + actual = sha256(temporary) + if expected_sha256 and actual != expected_sha256: + temporary.unlink(missing_ok=True) + raise RiftLiftError( + f"checksum mismatch for {target.name}: expected {expected_sha256}, got {actual}" + ) + temporary.chmod(0o644) + temporary.replace(target) + return target + + +def linux_to_windows(path: Path) -> str: + absolute = path.expanduser().resolve() + return "Z:" + str(absolute).replace("/", "\\") From 79abba4adf5ca4b346ac329f3ee96ac6e9465640 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:01:51 -0400 Subject: [PATCH 05/12] Reuse the existing Linux UI on Windows and remove replacement window --- .github/workflows/windows-host.yml | 2 +- WINDOWS.md | 15 ++- src/riftlift/cli.py | 4 + src/riftlift/gui.py | 6 -- src/riftlift/main_window.py | 29 +++++- src/riftlift/playtime.py | 21 ++++- src/riftlift/windows.py | 11 ++- src/riftlift/windows_gui.py | 146 ----------------------------- src/riftlift/windows_ui_backend.py | 30 ++++++ tests/test_gui.py | 8 +- tests/test_playtime.py | 4 +- tests/test_windows_native.py | 34 ++++++- 12 files changed, 134 insertions(+), 176 deletions(-) delete mode 100644 src/riftlift/windows_gui.py create mode 100644 src/riftlift/windows_ui_backend.py diff --git a/.github/workflows/windows-host.yml b/.github/workflows/windows-host.yml index c679cea..4baca39 100644 --- a/.github/workflows/windows-host.yml +++ b/.github/workflows/windows-host.yml @@ -12,6 +12,6 @@ jobs: python-version: '3.12' - run: python -m pip install -e . pytest - run: python -m riftlift.cli --help - - run: python -m pytest tests/test_windows_native.py tests/test_util.py tests/test_library.py -q + - run: python -m pytest tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py -q env: QT_QPA_PLATFORM: offscreen diff --git a/WINDOWS.md b/WINDOWS.md index 797f01e..a8553cf 100644 --- a/WINDOWS.md +++ b/WINDOWS.md @@ -22,13 +22,18 @@ OpenVR headset runtime, and installed PC game files. For SteamVR use `--backend openvr`. The selected runtime must already be installed and configured; this installer does not change the system's active XR runtime. Run games through their normal client once first to finish prerequisites and -account setup. The GUI lets you add installed executables, select a backend, view -local diagnostics, and launch a game. It keeps launch work off the UI thread. +account setup. The existing Linux GUI is reused unchanged in layout and styling: `main_window.py`, +`game_ui.py`, `auth_ui.py`, and `theme.py`. No separate Windows UI exists. +A service adapter connects System and Launch in VR to the native backend. +Set `RIFTLIFT_WINDOWS_BACKEND=openvr` or `openxr` to override automatic backend +selection. The existing background-task and activity-log implementation is reused. ## Current boundaries -- Native Windows sign-in, store downloading, Steam shortcut management, and - playtime tracking are not implemented in this experimental frontend. +- Native Windows sign-in, store downloading and Steam shortcut management + are not implemented in this experimental host. Their existing controls remain + visible but are disabled on Windows; Add Game retains the existing local-game + dialog. Shared playtime tracking uses Windows file locking. - Install purchases with Meta's PC application before adding their executables. Original platform authentication and entitlement checks remain in place. - Game folders are referenced in place. No game executable or platform DLL is @@ -65,7 +70,7 @@ game build, exit code, and logs. Injection success alone is not a game pass. ```powershell .\.venv\Scripts\python.exe -m pip install pytest -.\.venv\Scripts\python.exe -m pytest tests/test_windows_native.py tests/test_util.py tests/test_library.py -q +.\.venv\Scripts\python.exe -m pytest tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py -q ``` The Linux-focused full suite includes POSIX paths, permissions, and `fcntl` and diff --git a/src/riftlift/cli.py b/src/riftlift/cli.py index bd5b7fa..0128899 100644 --- a/src/riftlift/cli.py +++ b/src/riftlift/cli.py @@ -24,6 +24,10 @@ def parser() -> argparse.ArgumentParser: + if os.name == "nt": + from .windows import parser as windows_parser + + return windows_parser() root = argparse.ArgumentParser( prog="riftlift", description="Run owned Meta Rift games on Linux OpenXR/Monado.", diff --git a/src/riftlift/gui.py b/src/riftlift/gui.py index 0ce599d..ed0bc18 100644 --- a/src/riftlift/gui.py +++ b/src/riftlift/gui.py @@ -4,12 +4,6 @@ def main() -> int: - import os - - if os.name == "nt": - from .windows_gui import main as windows_main - - return windows_main() try: from .main_window import main as window_main except ModuleNotFoundError as error: diff --git a/src/riftlift/main_window.py b/src/riftlift/main_window.py index f0efb5e..3f71009 100644 --- a/src/riftlift/main_window.py +++ b/src/riftlift/main_window.py @@ -4,6 +4,7 @@ import contextlib import io +import os import threading from collections.abc import Callable @@ -18,10 +19,8 @@ games, set_debug_logging, ) -from .doctor import doctor from .game_ui import LocalGameDialog, StoreGameDialog -from .launch import launch -from .library import add, add_local +from .library import add from .metadata import populate_game_metadata from .playtime import playtime, playtime_label from .steam import sync_with_restart @@ -30,6 +29,14 @@ from .theme import STYLE from .util import RiftLiftError +if os.name == "nt": + from .windows import add_local + from .windows_ui_backend import doctor, launch +else: + from .doctor import doctor + from .launch import launch + from .library import add_local + class Events(QtCore.QObject): output = QtCore.Signal(str) @@ -118,6 +125,12 @@ def __init__(self, paths: Paths | None = None): self.setMinimumSize(1024, 637) self.setStyleSheet(STYLE) self._build() + if os.name == "nt": + for control in (self.signin, self.steam_games, self.debug_logging): + control.setEnabled(False) + control.setToolTip( + "This integration is pending native Windows support." + ) self.refresh() def label(self, text="", name=""): @@ -420,6 +433,13 @@ def open_store(self): def add_dialog(self): dialog = StoreGameDialog(self.local_dialog, self) + if os.name == "nt": + dialog.entry.setEnabled(False) + dialog.steam.setChecked(False) + dialog.steam.setEnabled(False) + dialog.validation.setText( + "Install with Meta's PC app, then choose Add a local game above." + ) if dialog.exec() != QtWidgets.QDialog.Accepted: return @@ -433,6 +453,9 @@ def operation(): def local_dialog(self): dialog = LocalGameDialog(self) + if os.name == "nt": + dialog.steam.setChecked(False) + dialog.steam.setEnabled(False) if dialog.exec() != QtWidgets.QDialog.Accepted: return diff --git a/src/riftlift/playtime.py b/src/riftlift/playtime.py index 955473f..c7077c1 100644 --- a/src/riftlift/playtime.py +++ b/src/riftlift/playtime.py @@ -1,6 +1,5 @@ from __future__ import annotations -import fcntl import json import os import threading @@ -14,6 +13,11 @@ from .config import Paths from .util import atomic_write_text +if os.name == "nt": + import msvcrt +else: + import fcntl + _VERSION = 1 _CHECKPOINT_SECONDS = 30.0 @@ -66,9 +70,16 @@ def _update( paths: Paths, slug: str, operation: Callable[[dict[str, Any]], None] ) -> None: paths.data.mkdir(parents=True, exist_ok=True) - with _lock_target(paths).open("a+", encoding="utf-8") as lock: - os.fchmod(lock.fileno(), 0o600) - fcntl.flock(lock, fcntl.LOCK_EX) + with _lock_target(paths).open("a+b") as lock: + if os.name == "nt": + if lock.tell() == 0: + lock.write(b"\0") + lock.flush() + lock.seek(0) + msvcrt.locking(lock.fileno(), msvcrt.LK_LOCK, 1) + else: + os.fchmod(lock.fileno(), 0o600) + fcntl.flock(lock, fcntl.LOCK_EX) value = _read(paths) games = value.setdefault("games", {}) record = games.setdefault(slug, {}) @@ -78,7 +89,7 @@ def _update( operation(record) value["version"] = _VERSION _write(paths, value) - fcntl.flock(lock, fcntl.LOCK_UN) + # Closing the descriptor releases either OS's lock, including on error. def playtime(paths: Paths, slug: str) -> Playtime: diff --git a/src/riftlift/windows.py b/src/riftlift/windows.py index d8e0b20..7d75b64 100644 --- a/src/riftlift/windows.py +++ b/src/riftlift/windows.py @@ -134,12 +134,15 @@ def add_local( name: str | None = None, root: str | None = None, arguments: str | None = None, + artwork: str | None = None, ) -> Game: from .library import add_local as register if not is_pe64(Path(executable)): raise RiftLiftError("Native Windows launcher requires an x64 PE executable") - game = register(paths, executable, name=name, root=root, arguments=arguments) + game = register( + paths, executable, name=name, root=root, arguments=arguments, artwork=artwork + ) game.platform_shim = False game.platform_offline = False game.save(paths) @@ -196,7 +199,9 @@ def launch( # Do not write to game files or install the Linux platform shim. log = paths.data / "logs" / f"{game.slug}.log" log.parent.mkdir(parents=True, exist_ok=True) - with log.open("a", encoding="utf-8") as stream: + from .playtime import PlaytimeSession + + with log.open("a", encoding="utf-8") as stream, PlaytimeSession(paths, game.slug): process = subprocess.run( command, cwd=game.game_dir, @@ -245,7 +250,7 @@ def main(argv: list[str] | None = None) -> int: paths = Paths.defaults() try: if args.command == "gui": - from .windows_gui import main as gui + from .gui import main as gui return gui() if args.command == "setup": diff --git a/src/riftlift/windows_gui.py b/src/riftlift/windows_gui.py deleted file mode 100644 index a274f17..0000000 --- a/src/riftlift/windows_gui.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Small native Windows library; runtime execution stays in a child process.""" - -from __future__ import annotations - -import sys - -from PySide6.QtCore import QProcess, Qt -from PySide6.QtWidgets import ( - QApplication, - QComboBox, - QFileDialog, - QHBoxLayout, - QLabel, - QListWidget, - QListWidgetItem, - QMainWindow, - QMessageBox, - QPushButton, - QTextEdit, - QVBoxLayout, - QWidget, -) - -from .config import Paths, games -from .windows import add_local, doctor - - -class Window(QMainWindow): - def __init__(self): - super().__init__() - self.paths = Paths.defaults() - self.setWindowTitle("RiftLift — Windows experimental") - self.resize(840, 580) - body = QWidget() - self.setCentralWidget(body) - layout = QVBoxLayout(body) - layout.addWidget(QLabel("RiftLift native Windows host • x64 OpenXR / OpenVR")) - layout.addWidget( - QLabel( - "Install games with Meta's PC app or the game's community installer, then add the .exe." - ) - ) - self.library = QListWidget() - layout.addWidget(self.library) - row = QHBoxLayout() - layout.addLayout(row) - for label, action in [ - ("Add installed game…", self.add_game), - ("Refresh", self.refresh), - ("System", self.system), - ]: - button = QPushButton(label) - button.clicked.connect(action) - row.addWidget(button) - self.backend = QComboBox() - self.backend.addItems(["openxr", "openvr"]) - row.addWidget(self.backend) - self.launch_button = QPushButton("Launch in VR") - self.launch_button.clicked.connect(self.launch_game) - row.addWidget(self.launch_button) - self.log = QTextEdit() - self.log.setReadOnly(True) - layout.addWidget(self.log) - self.child = QProcess(self) - self.child.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) - self.child.readyReadStandardOutput.connect(self.read_output) - self.child.finished.connect(self.finished) - self.child.errorOccurred.connect(self.process_error) - self.refresh() - self.system() - - def refresh(self): - self.library.clear() - try: - for game in games(self.paths): - item = QListWidgetItem(game.name) - item.setData(Qt.ItemDataRole.UserRole, game.slug) - self.library.addItem(item) - except (OSError, ValueError) as error: - QMessageBox.critical(self, "Library", str(error)) - - def system(self): - report, _ = doctor(self.paths) - self.log.setPlainText(report) - - def add_game(self): - filename, _ = QFileDialog.getOpenFileName( - self, "Select installed game", "", "Windows executable (*.exe)" - ) - if filename: - try: - game = add_local(self.paths, filename) - self.log.append( - f"Registered {game.name}; original game files unchanged." - ) - self.refresh() - except (OSError, ValueError, RuntimeError) as error: - QMessageBox.critical(self, "Add game", str(error)) - - def launch_game(self): - item = self.library.currentItem() - if not item or self.child.state() != QProcess.ProcessState.NotRunning: - return - self.launch_button.setEnabled(False) - self.child.start( - sys.executable, - [ - "-m", - "riftlift.cli", - "launch", - item.data(Qt.ItemDataRole.UserRole), - "--backend", - self.backend.currentText(), - ], - ) - - def read_output(self): - self.log.append( - bytes(self.child.readAllStandardOutput()).decode("utf-8", errors="replace") - ) - - def finished(self, code, _status): - self.launch_button.setEnabled(True) - self.log.append( - f"Process finished: {code}. Rendering requires an in-headset test." - ) - - def process_error(self, _error): - self.launch_button.setEnabled(True) - self.log.append(self.child.errorString()) - - def closeEvent(self, event): - if self.child.state() != QProcess.ProcessState.NotRunning: - QMessageBox.information( - self, "Game running", "Close the game before closing RiftLift." - ) - event.ignore() - else: - event.accept() - - -def main() -> int: - app = QApplication.instance() or QApplication(sys.argv) - window = Window() - window.show() - return app.exec() diff --git a/src/riftlift/windows_ui_backend.py b/src/riftlift/windows_ui_backend.py new file mode 100644 index 0000000..c32c1e8 --- /dev/null +++ b/src/riftlift/windows_ui_backend.py @@ -0,0 +1,30 @@ +"""Adapt the native host to the existing main_window service signatures. + +No widgets, styles, or replacement windows live here. +""" + +import os + +from . import windows +from .config import Game, Paths +from .util import RiftLiftError + + +def doctor(paths: Paths) -> int: + report, status = windows.doctor(paths) + print(report) + if status: + raise RiftLiftError("Windows VR setup needs attention; see View Activity.") + return status + + +def launch(paths: Paths, game: Game, arguments: list[str]) -> int: + backend = os.environ.get("RIFTLIFT_WINDOWS_BACKEND") or ( + "openxr" if windows.runtime_ready("openxr") else "openvr" + ) + result = windows.launch(paths, game, backend, extra=arguments) + if result: + raise RiftLiftError( + f"Native game launch exited with code {result}; see View Activity." + ) + return result diff --git a/tests/test_gui.py b/tests/test_gui.py index 670ef21..7382e20 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -235,13 +235,13 @@ def test_store_action_matches_the_selected_game_source(tmp_path: Path) -> None: paths.create() app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) window = Window(paths) - rift = Game("rift", "Rift Game", "123", "rift.game", "/tmp", "game.exe", []) + rift = Game("rift", "Rift Game", "123", "rift.game", str(tmp_path), "game.exe", []) steam = Game( "steam", "Steam Game", "456", "steam.app.456", - "/tmp", + str(tmp_path), "game.exe", [], store_url="https://store.steampowered.com/app/456/", @@ -258,7 +258,7 @@ def test_store_action_matches_the_selected_game_source(tmp_path: Path) -> None: "Local Game", "", "local.local-game", - "/tmp", + str(tmp_path), "game.exe", [], source="local", @@ -284,7 +284,7 @@ def test_selected_game_shows_local_playtime(tmp_path: Path) -> None: add_playtime(paths, "echo", 7380) app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) window = Window(paths) - game = Game("echo", "Echo", "", "local.echo", "/tmp", "echo.exe", []) + game = Game("echo", "Echo", "", "local.echo", str(tmp_path), "echo.exe", []) window.show_game(game) diff --git a/tests/test_playtime.py b/tests/test_playtime.py index 89dbd27..500850c 100644 --- a/tests/test_playtime.py +++ b/tests/test_playtime.py @@ -1,3 +1,4 @@ +import os from pathlib import Path from riftlift.config import Paths @@ -34,7 +35,8 @@ def test_playtime_accumulates_across_launches(tmp_path: Path) -> None: assert tracked.seconds == 3665.5 assert tracked.launches == 2 assert tracked.last_played_at == "2026-08-11T13:00:00+00:00" - assert (paths.data / "playtime.json").stat().st_mode & 0o777 == 0o600 + if os.name != "nt": + assert (paths.data / "playtime.json").stat().st_mode & 0o777 == 0o600 def test_session_records_exact_final_interval(tmp_path: Path) -> None: diff --git a/tests/test_windows_native.py b/tests/test_windows_native.py index 6eb2667..1f1345c 100644 --- a/tests/test_windows_native.py +++ b/tests/test_windows_native.py @@ -104,11 +104,41 @@ def test_gui_constructs_without_linux_imports(paths, monkeypatch): monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication - from riftlift.windows_gui import Window + from riftlift.main_window import Window app = QApplication.instance() or QApplication([]) window = Window() - assert "Windows" in window.windowTitle() + assert window.windowTitle() == "RiftLift" + assert Window.__module__ == "riftlift.main_window" + assert not window.signin.isEnabled() + assert not window.steam_games.isEnabled() assert window.library.count() == 0 window.close() app.processEvents() + + +def test_shared_ui_backend_reports_failures(paths, monkeypatch): + from riftlift import windows_ui_backend + + monkeypatch.setattr(windows, "doctor", lambda p: ("Missing runtime", 2)) + with pytest.raises(RiftLiftError, match="View Activity"): + windows_ui_backend.doctor(paths) + game = windows.add_local(paths, sys.executable, "Probe") + monkeypatch.setattr(windows, "runtime_ready", lambda b: True) + monkeypatch.setattr(windows, "launch", lambda *a, **k: 7) + with pytest.raises(RiftLiftError, match="code 7"): + windows_ui_backend.launch(paths, game, []) + + +def test_windows_playtime_locks_across_processes(paths): + import subprocess + + from riftlift.playtime import playtime + + script = ( + "from riftlift.config import Paths; from riftlift.playtime import mark_launch; " + "[mark_launch(Paths.defaults(), 'probe') for _ in range(5)]" + ) + children = [subprocess.Popen([sys.executable, "-c", script]) for _ in range(3)] + assert all(child.wait(timeout=30) == 0 for child in children) + assert playtime(paths, "probe").launches == 15 From 40bec22115df1049adc7772915cc041c9c51fd3e Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:11:19 -0400 Subject: [PATCH 06/12] Integrate Windows into product metadata and remove separate experimental branding --- .github/workflows/windows-host.yml | 2 +- README.md | 11 ++-- WINDOWS.md | 84 ------------------------------ pyproject.toml | 5 +- src/riftlift/windows.py | 13 ++--- tests/test_windows_native.py | 17 ++++++ 6 files changed, 34 insertions(+), 98 deletions(-) delete mode 100644 WINDOWS.md diff --git a/.github/workflows/windows-host.yml b/.github/workflows/windows-host.yml index 4baca39..2f224e8 100644 --- a/.github/workflows/windows-host.yml +++ b/.github/workflows/windows-host.yml @@ -1,4 +1,4 @@ -name: Experimental Windows host +name: Windows on: push: pull_request: diff --git a/README.md b/README.md index fbf76aa..e3c7e15 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # RiftLift -**Play your Meta Rift (Oculus Rift) PC VR games on Linux.** +**Play your Meta Rift (Oculus Rift) PC VR games on Linux and Windows.** -RiftLift is a Linux compatibility app for Meta Rift PC VR games. Its desktop +RiftLift is a compatibility app for Meta Rift PC VR games. Its desktop GUI handles Meta sign-in, owned-game downloads, Steam shortcuts, local playtime, and launching through your existing VR headset setup. It supports Rift Store releases and compatible Steam games that include an Oculus mode. RiftLift can @@ -19,7 +19,7 @@ See the [compatibility wiki](docs/COMPATIBILITY.md) for games tested with real V Before you start, you need: -- a 64-bit Linux PC; +- a 64-bit Linux or Windows PC; - Steam; - a VR headset that already works through SteamVR or Monado/OpenXR; and - a Meta account that owns a **Rift / PC VR** game. @@ -43,6 +43,11 @@ Source installs use the checkout and selected payloads directly; release artifact hashes are enforced by the generated all-in-one installer, not baked into the application source. +On Windows, run `./install-windows.ps1` from a source checkout with Python 3.12 +and Git installed, then open `RiftLift.cmd`. The same desktop app uses your +native Windows OpenXR or SteamVR runtime. Install games with Meta's PC app, +then choose **Add Game → Add a local game…** to add them to RiftLift. + ### 2. Check your setup and sign in Open **RiftLift** and click **System** to verify the setup. diff --git a/WINDOWS.md b/WINDOWS.md deleted file mode 100644 index a8553cf..0000000 --- a/WINDOWS.md +++ /dev/null @@ -1,84 +0,0 @@ -# Experimental native Windows host - -This branch adds a native Windows entry point to RiftLift. It is **not yet a -verified Windows game-compatibility release**. The existing Linux host remains -the default on Linux. The native host reuses the upstream x64 PE launcher and -OpenXR/OpenVR backends from v0.10.2.2 without Wine, Proton, DXVK, or xrizer. - -## Install and launch - -Requirements: x64 Windows, Python 3.12, Git, a working native Windows OpenXR or -OpenVR headset runtime, and installed PC game files. - -```powershell -.\install-windows.ps1 -.\RiftLift.cmd -.\RiftLift.cmd doctor --no-paste -.\RiftLift.cmd add-local "C:\Games\Example\Game.exe" --name "Example" -.\RiftLift.cmd launch example --backend openxr --dry-run -.\RiftLift.cmd launch example --backend openxr -``` - -For SteamVR use `--backend openvr`. The selected runtime must already be installed -and configured; this installer does not change the system's active XR runtime. -Run games through their normal client once first to finish prerequisites and -account setup. The existing Linux GUI is reused unchanged in layout and styling: `main_window.py`, -`game_ui.py`, `auth_ui.py`, and `theme.py`. No separate Windows UI exists. -A service adapter connects System and Launch in VR to the native backend. -Set `RIFTLIFT_WINDOWS_BACKEND=openvr` or `openxr` to override automatic backend -selection. The existing background-task and activity-log implementation is reused. - -## Current boundaries - -- Native Windows sign-in, store downloading and Steam shortcut management - are not implemented in this experimental host. Their existing controls remain - visible but are disabled on Windows; Add Game retains the existing local-game - dialog. Shared playtime tracking uses Windows file locking. -- Install purchases with Meta's PC application before adding their executables. - Original platform authentication and entitlement checks remain in place. -- Game folders are referenced in place. No game executable or platform DLL is - replaced. The Linux compatibility platform shim is deliberately not installed. -- Only x64 executables are accepted. OpenXR registration and file checks are - preflight checks, not proof of a working headset session. -- `doctor` never uploads a paste. Exit 2 means the native payload/runtime - preconditions are incomplete; exit 0 still does not certify gameplay. -- Application state is under `portable/` with `RiftLift.cmd`; direct CLI use - defaults to `%LOCALAPPDATA%\RiftLift`. Set `RIFTLIFT_HOME` to override. -- Launch logs are under `portable/data/logs/`; the native launcher also writes - `%LOCALAPPDATA%\RiftLift\RiftLiftLauncher.txt`. - -## Game test matrix - -| Game | Windows result on initial host | -| --- | --- | -| Lone Echo | Pending installed PC game and configured headset | -| Stormland | Pending installed PC game and configured headset | -| Vader Immortal | Pending installed PC game, episode selection, and headset | -| Oculus First Contact | Pending installed PC game and configured headset | -| Echo VR | Pending community installation/account linking and headset | - -Echo VR community installation is separate from RiftLift. Start with the -[Echo VR Lounge community](https://discord.com/servers/echo-vr-lounge-779349159852769310) -and its current PC patching instructions; multiplayer account linking requires -the player's participation. Do not substitute a Quest APK for the Windows game. - -For each title, verify launch, both-eye rendering, tracking, controller mapping, -audio, menu interaction, and gameplay. Record native backend, headset connection, -game build, exit code, and logs. Injection success alone is not a game pass. - -## Tests and rollback - -```powershell -.\.venv\Scripts\python.exe -m pip install pytest -.\.venv\Scripts\python.exe -m pytest tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py -q -``` - -The Linux-focused full suite includes POSIX paths, permissions, and `fcntl` and -is not a Windows acceptance suite. Existing Linux CI continues to cover that -host; the added Windows job covers the native frontend and shared utilities. - -Close games and RiftLift before undoing the installation. The installation is -local to this checkout (`.venv/` and `portable/`); it does not register services, -change XR registry keys, replace game files, or alter another VR installation. -Keep `portable/` if you want to preserve the local library. Revert the branch's -commits to restore upstream source behavior. diff --git a/pyproject.toml b/pyproject.toml index c9077d5..cee45db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,17 @@ build-backend = "setuptools.build_meta" [project] name = "riftlift" dynamic = ["version"] -description = "Plug-and-play Meta Rift games for working Linux OpenXR runtimes" +description = "Meta Rift games for OpenXR runtimes on Linux and Windows" readme = "README.md" requires-python = ">=3.10.12" license = "GPL-3.0-or-later" authors = [{name = "Villagers654"}] -keywords = ["linux", "meta", "monado", "oculus", "openxr", "rift", "vr"] +keywords = ["linux", "windows", "meta", "monado", "oculus", "openxr", "rift", "vr"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Console", "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", "Topic :: Games/Entertainment", ] diff --git a/src/riftlift/windows.py b/src/riftlift/windows.py index 7d75b64..8c0e706 100644 --- a/src/riftlift/windows.py +++ b/src/riftlift/windows.py @@ -1,8 +1,4 @@ -"""Experimental native Windows host; no Wine, Proton, or platform shim. - -The upstream native PE backends are used unchanged. A loaded DLL or successful -injection is not proof of headset rendering or game compatibility. -""" +"""Native Windows backend for RiftLift's shared CLI and desktop application.""" from __future__ import annotations @@ -113,7 +109,7 @@ def doctor(paths: Paths) -> tuple[str, int]: installed = games(paths) text = "\n".join( [ - f"RiftLift {__version__}: experimental native Windows host", + f"RiftLift {__version__} on Windows", f"Native payload: {'INSTALLED' if payload_ok else 'MISSING'} ({native})", f"OpenXR manifest: {xr or 'NOT REGISTERED'}", f"OpenVR runtime: {vr or 'NOT REGISTERED'}", @@ -215,12 +211,13 @@ def launch( def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( - prog="riftlift", description="Experimental native Windows RiftLift host" + prog="riftlift", + description="Run Meta Rift games through native Windows VR runtimes.", ) p.add_argument( "--version", action="version", - version=f"%(prog)s {__version__} (Windows experimental)", + version=f"%(prog)s {__version__}", ) sub = p.add_subparsers(dest="command", required=True) sub.add_parser("gui", help="open the Windows library") diff --git a/tests/test_windows_native.py b/tests/test_windows_native.py index 1f1345c..14e7833 100644 --- a/tests/test_windows_native.py +++ b/tests/test_windows_native.py @@ -40,6 +40,23 @@ def test_cli_help_has_native_backend(capsys): assert "native Windows" in capsys.readouterr().out +def test_windows_uses_the_shared_product_version(capsys): + from riftlift import __version__ + from riftlift.cli import main + + with pytest.raises(SystemExit) as result: + main(["--version"]) + assert result.value.code == 0 + assert capsys.readouterr().out == f"riftlift {__version__}\n" + + +def test_windows_metadata_is_part_of_the_main_package(): + root = Path(__file__).resolve().parents[1] + metadata = (root / "pyproject.toml").read_text() + assert '"Operating System :: Microsoft :: Windows"' in metadata + assert not (root / "WINDOWS.md").exists() + + def test_add_local_preserves_game_binary(paths): executable = Path(sys.executable) before = executable.read_bytes() From bd891d4b054161df8a61bff5e31cae973cb2707b Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:56:37 -0400 Subject: [PATCH 07/12] Fix native VR bridges and add simulated-headset diagnostics --- runtime/CMakeLists.txt | 109 ++++++++++++++++++++++ runtime/FrameCapture.h | 82 ++++++++++++++++ runtime/PlatformCompat.h | 22 +++++ runtime/tests/boundary-buffer.cpp | 38 ++++++++ runtime/tests/compositor-probe.cpp | 86 +++++++++++++++++ runtime/tests/platform-load.cpp | 20 ++++ runtime/tests/simulated-input.cpp | 44 +++++++++ runtime/windows-openvr/InputManager.cpp | 1 - runtime/windows-openvr/REV_CAPI.cpp | 118 ++++++++++++++++++++++-- runtime/windows-openvr/TextureD3D.cpp | 4 + runtime/windows-openvr/main.cpp | 16 +++- runtime/windows-openxr/Runtime.cpp | 12 ++- runtime/windows-openxr/Swapchain.cpp | 18 +++- runtime/windows-openxr/main.cpp | 16 +++- scripts/platform-exports.py | 29 ++++++ tests/test_platform_exports.py | 35 +++++++ 16 files changed, 632 insertions(+), 18 deletions(-) create mode 100644 runtime/CMakeLists.txt create mode 100644 runtime/FrameCapture.h create mode 100644 runtime/PlatformCompat.h create mode 100644 runtime/tests/boundary-buffer.cpp create mode 100644 runtime/tests/compositor-probe.cpp create mode 100644 runtime/tests/platform-load.cpp create mode 100644 runtime/tests/simulated-input.cpp create mode 100644 scripts/platform-exports.py create mode 100644 tests/test_platform_exports.py diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt new file mode 100644 index 0000000..50566ef --- /dev/null +++ b/runtime/CMakeLists.txt @@ -0,0 +1,109 @@ +cmake_minimum_required(VERSION 3.24) +project(RiftLiftNative LANGUAGES C CXX) +if(NOT MSVC OR NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR "RiftLift native requires a Windows x64 MSVC toolchain") +endif() +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_MSVC_RUNTIME_LIBRARY MultiThreaded) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") +file(MAKE_DIRECTORY "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") +configure_file(LICENSE "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/LICENSE" COPYONLY) +configure_file(../LICENSE "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/RIFTLIFT-LICENSE" COPYONLY) +file(COPY windows-openvr/Input DESTINATION "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}") +set(DETOURS_SOURCE_DIR "" CACHE PATH "Microsoft Detours v4.0.1 source") +set(OPENXR_SDK_SOURCE_DIR "" CACHE PATH "Khronos OpenXR SDK source") +if(NOT EXISTS "${DETOURS_SOURCE_DIR}/src/detours.cpp" OR NOT EXISTS "${OPENXR_SDK_SOURCE_DIR}/CMakeLists.txt") + message(FATAL_ERROR "Set DETOURS_SOURCE_DIR and OPENXR_SDK_SOURCE_DIR") +endif() + +include(CTest) +set(ext "${CMAKE_CURRENT_SOURCE_DIR}/Externals") +if(BUILD_TESTING) + add_executable(simulated-input-test tests/simulated-input.cpp) + target_include_directories(simulated-input-test PRIVATE "${ext}/LibOVR/Include") + target_link_libraries(simulated-input-test PRIVATE RiftLiftOpenVR64) + add_test(NAME simulated-input COMMAND simulated-input-test) + set_tests_properties(simulated-input PROPERTIES SKIP_RETURN_CODE 77 ENVIRONMENT "RIFTLIFT_SIMULATOR=1;RIFTLIFT_SIMULATED_CONTROLLERS=1") + add_executable(compositor-probe tests/compositor-probe.cpp) + target_link_libraries(compositor-probe PRIVATE openvr_api64 d3d11 dxgi) + add_executable(boundary-buffer-test tests/boundary-buffer.cpp) + target_include_directories(boundary-buffer-test PRIVATE "${ext}/LibOVR/Include") + target_link_libraries(boundary-buffer-test PRIVATE RiftLiftOpenVR64 openvr_api64) + add_test(NAME boundary-buffer COMMAND boundary-buffer-test) + set_tests_properties(boundary-buffer PROPERTIES SKIP_RETURN_CODE 77) +endif() +set(DYNAMIC_LOADER OFF CACHE BOOL "" FORCE) +set(BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(BUILD_API_LAYERS OFF CACHE BOOL "" FORCE) +add_subdirectory("${OPENXR_SDK_SOURCE_DIR}" openxr) +set_property(TARGET openxr_loader PROPERTY MSVC_RUNTIME_LIBRARY MultiThreaded) +set(detours_sources detours modules disasm image creatwth disolx86 disolx64 disolia64 disolarm disolarm64) +list(TRANSFORM detours_sources PREPEND "${DETOURS_SOURCE_DIR}/src/") +list(TRANSFORM detours_sources APPEND ".cpp") +add_library(detours STATIC ${detours_sources}) +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/include/detours") +configure_file("${DETOURS_SOURCE_DIR}/src/detours.h" "${CMAKE_BINARY_DIR}/include/detours/detours.h" COPYONLY) +configure_file("${DETOURS_SOURCE_DIR}/src/detver.h" "${CMAKE_BINARY_DIR}/include/detours/detver.h" COPYONLY) +target_include_directories(detours PUBLIC "${CMAKE_BINARY_DIR}/include") +set(ovr_util "${ext}/LibOVR/Shim/OVR_CAPI_Util.cpp" "${ext}/LibOVR/Shim/OVR_StereoProjection.cpp") +set(vrcore dirtools_public envvartools_public hmderrors_public pathtools_public sharedlibtools_public strtools_public vrpathregistry_public) +list(TRANSFORM vrcore PREPEND "${ext}/openvr/src/vrcore/") +list(TRANSFORM vrcore APPEND ".cpp") +add_library(openvr_api64 SHARED ${vrcore} "${ext}/openvr/src/jsoncpp.cpp" "${ext}/openvr/src/openvr_api_public.cpp" "${ext}/openvr_dllmain.cpp" "${ext}/openvr_api.def") +target_include_directories(openvr_api64 PUBLIC "${ext}/openvr/headers" PRIVATE "${ext}/openvr/src" "${ext}/openvr/src/vrcore") +target_compile_definitions(openvr_api64 PRIVATE WIN64 VRCORE_NO_PLATFORM VR_API_PUBLIC _CRT_SECURE_NO_WARNINGS _CRT_NONSTDC_NO_DEPRECATE) +target_link_libraries(openvr_api64 PRIVATE detours shell32 advapi32) +foreach(backend openxr openvr) + file(GLOB sources CONFIGURE_DEPENDS "windows-${backend}/*.cpp") + set(target "RiftLift${backend}64") + if(backend STREQUAL "openxr") + set(target RiftLiftOpenXR64) + set(exports windows-openxr/RiftLiftOpenXR.def) + else() + set(target RiftLiftOpenVR64) + set(exports windows-openvr/RiftLiftOpenVR.def) + endif() + add_library(${target} SHARED ${sources} ${ovr_util} "${ext}/glad/src/glad.c" ${exports}) + target_include_directories(${target} PRIVATE "${ext}/microprofile" "${ext}/LibOVR/Include" "${ext}/glad/include" "${ext}/Vulkan/include" "${CMAKE_BINARY_DIR}/include") + target_compile_definitions(${target} PRIVATE UNICODE _UNICODE NOMINMAX OVR_DLL_BUILD MICROPROFILE_ENABLED=0 MICROPROFILE_GPU_TIMERS=0 XR_USE_PLATFORM_WIN32 VK_NO_PROTOTYPES VK_USE_PLATFORM_WIN32_KHR _CRT_SECURE_NO_WARNINGS) + target_link_libraries(${target} PRIVATE detours ws2_32 opengl32 d3d11 dxgi dxguid dsound winmm shlwapi pathcch) + if(backend STREQUAL "openxr") + target_link_libraries(${target} PRIVATE openxr_loader) + else() + target_sources(${target} PRIVATE "${ext}/glad/src/glad_wgl.c") + target_link_libraries(${target} PRIVATE openvr_api64) + find_program(FXC_EXECUTABLE fxc REQUIRED) + foreach(shader VertexShader MirrorShader CompositorShader) + set(profile ps_4_0) + if(shader STREQUAL "VertexShader") + set(profile vs_4_0) + endif() + set(header "${CMAKE_BINARY_DIR}/include/${shader}.hlsl.h") + add_custom_command(OUTPUT "${header}" COMMAND "${FXC_EXECUTABLE}" /nologo /T ${profile} /E main /Fh "${header}" /Vn "g_${shader}" "${CMAKE_CURRENT_SOURCE_DIR}/windows-openvr/${shader}.hlsl" DEPENDS "windows-openvr/${shader}.hlsl" VERBATIM) + target_sources(${target} PRIVATE "${header}") + endforeach() + endif() +endforeach() +add_executable(RiftLiftLauncher windows-launcher/main.cpp) +target_compile_definitions(RiftLiftLauncher PRIVATE UNICODE _UNICODE NOMINMAX _CRT_SECURE_NO_WARNINGS) +target_link_libraries(RiftLiftLauncher PRIVATE detours openvr_api64 shlwapi shell32) + +set(META_PLATFORM_IMPL "" CACHE FILEPATH "Original platform implementation DLL for export forwarding") +if(META_PLATFORM_IMPL) + find_package(Python3 REQUIRED COMPONENTS Interpreter) + set(platform_def "${CMAKE_BINARY_DIR}/platform-forwarders.c") + execute_process(COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/platform-exports.py" "${META_PLATFORM_IMPL}" "${CMAKE_CURRENT_SOURCE_DIR}/../compat/riftlift-platform-shim.c" "${platform_def}" COMMAND_ERROR_IS_FATAL ANY) + add_library(LibOVRPlatformImpl64_1 SHARED ../compat/riftlift-platform-shim.c "${platform_def}") + set_property(TARGET LibOVRPlatformImpl64_1 PROPERTY C_STANDARD 11) + target_compile_definitions(LibOVRPlatformImpl64_1 PRIVATE _CRT_SECURE_NO_WARNINGS) + get_filename_component(meta_runtime_dir "${META_PLATFORM_IMPL}" DIRECTORY) + foreach(dependency LibOVRPlatform64_1.dll LibOVRP2P64_1.dll LibOVRRT64_1.dll) + configure_file("${meta_runtime_dir}/${dependency}" "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/${dependency}" COPYONLY) + endforeach() + configure_file("${META_PLATFORM_IMPL}" "${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/LibOVRPlatformImpl64_1_real.dll" COPYONLY) + if(BUILD_TESTING) + add_executable(platform-load-test tests/platform-load.cpp) + add_test(NAME platform-load COMMAND RiftLiftLauncher /openvr /wait "$") + set_tests_properties(platform-load PROPERTIES ENVIRONMENT "RIFTLIFT_PLATFORM_DLL=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/LibOVRPlatformImpl64_1.dll;RIFTLIFT_PLATFORM_OFFLINE=1") + endif() +endif() diff --git a/runtime/FrameCapture.h b/runtime/FrameCapture.h new file mode 100644 index 0000000..8838968 --- /dev/null +++ b/runtime/FrameCapture.h @@ -0,0 +1,82 @@ +#pragma once + +// Opt-in diagnostics of application-owned render textures, before compositor +// submission. Never reads a desktop/window surface. At most six frames per process. +#include +#include +#include +#include +#include +#include +#include +#include + +inline void CaptureApplicationFrame(ID3D11Texture2D* texture, ID3D11On12Device* interop = nullptr) +{ + static const char* directory = std::getenv("RIFTLIFT_CAPTURE_FRAMES_DIR"); + if (!directory || !*directory || !texture) return; + D3D11_TEXTURE2D_DESC desc; + texture->GetDesc(&desc); + const bool bgra = desc.Format == DXGI_FORMAT_B8G8R8A8_TYPELESS || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; + const bool rgba = desc.Format == DXGI_FORMAT_R8G8B8A8_TYPELESS || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; + const bool half = desc.Format == DXGI_FORMAT_R16G16B16A16_TYPELESS || desc.Format == DXGI_FORMAT_R16G16B16A16_FLOAT; + if (!(bgra || rgba || half) || desc.SampleDesc.Count != 1 || desc.Width < 64 || desc.Height < 64) return; + static std::mutex lock; + static unsigned count = 0; + static ULONGLONG previous = 0; + static const ULONGLONG started = GetTickCount64(); + static const unsigned delay = [] { + const char* value = std::getenv("RIFTLIFT_CAPTURE_DELAY_SECONDS"); + return value ? static_cast(std::atoi(value)) : 0U; + }(); + std::lock_guard guard(lock); + const auto now = GetTickCount64(); + if (now - started < static_cast(delay) * 1000 || count >= 6 || now - previous < 5000) return; + previous = now; + ++count; + Microsoft::WRL::ComPtr device; + Microsoft::WRL::ComPtr context; + Microsoft::WRL::ComPtr staging; + texture->GetDevice(&device); + device->GetImmediateContext(&context); + desc.MipLevels = desc.ArraySize = 1; + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = desc.MiscFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + if (FAILED(device->CreateTexture2D(&desc, nullptr, &staging))) return; + ID3D11Resource* wrapped = texture; + if (interop) interop->AcquireWrappedResources(&wrapped, 1); + const auto release = [&] { + if (interop) { interop->ReleaseWrappedResources(&wrapped, 1); context->Flush(); } + }; + context->CopySubresourceRegion(staging.Get(), 0, 0, 0, 0, texture, 0, nullptr); + D3D11_MAPPED_SUBRESOURCE mapped = {}; + if (FAILED(context->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &mapped))) { release(); return; } + char path[MAX_PATH]; + FILE* output = nullptr; + if (sprintf_s(path, "%s/application-%lu-%u.ppm", directory, GetCurrentProcessId(), count) > 0 && + fopen_s(&output, path, "wb") == 0) { + std::fprintf(output, "P6\n# format=%u; HDR uses Reinhard tone mapping and gamma 2.2\n%u %u\n255\n", desc.Format, desc.Width, desc.Height); + std::vector line(static_cast(desc.Width) * 3); + for (UINT y = 0; y < desc.Height; ++y) { + const auto row = static_cast(mapped.pData) + y * mapped.RowPitch; + for (UINT x = 0; x < desc.Width; ++x) { + for (UINT c = 0; c < 3; ++c) { + if (half) { + const auto value = reinterpret_cast(row)[x*4+c]; + const unsigned exponent = (value >> 10) & 31, fraction = value & 1023; + float linear = exponent ? std::ldexp(1.0f + fraction / 1024.0f, static_cast(exponent)-15) : std::ldexp(static_cast(fraction), -24); + if ((value & 0x8000) || exponent == 31) linear = 0; + line[x*3+c] = static_cast(255 * std::pow(linear/(1+linear), 1.0f/2.2f)); + } else { + line[x*3+c] = row[x*4+(bgra ? 2-c : c)]; + } + } + } + std::fwrite(line.data(), 1, line.size(), output); + } + std::fclose(output); + } + context->Unmap(staging.Get(), 0); + release(); +} diff --git a/runtime/PlatformCompat.h b/runtime/PlatformCompat.h new file mode 100644 index 0000000..9e2849b --- /dev/null +++ b/runtime/PlatformCompat.h @@ -0,0 +1,22 @@ +#pragma once + +// Launch-scoped platform compatibility; never changes a machine-wide runtime. +inline const char* PlatformRedirect(LPCSTR name) +{ + if (!name) + return nullptr; + const char* base = PathFindFileNameA(name); + if (_stricmp(base, "LibOVRPlatformImpl64_1.dll") != 0) + return nullptr; + return getenv("RIFTLIFT_PLATFORM_DLL"); +} + +inline const char* PlatformRedirect(LPCWSTR name) +{ + if (!name) + return nullptr; + const wchar_t* base = PathFindFileNameW(name); + if (_wcsicmp(base, L"LibOVRPlatformImpl64_1.dll") != 0) + return nullptr; + return getenv("RIFTLIFT_PLATFORM_DLL"); +} diff --git a/runtime/tests/boundary-buffer.cpp b/runtime/tests/boundary-buffer.cpp new file mode 100644 index 0000000..c94ef5e --- /dev/null +++ b/runtime/tests/boundary-buffer.cpp @@ -0,0 +1,38 @@ +#include +#include +#include +#include +#include + +int main() +{ + vr::EVRInitError error; + vr::VR_Init(&error, vr::VRApplication_Background); + if (error != vr::VRInitError_None) { + std::printf("SKIP: an active OpenVR runtime is needed (%d)\n", error); + return 77; + } + int failures = 0; + const auto check = [&failures](bool ok, const char* message) { + if (!ok) { std::printf("FAIL: %s\n", message); ++failures; } + }; + check(ovr_GetBoundaryGeometry(nullptr, ovrBoundary_PlayArea, nullptr, nullptr) + == ovrError_InvalidParameter, "null count must be rejected"); + int count = -1; + const auto query = ovr_GetBoundaryGeometry(nullptr, ovrBoundary_PlayArea, nullptr, &count); + check(OVR_SUCCESS(query), "size query must ignore the input count"); + check(count == 0 || count == 4, "boundary contains zero or four corners"); + std::array guard; + std::memset(guard.data(), 0x5A, sizeof(guard)); + const auto before = guard; + int capacity = 0; + const auto result = ovr_GetBoundaryGeometry(nullptr, ovrBoundary_PlayArea, guard.data(), &capacity); + check(std::memcmp(guard.data(), before.data(), sizeof(guard)) == 0, + "a zero-capacity buffer must never be written"); + check(count ? result == ovrError_InsufficientArraySize : result == ovrSuccess_BoundaryInvalid, + "return the correct capacity/boundary result"); + check(capacity == count, "report the required point count"); + vr::VR_Shutdown(); + std::printf("Boundary buffer checks: %s\n", failures ? "FAILED" : "PASSED"); + return failures ? 1 : 0; +} diff --git a/runtime/tests/compositor-probe.cpp b/runtime/tests/compositor-probe.cpp new file mode 100644 index 0000000..9c2eb46 --- /dev/null +++ b/runtime/tests/compositor-probe.cpp @@ -0,0 +1,86 @@ +// Read the VR compositor's eye texture, without creating or capturing a desktop window. +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +int main(int argc, char** argv) +{ + vr::EVRInitError error; + auto system = vr::VR_Init(&error, vr::VRApplication_Background); + if (error != vr::VRInitError_None) { + std::printf("OpenVR initialization error: %d\n", error); + return 2; + } + auto compositor = vr::VRCompositor(); + vr::Compositor_CumulativeStats stats = {}; + compositor->GetCumulativeStats(&stats, sizeof(stats)); + std::printf("scene_pid=%u renderer_pid=%u stats_pid=%u presents=%u dropped=%u reprojected=%u\n", + compositor->GetCurrentSceneFocusProcess(), compositor->GetLastFrameRenderer(), + stats.m_nPid, stats.m_nNumFramePresents, stats.m_nNumDroppedFrames, stats.m_nNumReprojectedFrames); + vr::TrackedDevicePose_t poses[vr::k_unMaxTrackedDeviceCount] = {}; + system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, poses, vr::k_unMaxTrackedDeviceCount); + std::printf("hmd_connected=%d pose_valid=%d position=%.3f,%.3f,%.3f\n", + poses[0].bDeviceIsConnected, poses[0].bPoseIsValid, + poses[0].mDeviceToAbsoluteTracking.m[0][3], poses[0].mDeviceToAbsoluteTracking.m[1][3], poses[0].mDeviceToAbsoluteTracking.m[2][3]); + if (argc < 2) { vr::VR_Shutdown(); return 0; } + int32_t adapterIndex = 0; + system->GetDXGIOutputInfo(&adapterIndex); + ComPtr factory; + ComPtr adapter; + ComPtr device; + ComPtr context; + HRESULT hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory)); + if (SUCCEEDED(hr)) hr = factory->EnumAdapters1(adapterIndex, &adapter); + if (SUCCEEDED(hr)) hr = D3D11CreateDevice(adapter.Get(), D3D_DRIVER_TYPE_UNKNOWN, nullptr, 0, + nullptr, 0, D3D11_SDK_VERSION, &device, nullptr, &context); + if (FAILED(hr)) { std::printf("D3D device error: %08X\n", (unsigned)hr); vr::VR_Shutdown(); return 3; } + ID3D11ShaderResourceView* mirror = nullptr; + const auto result = compositor->GetMirrorTextureD3D11(vr::Eye_Left, device.Get(), reinterpret_cast(&mirror)); + if (result != vr::VRCompositorError_None || !mirror) { + std::printf("Eye texture unavailable: %d\n", result); vr::VR_Shutdown(); return 4; + } + ComPtr resource; + ComPtr texture; + mirror->GetResource(&resource); + hr = resource.As(&texture); + D3D11_TEXTURE2D_DESC desc = {}; + if (SUCCEEDED(hr)) texture->GetDesc(&desc); + std::printf("eye_width=%u eye_height=%u format=%u\n", desc.Width, desc.Height, desc.Format); + const bool bgra = desc.Format == DXGI_FORMAT_B8G8R8A8_TYPELESS || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM || desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; + const bool rgba = desc.Format == DXGI_FORMAT_R8G8B8A8_TYPELESS || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM || desc.Format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB; + int status = 5; + if (bgra || rgba) { + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = desc.MiscFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + ComPtr staging; + hr = device->CreateTexture2D(&desc, nullptr, &staging); + if (SUCCEEDED(hr)) { + context->CopyResource(staging.Get(), texture.Get()); + D3D11_MAPPED_SUBRESOURCE mapped = {}; + hr = context->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &mapped); + if (SUCCEEDED(hr)) { + FILE* output = nullptr; + if (fopen_s(&output, argv[1], "wb") == 0) { + std::fprintf(output, "P6\n%u %u\n255\n", desc.Width, desc.Height); + for (UINT y = 0; y < desc.Height; ++y) { + const auto row = static_cast(mapped.pData) + y * mapped.RowPitch; + for (UINT x = 0; x < desc.Width; ++x) { + const unsigned char rgb[] = {row[x*4+(bgra?2:0)], row[x*4+1], row[x*4+(bgra?0:2)]}; + std::fwrite(rgb, 1, 3, output); + } + } + status = std::fclose(output) == 0 ? 0 : 6; + } + context->Unmap(staging.Get(), 0); + } + } + } + compositor->ReleaseMirrorTextureD3D11(mirror); + vr::VR_Shutdown(); + return status; +} diff --git a/runtime/tests/platform-load.cpp b/runtime/tests/platform-load.cpp new file mode 100644 index 0000000..74fcc77 --- /dev/null +++ b/runtime/tests/platform-load.cpp @@ -0,0 +1,20 @@ +#include +#include +#include + +int main() +{ + HMODULE platform = LoadLibraryW(L"LibOVRPlatform64_1.dll"); + if (!platform) { std::printf("Platform load failed: %lu\n", GetLastError()); return 1; } + auto initialize = reinterpret_cast(GetProcAddress(platform, "ovr_PlatformInitializeWindows")); + auto fromString = reinterpret_cast(GetProcAddress(platform, "ovrID_FromString")); + auto initialized = reinterpret_cast(GetProcAddress(platform, "ovr_IsPlatformInitialized")); + if (!initialize || !fromString || !initialized) { std::puts("Required export missing"); return 1; } + uint64_t id = 0; + if (initialize("0") != 0 || !initialized() || !fromString(&id, "42") || id != 42) { + std::puts("Platform forwarding/initialization failed"); return 1; + } + std::puts("Signed platform facade, offline initialization, and utility forwarding: PASSED"); + FreeLibrary(platform); + return 0; +} diff --git a/runtime/tests/simulated-input.cpp b/runtime/tests/simulated-input.cpp new file mode 100644 index 0000000..96834a4 --- /dev/null +++ b/runtime/tests/simulated-input.cpp @@ -0,0 +1,44 @@ +#include +#include +#include +#include +#include + +int main() +{ + if (OVR_FAILURE(ovr_Initialize(nullptr))) return 77; + ovrSession session = nullptr; + ovrGraphicsLuid luid; + if (OVR_FAILURE(ovr_Create(&session, &luid))) { ovr_Shutdown(); return 1; } + int failures = 0; + const auto check = [&](bool ok, const char* message) { + if (!ok) { ++failures; std::printf("FAIL: %s\n", message); } + }; + check((ovr_GetConnectedControllerTypes(session) & ovrControllerType_Touch) == ovrControllerType_Touch, "two simulated hands connected"); + ovrInputState input = {}; + check(OVR_SUCCESS(ovr_GetInputState(session, ovrControllerType_Touch, &input)), "input query succeeds"); + check(input.ControllerType == ovrControllerType_Touch && input.Buttons == 0, "static controllers have no pressed buttons"); + const double now = ovr_GetTimeInSeconds(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + std::printf("Clock advanced %.6f seconds in 100ms\n", ovr_GetTimeInSeconds() - now); + auto state = ovr_GetTrackingState(session, now, false); + for (int attempt = 0; !state.StatusFlags && attempt < 100; ++attempt) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + state = ovr_GetTrackingState(session, 0, false); + } + ovrTrackedDeviceType types[] = {ovrTrackedDevice_LTouch, ovrTrackedDevice_RTouch}; + ovrPoseStatef poses[2] = {}; + check(OVR_SUCCESS(ovr_GetDevicePoses(session, types, 2, now, poses)), "device pose query succeeds without physical controllers"); + for (int hand = 0; hand < 2; ++hand) { + check(state.HandStatusFlags[hand] != 0, "hand tracking flags set"); + check(std::abs(poses[hand].ThePose.Position.x - state.HandPoses[hand].ThePose.Position.x) < 0.001f, "both pose APIs agree"); + } + ovr_SetTrackingOriginType(session, ovrTrackingOrigin_FloorLevel); + const auto standing = ovr_GetTrackingState(session, 0, false); + check(standing.HeadPose.ThePose.Position.y > 1.0f, "simulated standing head is above the floor"); + check(standing.HandPoses[0].ThePose.Position.y > 0.7f, "simulated hands follow standing height"); + ovr_Destroy(session); + ovr_Shutdown(); + std::printf("Simulated input: %s\n", failures ? "FAILED" : "PASSED"); + return failures ? 1 : 0; +} diff --git a/runtime/windows-openvr/InputManager.cpp b/runtime/windows-openvr/InputManager.cpp index 472ea42..a371432 100644 --- a/runtime/windows-openvr/InputManager.cpp +++ b/runtime/windows-openvr/InputManager.cpp @@ -8,7 +8,6 @@ #include #include #include -#include ovrResult InputManager::InputErrorToOvrError(vr::EVRInputError error) { diff --git a/runtime/windows-openvr/REV_CAPI.cpp b/runtime/windows-openvr/REV_CAPI.cpp index 5d41086..4f573c5 100644 --- a/runtime/windows-openvr/REV_CAPI.cpp +++ b/runtime/windows-openvr/REV_CAPI.cpp @@ -19,6 +19,26 @@ #define REV_DEFAULT_TIMEOUT 10000 +static bool SimulatedControllersEnabled() +{ + const char* simulator = getenv("RIFTLIFT_SIMULATOR"); + const char* controllers = getenv("RIFTLIFT_SIMULATED_CONTROLLERS"); + return simulator && strcmp(simulator, "1") == 0 && + controllers && strcmp(controllers, "1") == 0; +} + +static void AddSimulatedHands(ovrTrackingState& state) +{ + for (int hand = 0; hand < ovrHand_Count; ++hand) + { + state.HandPoses[hand] = {}; + state.HandPoses[hand].ThePose = OVR::Posef(state.HeadPose.ThePose) * + OVR::Posef(OVR::Quatf::Identity(), OVR::Vector3f(hand ? 0.25f : -0.25f, -0.25f, -0.45f)); + state.HandPoses[hand].TimeInSeconds = state.HeadPose.TimeInSeconds; + state.HandStatusFlags[hand] = state.StatusFlags; + } +} + HMODULE g_D3D11 = nullptr; unsigned int g_MinorVersion = OVR_MINOR_VERSION; vr::EVRInitError g_InitError = vr::VRInitError_Init_NotInitialized; @@ -109,7 +129,7 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_Initialize(const ovrInitParams* params) MicroProfileSetForceMetaCounters(true); MicroProfileWebServerStart(); - g_MinorVersion = params->RequestedMinorVersion; + g_MinorVersion = params ? params->RequestedMinorVersion : OVR_MINOR_VERSION; DetachDetours(); @@ -117,7 +137,11 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_Initialize(const ovrInitParams* params) AttachDetours(); - uint32_t timeout = params->ConnectionTimeoutMS; + TraceOculusValue("ovr_Initialize.openvrError", g_InitError); + if (g_InitError != vr::VRInitError_None) + return InitErrorToOvrError(g_InitError); + + uint32_t timeout = params ? params->ConnectionTimeoutMS : 0; if (timeout == 0) timeout = REV_DEFAULT_TIMEOUT; @@ -309,6 +333,15 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetSessionStatus(ovrSession session, ovrSessi sessionStatus->IsVisible = (RunningUnderWine() || vr::VRCompositor()->CanRenderScene()) && !first_call; first_call = false; + // The explicitly selected desktop simulator has no proximity sensor. + // Keep its virtual headset mounted so games can exercise the frame loop. + const char* simulator = getenv("RIFTLIFT_SIMULATOR"); + if (simulator && strcmp(simulator, "1") == 0) + { + sessionStatus->HmdMounted = true; + sessionStatus->IsVisible = true; + sessionStatus->HasInputFocus = true; + } static const bool do_sleep = session->UseHack(HACK_SLEEP_IN_SESSION_STATUS); if (do_sleep) @@ -403,6 +436,33 @@ OVR_PUBLIC_FUNCTION(ovrTrackingState) ovr_GetTrackingState(ovrSession session, d return state; session->Input->GetTrackingState(session, &state, absTime); + if (SimulatedControllersEnabled()) + { + // The null headset has no movement; its current pose also serves early + // queries made before the compositor has a predicted-frame history. + if (!state.StatusFlags) + session->Input->GetTrackingState(session, &state, 0); + if (!state.StatusFlags) + { + // SteamVR's null driver can have a raw pose before a seated origin + // exists. Use that pose for the static simulator only. + vr::TrackedDevicePose_t pose = {}; + vr::VRSystem()->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding, 0, &pose, 1); + if (pose.bPoseIsValid && pose.bDeviceIsConnected) + { + const REV::Matrix4f matrix(pose.mDeviceToAbsoluteTracking); + state.HeadPose.ThePose = OVR::Posef(OVR::Quatf(matrix), matrix.GetTranslation()); + state.HeadPose.TimeInSeconds = absTime; + state.StatusFlags = ovrStatus_OrientationValid | ovrStatus_PositionValid | + ovrStatus_OrientationTracked | ovrStatus_PositionTracked; + } + } + // The null driver's origin is at the floor. Eye-level games supply + // their own player height; floor-level games expect it in the pose. + if (vr::VRCompositor()->GetTrackingSpace() == vr::TrackingUniverseStanding) + state.HeadPose.ThePose.Position.y += 1.65f; + AddSimulatedHands(state); + } return state; } @@ -413,6 +473,23 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetDevicePoses(ovrSession session, ovrTracked if (!session) return ovrError_InvalidSession; + if (deviceCount < 0 || (deviceCount > 0 && (!deviceTypes || !outDevicePoses))) + return ovrError_InvalidParameter; + if (SimulatedControllersEnabled()) + { + ovrTrackingState state = ovr_GetTrackingState(session, absTime, false); + for (int i = 0; i < deviceCount; ++i) + { + if (deviceTypes[i] == ovrTrackedDevice_HMD) outDevicePoses[i] = state.HeadPose; + else if (deviceTypes[i] == ovrTrackedDevice_LTouch) outDevicePoses[i] = state.HandPoses[ovrHand_Left]; + else if (deviceTypes[i] == ovrTrackedDevice_RTouch) outDevicePoses[i] = state.HandPoses[ovrHand_Right]; + else { + ovrResult result = session->Input->GetDevicePoses(session, &deviceTypes[i], 1, absTime, &outDevicePoses[i]); + if (OVR_FAILURE(result)) return result; + } + } + return ovrSuccess; + } return session->Input->GetDevicePoses(session, deviceTypes, deviceCount, absTime, outDevicePoses); } @@ -551,6 +628,25 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetInputState(ovrSession session, ovrControll ovrInputState state = { 0 }; ovrResult result = session->Input->GetInputState(session, controllerType, &state); + if (SimulatedControllersEnabled() && (controllerType & ovrControllerType_Touch)) + { + state.ControllerType = static_cast(state.ControllerType | (controllerType & ovrControllerType_Touch)); + state.TimeInSeconds = ovr_GetTimeInSeconds(); + // A test harness may supply button bits through a launch-scoped file. + // This does not synthesize Windows keyboard or mouse input. + if (const char* path = getenv("RIFTLIFT_SIMULATED_INPUT")) + { + FILE* input = nullptr; + if (fopen_s(&input, path, "r") == 0) + { + unsigned buttons = 0; + if (fscanf_s(input, "%u", &buttons) == 1) + state.Buttons |= buttons & (ovrButton_A | ovrButton_B | ovrButton_X | ovrButton_Y | ovrButton_Enter); + fclose(input); + } + } + result = ovrSuccess; + } // We need to make sure we don't write outside of the bounds of the struct // when the client expects a pre-1.7 version of LibOVR. @@ -574,7 +670,7 @@ OVR_PUBLIC_FUNCTION(unsigned int) ovr_GetConnectedControllerTypes(ovrSession ses // XR runtimes may publish interaction profiles after session creation. // Query the current OpenVR roles instead of returning a startup-time cache. session->Input->UpdateConnectedControllers(); - return session->Input->ConnectedControllers; + return session->Input->ConnectedControllers | (SimulatedControllersEnabled() ? ovrControllerType_Touch : 0); } OVR_PUBLIC_FUNCTION(ovrTouchHapticsDesc) ovr_GetTouchHapticsDesc(ovrSession session, ovrControllerType controllerType) @@ -733,13 +829,21 @@ OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetBoundaryGeometry(ovrSession session, ovrBo { REV_TRACE(ovr_GetBoundaryGeometry); - vr::HmdQuad_t playRect; + if (!outFloorPointsCount || (outFloorPoints && *outFloorPointsCount < 0)) + return ovrError_InvalidParameter; + vr::HmdQuad_t playRect = {}; bool valid = vr::VRChaperone()->GetPlayAreaRect(&playRect); + const int capacity = outFloorPoints ? *outFloorPointsCount : 0; + *outFloorPointsCount = valid ? 4 : 0; + // A missing play area reports zero points. Never copy into the caller's + // zero-sized buffer on its second query (the null headset has no bounds). + if (!valid) + return ovrSuccess_BoundaryInvalid; + if (outFloorPoints && capacity < 4) + return ovrError_InsufficientArraySize; if (outFloorPoints) memcpy(outFloorPoints, playRect.vCorners, 4 * sizeof(ovrVector3f)); - if (outFloorPointsCount) - *outFloorPointsCount = valid ? 4 : 0; - return valid ? ovrSuccess : ovrSuccess_BoundaryInvalid; + return ovrSuccess; } OVR_PUBLIC_FUNCTION(ovrResult) ovr_GetBoundaryDimensions(ovrSession session, ovrBoundaryType boundaryType, ovrVector3f* outDimensions) diff --git a/runtime/windows-openvr/TextureD3D.cpp b/runtime/windows-openvr/TextureD3D.cpp index fe70380..b4ae1cd 100644 --- a/runtime/windows-openvr/TextureD3D.cpp +++ b/runtime/windows-openvr/TextureD3D.cpp @@ -1,6 +1,7 @@ #include "TextureD3D.h" #include "Common.h" #include "OVR_CAPI.h" +#include "../FrameCapture.h" #include #include @@ -37,6 +38,8 @@ void TextureD3D::ToVRTexture(vr::Texture_t& texture) if (m_pDevice12) { + if (m_pDevice11on12) + CaptureApplicationFrame(m_pTexture.Get(), m_pDevice11on12.Get()); if (m_pResolveList) m_pQueue->ExecuteCommandLists(1, (ID3D12CommandList**)m_pResolveList.GetAddressOf()); @@ -45,6 +48,7 @@ void TextureD3D::ToVRTexture(vr::Texture_t& texture) } else { + CaptureApplicationFrame(m_pTexture.Get()); texture.eType = vr::TextureType_DirectX; texture.handle = m_pTexture.Get(); } diff --git a/runtime/windows-openvr/main.cpp b/runtime/windows-openvr/main.cpp index 43d75f1..06db557 100644 --- a/runtime/windows-openvr/main.cpp +++ b/runtime/windows-openvr/main.cpp @@ -5,6 +5,8 @@ #include #include #include +#include "../PlatformCompat.h" +#include "Common.h" #include "Extras\OVR_CAPI_Util.h" #include "OVR_Version.h" @@ -146,6 +148,8 @@ bool IsOvrRuntimeName(LPCWSTR lpModuleName) HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -156,6 +160,9 @@ HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpFileName, HANDLE file, DWORD flags) { + if (!(flags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -166,6 +173,8 @@ HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpFileName, HANDLE file, DWORD flags) HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -179,6 +188,9 @@ HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) HMODULE WINAPI HookLoadLibraryExW(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { + if (!(dwFlags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpLibFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpLibFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -228,7 +240,7 @@ void AttachDetours() DetourAttach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourAttach((PVOID*)&TrueOpenEvent, HookOpenEvent); DetourAttach((PVOID*)&TrueDXGIFactory, HookDXGIFactory); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } void DetachDetours() @@ -245,7 +257,7 @@ void DetachDetours() DetourDetach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourDetach((PVOID*)&TrueOpenEvent, HookOpenEvent); DetourDetach((PVOID*)&TrueDXGIFactory, HookDXGIFactory); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } BOOL APIENTRY DllMain(HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) diff --git a/runtime/windows-openxr/Runtime.cpp b/runtime/windows-openxr/Runtime.cpp index d2a4578..0f88e99 100644 --- a/runtime/windows-openxr/Runtime.cpp +++ b/runtime/windows-openxr/Runtime.cpp @@ -21,9 +21,11 @@ const char* Runtime::s_required_extensions[] = { // extension. Requesting the Windows D3D12 or Vulkan extensions at the same // time therefore produces duplicate XR_KHR_vulkan_enable names for runtimes // such as SteamVR, which correctly reject the instance create request. D3D12 -// Oculus clients are selected for the OpenVR bridge by the launcher instead. -// OpenGL remains distinct after Wine's translation and can coexist here. +// Keep that restriction under Wine only; native Windows clients such as Echo VR +// need the D3D12 extension for their command queue and swapchains. const char* Runtime::s_optional_extensions[] = { + "XR_KHR_D3D12_enable", + "XR_KHR_vulkan_enable", "XR_KHR_opengl_enable", XR_MND_HEADLESS_EXTENSION_NAME, XR_KHR_VISIBILITY_MASK_EXTENSION_NAME, @@ -70,12 +72,18 @@ ovrResult Runtime::CreateInstance(XrInstance* out_Instance, const ovrInitParams* CHK_XR(xrEnumerateInstanceExtensionProperties(nullptr, (uint32_t)properties.size(), &size, properties.data())); m_extensions.clear(); + const HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + const bool isWine = ntdll && GetProcAddress(ntdll, "wine_get_version") != nullptr; for (const char* extension : s_required_extensions) m_extensions.push_back(extension); for (const char* extension : s_optional_extensions) { + if (isWine && + (strcmp(extension, "XR_KHR_D3D12_enable") == 0 || + strcmp(extension, "XR_KHR_vulkan_enable") == 0)) + continue; auto findExtension = [extension](XrExtensionProperties props) { return strcmp(props.extensionName, extension) == 0; diff --git a/runtime/windows-openxr/Swapchain.cpp b/runtime/windows-openxr/Swapchain.cpp index a2ad85f..8fb1633 100644 --- a/runtime/windows-openxr/Swapchain.cpp +++ b/runtime/windows-openxr/Swapchain.cpp @@ -60,6 +60,14 @@ ovrResult ovrTextureSwapChainData::Init(XrSession session, const ovrTextureSwapC createInfo.faceCount = desc->Type == ovrTexture_Cube ? desc->ArraySize : 1; createInfo.arraySize = desc->Type == ovrTexture_Cube ? 1 : desc->ArraySize; createInfo.mipCount = desc->MipLevels; + TraceOculusValue("xrCreateSwapchain.ovrFormat", desc->Format); + TraceOculusValue("xrCreateSwapchain.format", format); + TraceOculusValue("xrCreateSwapchain.usageFlags", createInfo.usageFlags); + TraceOculusValue("xrCreateSwapchain.mipCount", createInfo.mipCount); + TraceOculusValue("xrCreateSwapchain.width", createInfo.width); + TraceOculusValue("xrCreateSwapchain.height", createInfo.height); + TraceOculusValue("xrCreateSwapchain.arraySize", createInfo.arraySize); + TraceOculusValue("xrCreateSwapchain.sampleCount", createInfo.sampleCount); CHK_XR(xrCreateSwapchain(session, &createInfo, &Swapchain)); XrSwapchainImageAcquireInfo acqInfo = XR_TYPE(SWAPCHAIN_IMAGE_ACQUIRE_INFO); @@ -103,10 +111,7 @@ DXGI_FORMAT ovrTextureSwapChainData::TextureFormatToDXGIFormat(ovrTextureFormat // Depth formats case OVR_FORMAT_D16_UNORM: return DXGI_FORMAT_D16_UNORM; - // WineOpenXR maps D24S8 to VK_FORMAT_D24_UNORM_S8_UINT, which is not - // universally advertised by Monado's Vulkan device. D32S8 preserves stencil - // and maps to the broadly supported VK_FORMAT_D32_SFLOAT_S8_UINT. - case OVR_FORMAT_D24_UNORM_S8_UINT: return DXGI_FORMAT_D32_FLOAT_S8X24_UINT; + case OVR_FORMAT_D24_UNORM_S8_UINT: return DXGI_FORMAT_D24_UNORM_S8_UINT; case OVR_FORMAT_D32_FLOAT: return DXGI_FORMAT_D32_FLOAT; case OVR_FORMAT_D32_FLOAT_S8X24_UINT: return DXGI_FORMAT_D32_FLOAT_S8X24_UINT; @@ -148,6 +153,11 @@ DXGI_FORMAT ovrTextureSwapChainData::NegotiateFormat(ovrSession session, DXGI_FO { if (session->SupportsFormat(format)) return format; + // Preserve native D24S8 when available; Wine/Monado may require D32S8. + if (format == DXGI_FORMAT_D24_UNORM_S8_UINT && session->SupportsFormat(DXGI_FORMAT_D32_FLOAT_S8X24_UINT)) + return DXGI_FORMAT_D32_FLOAT_S8X24_UINT; + if (format == DXGI_FORMAT_D32_FLOAT_S8X24_UINT && session->SupportsFormat(DXGI_FORMAT_D24_UNORM_S8_UINT)) + return DXGI_FORMAT_D24_UNORM_S8_UINT; // Upgrade R11G11B10F to RGBA16F if it's available if (format == DXGI_FORMAT_R11G11B10_FLOAT && session->SupportsFormat(DXGI_FORMAT_R16G16B16A16_FLOAT)) diff --git a/runtime/windows-openxr/main.cpp b/runtime/windows-openxr/main.cpp index ac63bc8..296d8b0 100644 --- a/runtime/windows-openxr/main.cpp +++ b/runtime/windows-openxr/main.cpp @@ -6,6 +6,8 @@ #include #include #include +#include "../PlatformCompat.h" +#include "Common.h" #include "OVR_CAPI.h" #include "OVR_Version.h" @@ -134,6 +136,8 @@ HANDLE WINAPI HookOpenEvent(DWORD dwDesiredAccess, BOOL bInheritHandle, LPCWSTR HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -147,6 +151,8 @@ HMODULE WINAPI HookLoadLibraryW(LPCWSTR lpFileName) HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) { + if (const char* platform = PlatformRedirect(lpFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -162,6 +168,9 @@ HMODULE WINAPI HookLoadLibraryA(LPCSTR lpFileName) HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { + if (!(dwFlags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpLibFileName)) + return TrueLoadLibraryA(platform); LPCSTR name = PathFindFileNameA(lpLibFileName); LPCSTR ext = PathFindExtensionA(name); size_t length = ext - name; @@ -174,6 +183,9 @@ HMODULE WINAPI HookLoadLibraryExA(LPCSTR lpLibFileName, HANDLE hFile, DWORD dwFl HMODULE WINAPI HookLoadLibraryExW(LPCWSTR lpLibFileName, HANDLE hFile, DWORD dwFlags) { + if (!(dwFlags & (LOAD_LIBRARY_AS_DATAFILE | LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE | LOAD_LIBRARY_AS_IMAGE_RESOURCE))) + if (const char* platform = PlatformRedirect(lpLibFileName)) + return TrueLoadLibraryA(platform); LPCWSTR name = PathFindFileNameW(lpLibFileName); LPCWSTR ext = PathFindExtensionW(name); size_t length = ext - name; @@ -230,7 +242,7 @@ void AttachDetours() DetourAttach((PVOID*)&TrueGetModuleHandleExA, HookGetModuleHandleExA); DetourAttach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourAttach(&(PVOID&)TrueOpenEvent, HookOpenEvent); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } void DetachDetours() @@ -246,7 +258,7 @@ void DetachDetours() DetourDetach((PVOID*)&TrueGetModuleHandleExA, HookGetModuleHandleExA); DetourDetach((PVOID*)&TrueGetModuleHandleExW, HookGetModuleHandleExW); DetourDetach(&(PVOID&)TrueOpenEvent, HookOpenEvent); - DetourTransactionCommit(); + TraceOculusValue("DetourTransactionCommit", DetourTransactionCommit()); } BOOL APIENTRY DllMain(HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) diff --git a/scripts/platform-exports.py b/scripts/platform-exports.py new file mode 100644 index 0000000..71baa8f --- /dev/null +++ b/scripts/platform-exports.py @@ -0,0 +1,29 @@ +"""Generate the native platform forwarders from dumpbin /exports output.""" + +import pathlib +import re +import subprocess +import sys + + +def generate_forwarders(symbols: str, source: str) -> str: + overrides = set(re.findall(r"__cdecl\s+(ovr\w+)\(", source)) + names = re.findall(r"^\s+\d+\s+[\dA-F]+\s+[\dA-F]+\s+(ovr\w+)", symbols, re.M) + if not names: + raise ValueError("No platform exports found") + return ( + "\n".join( + f'#pragma comment(linker, "/export:{name}=LibOVRPlatformImpl64_1_real.{name}")' + for name in names + if name not in overrides + ) + + "\n" + ) + + +if __name__ == "__main__": + original, source, output = map(pathlib.Path, sys.argv[1:4]) + symbols = subprocess.check_output(["dumpbin", "/exports", str(original)], text=True) + data = generate_forwarders(symbols, source.read_text()) + if not output.exists() or output.read_text() != data: + output.write_text(data) diff --git a/tests/test_platform_exports.py b/tests/test_platform_exports.py new file mode 100644 index 0000000..c741aa1 --- /dev/null +++ b/tests/test_platform_exports.py @@ -0,0 +1,35 @@ +"""Native loader regressions: public SDK utility names have no underscore.""" + +import runpy +from pathlib import Path + +import pytest + +generate = runpy.run_path( + str(Path(__file__).parents[1] / "scripts/platform-exports.py") +)["generate_forwarders"] + + +def test_native_forwarders_include_utility_exports_and_preserve_overrides(): + symbols = """ + 1 0 00001000 ovrID_FromString + 2 1 00002000 ovrKeyValuePair_makeString + 3 2 00003000 ovr_PlatformInitializeWindows + 4 3 00004000 ovr_User_GetID +""" + output = generate( + symbols, + "__declspec(dllexport) int __cdecl ovr_PlatformInitializeWindows(const char* id)", + ) + assert "ovrID_FromString=LibOVRPlatformImpl64_1_real.ovrID_FromString" in output + assert ( + "ovrKeyValuePair_makeString=LibOVRPlatformImpl64_1_real.ovrKeyValuePair_makeString" + in output + ) + assert "ovr_User_GetID=LibOVRPlatformImpl64_1_real.ovr_User_GetID" in output + assert "ovr_PlatformInitializeWindows" not in output + + +def test_native_forwarder_generation_rejects_an_empty_export_table(): + with pytest.raises(ValueError, match="No platform exports"): + generate("invalid dumpbin output", "") From 38cb5ee95de9b077487d9e4a671c96954a0479eb Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:56:38 -0400 Subject: [PATCH 08/12] Support Windows browser sign-in and packaged callback routing --- src/riftlift/auth.py | 2 +- src/riftlift/auth_browser.py | 9 ++++++++- src/riftlift/auth_ui.py | 6 +++++- src/riftlift/meta_auth.py | 38 ++++++++++++++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/riftlift/auth.py b/src/riftlift/auth.py index 7620a84..5afbf3f 100644 --- a/src/riftlift/auth.py +++ b/src/riftlift/auth.py @@ -43,7 +43,7 @@ def login(paths: Paths) -> int: complete_browser_login(paths, session) print("RiftLift is signed in to Meta.") return 0 - if process.poll() is not None: + if process is not None and process.poll() is not None: raise RiftLiftError("the browser closed before Meta sign-in finished") time.sleep(1) finally: diff --git a/src/riftlift/auth_browser.py b/src/riftlift/auth_browser.py index 47b3865..4a8fff9 100644 --- a/src/riftlift/auth_browser.py +++ b/src/riftlift/auth_browser.py @@ -10,6 +10,7 @@ import shutil import signal import subprocess +import webbrowser from dataclasses import dataclass from pathlib import Path @@ -183,6 +184,8 @@ def _desktop_browser(desktop_id: str) -> Browser: def default_browser() -> Browser: + if os.name == "nt": + return Browser("windows", "your default browser", "native", ()) override = os.environ.get("RIFTLIFT_AUTH_BROWSER", "").strip().lower() if override: if override.endswith(".desktop"): @@ -265,8 +268,12 @@ def _prepare_chromium_profile(profile: Path) -> None: def launch_browser_login( paths: Paths, browser: Browser, url: str = META_LOGIN_URL -) -> subprocess.Popen[bytes]: +) -> subprocess.Popen[bytes] | None: """Open Meta's hosted login in a RiftLift-owned, isolated browser profile.""" + if browser.family == "native": + if not webbrowser.open(url): + raise RiftLiftError("Could not open the Windows default browser") + return None home = browser_home(paths, browser) home.mkdir(parents=True, exist_ok=True, mode=0o700) home.chmod(0o700) diff --git a/src/riftlift/auth_ui.py b/src/riftlift/auth_ui.py index adae6de..b30cd1f 100644 --- a/src/riftlift/auth_ui.py +++ b/src/riftlift/auth_ui.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from concurrent.futures import Future, ThreadPoolExecutor from PySide6 import QtCore, QtWidgets @@ -39,7 +40,10 @@ def __init__(self, paths: Paths, parent=None): title.setObjectName("game") layout.addWidget(title) explanation = QtWidgets.QLabel( - "RiftLift opens your default browser in a dedicated sign-in window " + "RiftLift opens your default browser and returns here automatically " + "when Meta finishes. Your password and security codes go only to Meta." + if os.name == "nt" + else "RiftLift opens your default browser in a dedicated sign-in window " "and returns here automatically when Meta finishes. Your password " "and security codes go only to Meta." ) diff --git a/src/riftlift/meta_auth.py b/src/riftlift/meta_auth.py index a41c2ef..4da9b94 100644 --- a/src/riftlift/meta_auth.py +++ b/src/riftlift/meta_auth.py @@ -4,9 +4,11 @@ import hashlib import json +import os import secrets import shutil import subprocess +import sys from dataclasses import dataclass from pathlib import Path from urllib.error import HTTPError, URLError @@ -99,8 +101,44 @@ def record_callback(paths: Paths, callback_url: str) -> int: return 0 +def windows_callback_command(paths: Paths) -> tuple[Path, str]: + """Quote the callback for either a bundled app or a source installation.""" + if getattr(sys, "frozen", False): + python = Path(sys.executable) + entry = [str(python)] + else: + python = Path(sys.executable).with_name("pythonw.exe") + if not python.is_file(): + python = Path(sys.executable) + entry = [str(python), "-m", "riftlift.windows"] + command = ( + subprocess.list2cmdline( + [*entry, "--home", str(paths.config.parent), "callback"] + ) + + ' "%1"' + ) + return python, command + + def install_protocol_handler() -> Path: """Register RiftLift as the host handler for Meta's browser callback.""" + if os.name == "nt": + import winreg + + paths = Paths.defaults() + python, command = windows_callback_command(paths) + for scheme in ("oculus", "oculus-client"): + with winreg.CreateKey( + winreg.HKEY_CURRENT_USER, rf"Software\Classes\{scheme}" + ) as key: + winreg.SetValueEx(key, "", 0, winreg.REG_SZ, "URL:RiftLift Meta Login") + winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "") + with winreg.CreateKey( + winreg.HKEY_CURRENT_USER, + rf"Software\Classes\{scheme}\shell\open\command", + ) as key: + winreg.SetValueEx(key, "", 0, winreg.REG_SZ, command) + return python applications = xdg_data_home() / "applications" applications.mkdir(parents=True, exist_ok=True) desktop = applications / "riftlift-meta-login.desktop" From e35cb71e25dd74b8845ef768eeca7d702c7590bb Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:56:38 -0400 Subject: [PATCH 09/12] Enable Windows game downloads and automatic native runtime launches --- RiftLift-Simulator.cmd | 4 + src/riftlift/library.py | 1 + src/riftlift/windows.py | 282 ++++++++++++++++++++++++----- src/riftlift/windows_process.py | 58 ++++++ src/riftlift/windows_simulator.py | 94 ++++++++++ src/riftlift/windows_ui_backend.py | 9 +- tests/test_windows_native.py | 148 ++++++++++++++- tests/test_windows_package.py | 69 +++++++ 8 files changed, 612 insertions(+), 53 deletions(-) create mode 100644 RiftLift-Simulator.cmd create mode 100644 src/riftlift/windows_process.py create mode 100644 src/riftlift/windows_simulator.py create mode 100644 tests/test_windows_package.py diff --git a/RiftLift-Simulator.cmd b/RiftLift-Simulator.cmd new file mode 100644 index 0000000..2596274 --- /dev/null +++ b/RiftLift-Simulator.cmd @@ -0,0 +1,4 @@ +@echo off +setlocal +call "%~dp0RiftLift.cmd" simulate --runtime "%~dp0portable\tools\SteamVR" +exit /b %errorlevel% diff --git a/src/riftlift/library.py b/src/riftlift/library.py index 0e18998..85a985b 100644 --- a/src/riftlift/library.py +++ b/src/riftlift/library.py @@ -109,6 +109,7 @@ def add( arguments=launch_arguments, version=build.version, platform_offline=True, + platform_shim=True, source="meta", ) game.save(paths) diff --git a/src/riftlift/windows.py b/src/riftlift/windows.py index 8c0e706..d434df3 100644 --- a/src/riftlift/windows.py +++ b/src/riftlift/windows.py @@ -11,12 +11,16 @@ import sys import urllib.request import zipfile +from datetime import datetime from pathlib import Path, PurePosixPath +from meta_pcvr_downloader.api import MetaApiError +from meta_pcvr_downloader.download import DownloadError + from . import __version__ -from .config import Game, Paths, games +from .config import Game, Paths, debug_logging_enabled, games from .detection import is_pe64 -from .util import RiftLiftError +from .util import RiftLiftError, atomic_write_bytes, download, sha256 RELEASE = "v0.10.2.2" PAYLOAD_SHA256 = "90f9b1b5b26ba85a25ad2dcb3707b7a17540b0d40d310148dd98fa76c3a619eb" @@ -29,13 +33,57 @@ "LICENSE", "RIFTLIFT-LICENSE", } +PLATFORM_FILES = { + "LibOVRPlatform64_1.dll", + "LibOVRPlatformImpl64_1.dll", + "LibOVRPlatformImpl64_1_real.dll", + "LibOVRP2P64_1.dll", + "LibOVRRT64_1.dll", +} +SDK_RUNTIME_SHA256 = "f6941275692026b18666bb856d71fe1b19462017b2b2e556fe8df82461f493f5" + + +def install_sdk_runtime(paths: Paths) -> Path: + """Keep Meta's signed discovery DLL intact for the static Oculus SDK loader.""" + native = runtime_dir(paths) + if ( + (native / "LibOVRRT64_1.dll").is_file() + and sha256(native / "LibOVRRT64_1.dll") == SDK_RUNTIME_SHA256 + ): + return native + directory = paths.tools / "meta-runtime" + target = directory / "LibOVRRT64_1.dll" + if target.is_file() and sha256(target) == SDK_RUNTIME_SHA256: + return directory + package = download( + "https://securecdn-atl3-3.oculus.com/binaries/download/?id=3766757683456363", + paths.cache / "oculus-runtime.zip", + "adbdc5f0285a2ac2ead6fdd34522de98de1bf6782017d9857ea4044b2d2fd009", + ) + with zipfile.ZipFile(package) as bundle: + payload = bundle.read(target.name) + if hashlib.sha256(payload).hexdigest() != SDK_RUNTIME_SHA256: + raise RiftLiftError("Meta SDK runtime checksum mismatch") + atomic_write_bytes(target, payload) + return directory def runtime_dir(paths: Paths) -> Path: + if override := os.environ.get("RIFTLIFT_WINDOWS_RUNTIME"): + return Path(override).expanduser().resolve() + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) / "native" return paths.tools / "windows-native" / RELEASE def install_payload(paths: Paths, archive: Path | None = None) -> Path: + if getattr(sys, "frozen", False): + target = runtime_dir(paths) + if not all((target / name).is_file() for name in FILES | PLATFORM_FILES): + raise RiftLiftError( + "RiftLift's installation is incomplete. Please reinstall RiftLift." + ) + return target if archive: payload = archive.read_bytes() else: @@ -44,8 +92,8 @@ def install_payload(paths: Paths, archive: Path | None = None) -> Path: if hashlib.sha256(payload).hexdigest() != PAYLOAD_SHA256: raise RiftLiftError("Native payload SHA256 mismatch") target = runtime_dir(paths) - # Validate the complete archive before writing; never install the Linux - # platform-entitlement shim into a native Windows game. + # Validate the complete archive before writing. This pinned archive supplies + # the base bridge; the native platform layer is built with runtime/CMakeLists.txt. with zipfile.ZipFile(io.BytesIO(payload)) as bundle: names = {i.filename.replace("\\", "/") for i in bundle.infolist()} if not FILES.issubset(names): @@ -62,6 +110,7 @@ def install_payload(paths: Paths, archive: Path | None = None) -> Path: dest = target.joinpath(*relative.parts) dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(data) + (target / "local-build.json").unlink(missing_ok=True) return target @@ -83,12 +132,18 @@ def active_openxr() -> Path | None: def active_openvr() -> Path | None: - config = Path(os.environ["LOCALAPPDATA"]) / "openvr/openvrpaths.vrpath" + config = Path( + os.environ.get("VR_PATHREG_OVERRIDE") + or str(Path(os.environ["LOCALAPPDATA"]) / "openvr/openvrpaths.vrpath") + ) try: roots = json.loads(config.read_text(encoding="utf-8"))["runtime"] for root in roots: candidate = Path(root) - if (candidate / "bin/win64/vrclient_x64.dll").is_file(): + if any( + (candidate / name).is_file() + for name in ("bin/vrclient_x64.dll", "bin/win64/vrclient_x64.dll") + ): return candidate except (OSError, ValueError, KeyError, TypeError): pass @@ -96,32 +151,83 @@ def active_openvr() -> Path | None: def runtime_ready(backend: str) -> bool: + if backend not in {"openxr", "openvr"}: + raise RiftLiftError("Unknown native backend") target = active_openxr() if backend == "openxr" else active_openvr() return bool( target and (target.is_file() if backend == "openxr" else target.is_dir()) ) +def select_backend(game: Game, requested: str = "auto") -> str: + backend = requested + if backend == "auto": + if runtime_ready("openxr"): + xr, vr = active_openxr(), active_openvr() + # SteamVR's OpenXR path rejects Oculus cube and mipmapped textures. + # Use its OpenVR interface when both registrations refer to SteamVR. + if xr and vr and xr.resolve().parent == vr.resolve(): + return "openvr" + return "openxr" + return "openvr" + if backend not in {"openxr", "openvr"}: + raise RiftLiftError("Windows VR backend must be auto, openxr, or openvr") + return backend + + def doctor(paths: Paths) -> tuple[str, int]: native = runtime_dir(paths) payload_ok = all((native / name).is_file() for name in FILES) xr, vr = active_openxr(), active_openvr() installed = games(paths) + platform_ok = all((native / name).is_file() for name in PLATFORM_FILES) + platform_required = any(game.platform_shim for game in installed) text = "\n".join( [ f"RiftLift {__version__} on Windows", f"Native payload: {'INSTALLED' if payload_ok else 'MISSING'} ({native})", + f"Build: {'local source build' if (native / 'local-build.json').is_file() else 'runtime payload'}", f"OpenXR manifest: {xr or 'NOT REGISTERED'}", f"OpenVR runtime: {vr or 'NOT REGISTERED'}", f"Registered games: {len(installed)}", - "Game entitlement: handled by each game's original Meta platform runtime", + *( + ["SIMULATED HEADSET: desktop rendering only; no physical headset test."] + if os.environ.get("RIFTLIFT_SIMULATOR") + else [] + ), + *[ + f" {game.name}: " + f"{'FOUND' if game.executable_path.is_file() else 'EXECUTABLE MISSING'}" + for game in installed + ], + f"Platform compatibility: {'INSTALLED' if platform_ok else 'MISSING'} (RiftLift offline mode)", "Headset rendering/input/audio: NOT VERIFIED", "Diagnostics remain local; no paste is uploaded.", + "", + "Next steps:", + *([] if payload_ok else ["Install or repair the RiftLift native runtime."]), + *( + [] + if runtime_ready("openxr") or runtime_ready("openvr") + else [ + "Connect your headset and configure its Windows OpenXR runtime,", + "or install SteamVR in Steam and complete headset setup.", + ] + ), + *( + [] + if installed + else [ + "Install your owned PC games, then use Add Game to select their .exe files.", + ] + ), + "RiftLift selects the Windows VR runtime automatically.", + f"Game logs: {paths.data / 'logs'}", ] ) - return text, 0 if payload_ok and ( + return text, 0 if payload_ok and (platform_ok or not platform_required) and ( runtime_ready("openxr") or runtime_ready("openvr") - ) else 2 + ) and all(game.executable_path.is_file() for game in installed) else 2 def add_local( @@ -169,6 +275,8 @@ def launch_command( str(native / "RiftLiftLauncher.exe"), f"/{backend}", "/wait", + "/app", + game.app_key, "/cwd", str(game.game_dir), str(executable), @@ -192,21 +300,51 @@ def launch( raise RiftLiftError( f"Configure a Windows {backend} runtime and connect the headset first" ) - # Do not write to game files or install the Linux platform shim. + environment = os.environ.copy() + # Older Platform SDK loaders concatenate the DLL name directly to this value. + environment["LIBOVR_DLL_DIR"] = str(install_sdk_runtime(paths)) + os.sep + if game.platform_shim: + native = runtime_dir(paths) + if not all((native / name).is_file() for name in PLATFORM_FILES): + raise RiftLiftError("RiftLift native platform compatibility DLL is missing") + environment["PATH"] = str(native) + os.pathsep + environment.get("PATH", "") + environment["RIFTLIFT_PLATFORM_DLL"] = str( + native / "LibOVRPlatformImpl64_1.dll" + ) + environment["LIBOVR_DLL_DIR"] = str(native) + os.sep + if game.platform_offline: + environment["RIFTLIFT_PLATFORM_OFFLINE"] = "1" + if debug_logging_enabled(paths): + environment["RIFTLIFT_RUNTIME_TRACE"] = "1" log = paths.data / "logs" / f"{game.slug}.log" log.parent.mkdir(parents=True, exist_ok=True) from .playtime import PlaytimeSession - - with log.open("a", encoding="utf-8") as stream, PlaytimeSession(paths, game.slug): - process = subprocess.run( + from .windows_process import run_game + + print(f"Launching {game.name} through Windows {backend}; log: {log}") + with log.open("w", encoding="utf-8") as stream, PlaytimeSession(paths, game.slug): + stream.write(f"{datetime.now().isoformat()} | {game.name} | {backend}\n") + stream.write(f"Command: {subprocess.list2cmdline(command)}\n") + stream.flush() + returncode = run_game( command, cwd=game.game_dir, stdout=stream, - stderr=subprocess.STDOUT, - check=False, + env=environment, + ) + stream.write( + f"\nExit code: {returncode} (0x{returncode & 0xFFFFFFFF:08X})\n" + ) + launcher_log = ( + Path(os.environ["LOCALAPPDATA"]) / "RiftLift/RiftLiftLauncher.txt" ) - print(f"Launcher exit: {process.returncode}; log: {log}") - return process.returncode + if launcher_log.is_file(): + evidence = launcher_log.read_text(encoding="utf-8", errors="replace") + stream.write("\nNative launcher:\n" + evidence) + if debug_logging_enabled(paths): + print(evidence) + print(f"Launcher exit: {returncode}; log: {log}") + return returncode def parser() -> argparse.ArgumentParser: @@ -214,6 +352,7 @@ def parser() -> argparse.ArgumentParser: prog="riftlift", description="Run Meta Rift games through native Windows VR runtimes.", ) + p.add_argument("--home", type=Path, help=argparse.SUPPRESS) p.add_argument( "--version", action="version", @@ -221,6 +360,21 @@ def parser() -> argparse.ArgumentParser: ) sub = p.add_subparsers(dest="command", required=True) sub.add_parser("gui", help="open the Windows library") + simulator = sub.add_parser( + "simulate", help="open RiftLift with SteamVR's simulated headset" + ) + simulator.add_argument("--runtime", type=Path, required=True) + sub.add_parser("login", help="sign in to Meta in your default browser") + callback = sub.add_parser( + "callback", help="receive a Meta browser sign-in callback" + ) + callback.add_argument("url") + download = sub.add_parser("add", help="download an owned Meta Rift PC game") + download.add_argument("app") + download.add_argument("--build") + download.add_argument("--executable") + download.add_argument("--arguments") + download.add_argument("--jobs", type=int) setup = sub.add_parser("setup", help="install checksum-verified native x64 runtime") setup.add_argument("--archive", type=Path) doc = sub.add_parser("doctor", help="local Windows runtime checks") @@ -236,7 +390,7 @@ def parser() -> argparse.ArgumentParser: launch_parser = sub.add_parser("launch") launch_parser.add_argument("slug") launch_parser.add_argument( - "--backend", choices=["openxr", "openvr"], default="openxr" + "--backend", choices=["auto", "openxr", "openvr"], default="auto" ) launch_parser.add_argument("--dry-run", action="store_true") return p @@ -244,38 +398,76 @@ def parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: args = parser().parse_args(argv) + if args.home: + os.environ["RIFTLIFT_HOME"] = str(args.home) paths = Paths.defaults() try: - if args.command == "gui": - from .gui import main as gui - - return gui() - if args.command == "setup": - print(f"Native payload installed: {install_payload(paths, args.archive)}") - elif args.command == "doctor": - report, status = doctor(paths) - print(report) - return status - elif args.command == "list": - installed = games(paths) - print( - "\n".join(f"{g.slug}: {g.name}" for g in installed) - or "No games registered." - ) - elif args.command == "add-local": - game = add_local( - paths, args.executable, args.name, args.root, args.arguments - ) - print(f"Registered: {game.slug}") - elif args.command == "launch": - return launch( - paths, Game.load(paths, args.slug), args.backend, args.dry_run - ) - return 0 - except (OSError, ValueError, RiftLiftError, zipfile.BadZipFile) as error: + return _run_command(paths, args) + except ( + OSError, + ValueError, + RiftLiftError, + MetaApiError, + DownloadError, + zipfile.BadZipFile, + ) as error: print(f"RiftLift: {error}", file=sys.stderr) return 1 +def _run_command(paths: Paths, args: argparse.Namespace) -> int: + if args.command in {"gui", "simulate"}: + return _open_gui(paths, args) + if args.command == "login": + from .auth import login + + return login(paths) + if args.command == "callback": + from .auth import complete_login + + return complete_login(paths, args.url) + if args.command == "add": + from .library import add + + game = add( + paths, + args.app, + build_selector=args.build, + executable=args.executable, + arguments=args.arguments, + jobs=args.jobs, + ) + print(f"Installed: {game.name} ({game.slug})") + if args.command == "setup": + print(f"Native payload installed: {install_payload(paths, args.archive)}") + elif args.command == "doctor": + report, status = doctor(paths) + print(report) + return status + elif args.command == "list": + installed = games(paths) + print( + "\n".join(f"{g.slug}: {g.name}" for g in installed) + or "No games registered." + ) + elif args.command == "add-local": + game = add_local(paths, args.executable, args.name, args.root, args.arguments) + print(f"Registered: {game.slug}") + elif args.command == "launch": + game = Game.load(paths, args.slug) + return launch(paths, game, select_backend(game, args.backend), args.dry_run) + return 0 + + +def _open_gui(paths: Paths, args: argparse.Namespace) -> int: + if args.command == "simulate": + from .windows_simulator import start + + start(paths, args.runtime) + from .gui import main as gui + + return gui() + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/riftlift/windows_process.py b/src/riftlift/windows_process.py new file mode 100644 index 0000000..f59b1a0 --- /dev/null +++ b/src/riftlift/windows_process.py @@ -0,0 +1,58 @@ +"""Keep the packaged app's DLL search paths out of launched games.""" + +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +from pathlib import Path + + +def run_game(command, *, cwd, stdout, env) -> int: + frozen = getattr(sys, "frozen", False) + if not frozen: + return subprocess.run( + command, + cwd=cwd, + stdout=stdout, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW, + env=env, + check=False, + ).returncode + + bundle = Path(sys._MEIPASS).resolve() + # Preserve RiftLift's explicit native runtime directory in PATH, while + # removing the Qt/Python directories added by PyInstaller's runtime hooks. + native = bundle / "native" + clean_env = env.copy() + clean_env["PATH"] = os.pathsep.join( + part + for part in env.get("PATH", "").split(os.pathsep) + if part + and ( + not Path(part).resolve().is_relative_to(bundle) + or Path(part).resolve() == native + ) + ) + for name in ("QT_PLUGIN_PATH", "QT_QPA_PLATFORM_PLUGIN_PATH"): + if name in clean_env and Path(clean_env[name]).resolve().is_relative_to(bundle): + del clean_env[name] + kernel = ctypes.windll.kernel32 + original = ctypes.create_unicode_buffer(32768) + length = kernel.GetDllDirectoryW(len(original), original) + if not kernel.SetDllDirectoryW(None): + raise ctypes.WinError() + try: + process = subprocess.Popen( + command, + cwd=cwd, + stdout=stdout, + stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW, + env=clean_env, + ) + finally: + kernel.SetDllDirectoryW(original.value if length else None) + return process.wait() diff --git a/src/riftlift/windows_simulator.py b/src/riftlift/windows_simulator.py new file mode 100644 index 0000000..a59ec32 --- /dev/null +++ b/src/riftlift/windows_simulator.py @@ -0,0 +1,94 @@ +"""Opt-in SteamVR null headset for desktop rendering tests on Windows.""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +from .config import Paths +from .util import RiftLiftError, atomic_write_text + + +def configure(paths: Paths, runtime: Path) -> dict[str, str]: + runtime = runtime.expanduser().resolve() + required = ( + "bin/win64/vrstartup.exe", + "bin/vrclient_x64.dll", + "drivers/null/bin/win64/driver_null.dll", + "steamxr_win64.json", + ) + if not all((runtime / name).is_file() for name in required): + raise RiftLiftError( + "Choose a complete Windows SteamVR installation with its null driver" + ) + root = paths.data / "simulator" + config, logs = root / "config", root / "logs" + config.mkdir(parents=True, exist_ok=True) + logs.mkdir(parents=True, exist_ok=True) + registry = root / "openvrpaths.vrpath" + atomic_write_text( + registry, + json.dumps( + { + "jsonid": "vrpathreg", + "version": 1, + "runtime": [str(runtime)], + "config": [str(config)], + "log": [str(logs)], + "external_drivers": [], + }, + indent=2, + ), + ) + settings = config / "steamvr.vrsettings" + if not settings.exists(): + atomic_write_text( + settings, + json.dumps( + { + "steamvr": { + "forcedDriver": "null", + "activateMultipleDrivers": True, + "enableHomeApp": False, + "showMirrorView": True, + "renderTargetMultiplier": 1.0, + }, + "driver_null": { + "enable": True, + "serialNumber": "RiftLift Simulated HMD", + "modelNumber": "RiftLift desktop test headset", + "windowX": 100, + "windowY": 100, + "windowWidth": 1280, + "windowHeight": 720, + "renderWidth": 1024, + "renderHeight": 1024, + "displayFrequency": 90.0, + }, + "power": { + "pauseCompositorOnStandby": False, + "turnOffScreensTimeout": 86400.0, + }, + }, + indent=2, + ), + ) + return { + "VR_PATHREG_OVERRIDE": str(registry), + "XR_RUNTIME_JSON": str(runtime / "steamxr_win64.json"), + "RIFTLIFT_SIMULATOR": "1", + "RIFTLIFT_SIMULATED_CONTROLLERS": "1", + } + + +def start(paths: Paths, runtime: Path) -> None: + os.environ.update(configure(paths, runtime)) + subprocess.Popen( + [str(runtime.resolve() / "bin/win64/vrstartup.exe")], + creationflags=subprocess.CREATE_NO_WINDOW, + ) + print( + "SteamVR simulated headset and static hand poses started. Physical tracking/controllers/audio are not verified." + ) diff --git a/src/riftlift/windows_ui_backend.py b/src/riftlift/windows_ui_backend.py index c32c1e8..540ec37 100644 --- a/src/riftlift/windows_ui_backend.py +++ b/src/riftlift/windows_ui_backend.py @@ -3,8 +3,6 @@ No widgets, styles, or replacement windows live here. """ -import os - from . import windows from .config import Game, Paths from .util import RiftLiftError @@ -19,12 +17,11 @@ def doctor(paths: Paths) -> int: def launch(paths: Paths, game: Game, arguments: list[str]) -> int: - backend = os.environ.get("RIFTLIFT_WINDOWS_BACKEND") or ( - "openxr" if windows.runtime_ready("openxr") else "openvr" - ) + backend = windows.select_backend(game) result = windows.launch(paths, game, backend, extra=arguments) if result: raise RiftLiftError( - f"Native game launch exited with code {result}; see View Activity." + f"Native game launch exited with code {result} " + f"(0x{result & 0xffffffff:08X}). Log: {paths.data / 'logs' / (game.slug + '.log')}" ) return result diff --git a/tests/test_windows_native.py b/tests/test_windows_native.py index 14e7833..2a44539 100644 --- a/tests/test_windows_native.py +++ b/tests/test_windows_native.py @@ -88,7 +88,7 @@ def test_native_launch_builds_argv_without_shell_or_wine(paths): for name in windows.FILES: (runtime / name).touch() argv = windows.launch_command(paths, game, "openxr", ["tail"]) - assert argv[1:4] == ["/openxr", "/wait", "/cwd"] + assert argv[1:6] == ["/openxr", "/wait", "/app", game.app_key, "/cwd"] assert argv[-2:] == ["two words", "tail"] assert "wine" not in argv and "proton" not in argv assert "LibOVRPlatformImpl64_1.dll" not in " ".join(argv) @@ -117,6 +117,22 @@ def test_doctor_missing_runtime_is_not_success(paths, monkeypatch): assert "NOT VERIFIED" in report +def test_doctor_requires_platform_dependencies_for_downloaded_games(paths, monkeypatch): + game = windows.add_local(paths, sys.executable, "Probe") + game.platform_shim = True + game.save(paths) + native = windows.runtime_dir(paths) + native.mkdir(parents=True) + for name in windows.FILES: + (native / name).touch() + monkeypatch.setattr(windows, "runtime_ready", lambda backend: True) + monkeypatch.setattr(windows, "active_openxr", lambda: None) + monkeypatch.setattr(windows, "active_openvr", lambda: None) + report, status = windows.doctor(paths) + assert status == 2 + assert "Platform compatibility: MISSING" in report + + def test_gui_constructs_without_linux_imports(paths, monkeypatch): monkeypatch.setenv("QT_QPA_PLATFORM", "offscreen") from PySide6.QtWidgets import QApplication @@ -127,7 +143,8 @@ def test_gui_constructs_without_linux_imports(paths, monkeypatch): window = Window() assert window.windowTitle() == "RiftLift" assert Window.__module__ == "riftlift.main_window" - assert not window.signin.isEnabled() + assert window.signin.isEnabled() + assert window.debug_logging.isEnabled() assert not window.steam_games.isEnabled() assert window.library.count() == 0 window.close() @@ -159,3 +176,130 @@ def test_windows_playtime_locks_across_processes(paths): children = [subprocess.Popen([sys.executable, "-c", script]) for _ in range(3)] assert all(child.wait(timeout=30) == 0 for child in children) assert playtime(paths, "probe").launches == 15 + + +def test_native_runtime_selection_is_automatic(paths, monkeypatch): + monkeypatch.setenv("RIFTLIFT_WINDOWS_BACKEND", "openvr") + game = windows.add_local(paths, sys.executable, "Probe") + monkeypatch.setattr(windows, "active_openxr", lambda: None) + monkeypatch.setattr(windows, "active_openvr", lambda: None) + monkeypatch.setattr(windows, "runtime_ready", lambda backend: backend == "openvr") + assert windows.select_backend(game) == "openvr" + monkeypatch.setattr(windows, "runtime_ready", lambda backend: True) + assert windows.select_backend(game) == "openxr" + assert windows.select_backend(game, "openvr") == "openvr" + with pytest.raises(RiftLiftError, match="backend"): + windows.select_backend(game, "invalid") + + +def test_automatic_steamvr_uses_its_openvr_interface(paths, monkeypatch): + game = windows.add_local(paths, sys.executable, "Probe") + steamvr = paths.tools / "SteamVR" + monkeypatch.setattr(windows, "runtime_ready", lambda backend: True) + monkeypatch.setattr( + windows, "active_openxr", lambda: steamvr / "steamxr_win64.json" + ) + monkeypatch.setattr(windows, "active_openvr", lambda: steamvr) + assert windows.select_backend(game) == "openvr" + monkeypatch.setattr( + windows, "active_openxr", lambda: paths.tools / "other/runtime.json" + ) + assert windows.select_backend(game) == "openxr" + + +def test_openvr_finds_current_steamvr_layout(tmp_path, monkeypatch): + import json + + runtime = tmp_path / "SteamVR" + (runtime / "bin").mkdir(parents=True) + (runtime / "bin/vrclient_x64.dll").touch() + registry = tmp_path / "openvrpaths.vrpath" + registry.write_text(json.dumps({"runtime": [str(runtime)]})) + monkeypatch.setenv("VR_PATHREG_OVERRIDE", str(registry)) + assert windows.active_openvr() == runtime + + +def test_simulator_uses_isolated_configuration(paths, tmp_path): + import json + + from riftlift.windows_simulator import configure + + runtime = tmp_path / "SteamVR" + for name in ( + "bin/win64/vrstartup.exe", + "bin/vrclient_x64.dll", + "drivers/null/bin/win64/driver_null.dll", + "steamxr_win64.json", + ): + target = runtime / name + target.parent.mkdir(parents=True, exist_ok=True) + target.touch() + environment = configure(paths, runtime) + registry = json.loads(Path(environment["VR_PATHREG_OVERRIDE"]).read_text()) + assert registry["runtime"] == [str(runtime)] + assert Path(registry["config"][0]).is_relative_to(paths.data) + assert environment["XR_RUNTIME_JSON"] == str(runtime / "steamxr_win64.json") + settings = Path(registry["config"][0]) / "steamvr.vrsettings" + assert json.loads(settings.read_text())["driver_null"]["enable"] + settings.write_text('{"custom": true}') + configure(paths, runtime) + assert json.loads(settings.read_text()) == {"custom": True} + + +def test_windows_browser_uses_os_default_without_owning_process(paths, monkeypatch): + from riftlift import auth_browser + + opened = [] + monkeypatch.setattr( + auth_browser.webbrowser, "open", lambda url: opened.append(url) or True + ) + browser = auth_browser.default_browser() + assert ( + auth_browser.launch_browser_login(paths, browser, "https://auth.meta.com/") + is None + ) + assert opened == ["https://auth.meta.com/"] + + +def test_download_error_is_concise_and_does_not_register_game( + paths, monkeypatch, capsys +): + from meta_pcvr_downloader.download import DownloadError + + def denied(*args, **kwargs): + raise DownloadError("Meta refused the manifest") + + monkeypatch.setattr("riftlift.library.add", denied) + assert windows.main(["add", "123456789"]) == 1 + assert "Meta refused the manifest" in capsys.readouterr().err + assert not list((paths.data / "games").glob("*.json")) + + +def test_windows_download_preserves_manifest_and_enables_offline_compat( + paths, monkeypatch +): + from types import SimpleNamespace + + from riftlift import library + + monkeypatch.setattr(library, "runtime_access_token", lambda paths: "test-token") + build = SimpleNamespace(app_name="Test Download", version="1.0") + monkeypatch.setattr(library, "list_builds", lambda *args: [build]) + monkeypatch.setattr(library, "select_build", lambda *args: build) + manifest = { + "canonicalName": "publisher.test", + "launchFile": "bin/game.exe", + "launchParameters": '"two words"', + } + monkeypatch.setattr(library, "fetch_manifest", lambda *args: manifest) + monkeypatch.setattr(library, "_best_executable", lambda *args: "bin/game.exe") + monkeypatch.setattr(library, "populate_game_metadata", lambda *args: None) + received = [] + monkeypatch.setattr( + library, "Downloader", lambda *args: SimpleNamespace(run=received.append) + ) + game = library.add(paths, "123456789") + assert received == [manifest] + assert game.app_key == "publisher.test" + assert game.arguments == ["two words"] + assert game.platform_shim and game.platform_offline diff --git a/tests/test_windows_package.py b/tests/test_windows_package.py new file mode 100644 index 0000000..c6a5ad4 --- /dev/null +++ b/tests/test_windows_package.py @@ -0,0 +1,69 @@ +import sys +from pathlib import Path + +import pytest + +from riftlift import windows +from riftlift.config import Paths +from riftlift.meta_auth import windows_callback_command +from riftlift.util import RiftLiftError + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows packaging") + + +@pytest.fixture +def bundle(tmp_path, monkeypatch): + monkeypatch.setenv("RIFTLIFT_HOME", str(tmp_path / "user data")) + monkeypatch.delenv("RIFTLIFT_WINDOWS_RUNTIME", raising=False) + monkeypatch.setattr(sys, "frozen", True, raising=False) + monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path / "app files"), raising=False) + paths = Paths.defaults() + native = windows.runtime_dir(paths) + native.mkdir(parents=True) + for name in windows.FILES | windows.PLATFORM_FILES: + (native / name).write_bytes(b"test payload") + return paths, native + + +def test_bundled_setup_is_offline_and_preserves_payload(bundle, monkeypatch): + paths, native = bundle + monkeypatch.setattr( + windows.urllib.request, "urlopen", lambda *a, **k: pytest.fail("network") + ) + assert windows.install_payload(paths) == native + assert (native / "RiftLiftOpenVR64.dll").read_bytes() == b"test payload" + + +def test_incomplete_bundle_requests_reinstall_without_download(bundle, monkeypatch): + paths, native = bundle + (native / "LibOVRPlatformImpl64_1.dll").unlink() + monkeypatch.setattr( + windows.urllib.request, "urlopen", lambda *a, **k: pytest.fail("network") + ) + with pytest.raises(RiftLiftError, match="reinstall"): + windows.install_payload(paths) + + +def test_bundled_discovery_needs_no_first_launch_download(bundle, monkeypatch): + paths, native = bundle + monkeypatch.setattr(windows, "sha256", lambda p: windows.SDK_RUNTIME_SHA256) + monkeypatch.setattr(windows, "download", lambda *a, **k: pytest.fail("network")) + assert windows.install_sdk_runtime(paths) == native + + +def test_packaged_callback_uses_exe_and_quotes_spaces(bundle, monkeypatch): + paths, _ = bundle + exe = Path("C:/Program Files/RiftLift/RiftLift.exe") + monkeypatch.setattr(sys, "executable", str(exe)) + target, command = windows_callback_command(paths) + assert target == exe + assert command.startswith(f'"{exe}" --home "') + assert command.endswith(' callback "%1"') + assert "-m" not in command + + +def test_source_callback_uses_python_module(tmp_path, monkeypatch): + monkeypatch.setenv("RIFTLIFT_HOME", str(tmp_path)) + monkeypatch.setattr(sys, "frozen", False, raising=False) + _, command = windows_callback_command(Paths.defaults()) + assert "-m riftlift.windows" in command From 03cf0850c930d55128c4335e66d41294c5bf4c16 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:56:38 -0400 Subject: [PATCH 10/12] Polish Windows library controls and reuse the existing app window --- src/riftlift/main_window.py | 70 +++++++++++++++++++++++++++++---- src/riftlift/windows_desktop.py | 51 ++++++++++++++++++++++++ tests/test_gui.py | 3 +- 3 files changed, 115 insertions(+), 9 deletions(-) create mode 100644 src/riftlift/windows_desktop.py diff --git a/src/riftlift/main_window.py b/src/riftlift/main_window.py index 3f71009..ae24cbc 100644 --- a/src/riftlift/main_window.py +++ b/src/riftlift/main_window.py @@ -126,11 +126,14 @@ def __init__(self, paths: Paths | None = None): self.setStyleSheet(STYLE) self._build() if os.name == "nt": - for control in (self.signin, self.steam_games, self.debug_logging): + for control in (self.steam_games,): control.setEnabled(False) control.setToolTip( "This integration is pending native Windows support." ) + self.debug_logging.setToolTip( + "Include native launcher diagnostics in View Activity." + ) self.refresh() def label(self, text="", name=""): @@ -159,7 +162,7 @@ def _build_header(self, outer: QtWidgets.QVBoxLayout) -> None: header.addWidget(self.debug_logging) self.check = self.button( "System", - lambda: self.run_task("Checking your system", lambda: doctor(self.paths)), + self.show_system, ) self.check.setObjectName("nav") self.signin = self.button( @@ -185,7 +188,11 @@ def _build_library(self) -> QtWidgets.QWidget: self.count = self.label("", "muted") heading.addWidget(self.count) heading.addStretch() - self.refresh_button = self.button("⟳", self.refresh_library) + self.refresh_button = self.button("", self.refresh_library) + self.refresh_button.setIcon( + self.style().standardIcon(QtWidgets.QStyle.SP_BrowserReload) + ) + self.refresh_button.setAccessibleName("Refresh library") self.refresh_button.setObjectName("refresh") self.refresh_button.setToolTip("Refresh library and game info") self.refresh_button.setFixedSize(34, 34) @@ -206,7 +213,7 @@ def _build_empty_state(self) -> QtWidgets.QWidget: title.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(title) hint = self.label( - "Add an owned Meta Rift title to download it and make it ready for OpenXR.", + "Add an owned Meta Rift title to download it and play with your headset.", "muted", ) hint.setAlignment(QtCore.Qt.AlignCenter) @@ -233,6 +240,7 @@ def _build_game_detail(self) -> HeroPanel: info.addSpacing(8) self.meta = self.label("", "muted") self.meta.setWordWrap(True) + self.meta.setMaximumWidth(340) info.addWidget(self.meta) info.addSpacing(14) actions = QtWidgets.QHBoxLayout() @@ -260,6 +268,7 @@ def _build_status_bar(self, outer: QtWidgets.QVBoxLayout) -> None: layout = QtWidgets.QHBoxLayout(bar) layout.setContentsMargins(0, 0, 0, 0) self.status = self.label("Ready", "muted") + self.status.setWordWrap(True) layout.addWidget(self.status, 1) activity = self.button("View Activity", self.show_activity) activity.setObjectName("nav") @@ -390,6 +399,50 @@ def show_game(self, game): def game(self): return next((g for g in self.installed if g.slug == self.slug), None) + def show_system(self): + if os.name != "nt": + self.run_task("Checking your system", lambda: doctor(self.paths)) + return + from . import windows + + dialog = QtWidgets.QDialog(self) + dialog.setWindowTitle("RiftLift system") + dialog.resize(820, 530) + dialog.setStyleSheet(STYLE) + layout = QtWidgets.QVBoxLayout(dialog) + layout.addWidget(self.label("Windows VR setup", "game")) + report, status = windows.doctor(self.paths) + heading = self.label( + "Setup needs attention" + if status + else "Runtime files are ready; launch a game to test VR output.", + "muted", + ) + heading.setWordWrap(True) + layout.addWidget(heading) + view = QtWidgets.QTextEdit(readOnly=True) + view.setPlainText(report) + layout.addWidget(view) + buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Close) + buttons.rejected.connect(dialog.reject) + repair = buttons.addButton( + "Install / repair runtime", QtWidgets.QDialogButtonBox.ActionRole + ) + repair.setEnabled(not self.busy) + + def install(): + dialog.accept() + self.run_task( + "Installing native runtime", + lambda: windows.install_payload(self.paths), + "Native runtime installed", + ) + + repair.clicked.connect(install) + layout.addWidget(buttons) + self._append_log(report + "\n") + dialog.exec() + def launch_game(self): if g := self.game(): self.run_task( @@ -434,12 +487,8 @@ def open_store(self): def add_dialog(self): dialog = StoreGameDialog(self.local_dialog, self) if os.name == "nt": - dialog.entry.setEnabled(False) dialog.steam.setChecked(False) dialog.steam.setEnabled(False) - dialog.validation.setText( - "Install with Meta's PC app, then choose Add a local game above." - ) if dialog.exec() != QtWidgets.QDialog.Accepted: return @@ -555,6 +604,11 @@ def main() -> int: app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) app.setApplicationName("RiftLift") app.setStyle("Fusion") + if os.name == "nt": + from .windows_desktop import activate_existing + + if activate_existing(app): + return 0 window = Window() app._riftlift_window = window window.show() diff --git a/src/riftlift/windows_desktop.py b/src/riftlift/windows_desktop.py new file mode 100644 index 0000000..cc391d1 --- /dev/null +++ b/src/riftlift/windows_desktop.py @@ -0,0 +1,51 @@ +"""Windows taskbar identity and one library window per data directory.""" + +from __future__ import annotations + +import hashlib +import sys +from pathlib import Path + +from PySide6 import QtGui, QtNetwork + +from .config import Paths + + +def activate_existing(app) -> bool: + name = ( + "riftlift-" + + hashlib.sha256(str(Paths.defaults().data).casefold().encode()).hexdigest()[ + :24 + ] + ) + socket = QtNetwork.QLocalSocket() + socket.connectToServer(name) + if socket.waitForConnected(300): + socket.write(b"activate") + socket.waitForBytesWritten(1000) + socket.disconnectFromServer() + return True + server = QtNetwork.QLocalServer(app) + server.setSocketOptions(QtNetwork.QLocalServer.UserAccessOption) + QtNetwork.QLocalServer.removeServer(name) + if not server.listen(name): + raise OSError("Could not open RiftLift's local application connection") + + def activate(): + while server.hasPendingConnections(): + client = server.nextPendingConnection() + client.close() + client.deleteLater() + window = getattr(app, "_riftlift_window", None) + if window: + window.showNormal() + window.raise_() + window.activateWindow() + app._riftlift_activations = getattr(app, "_riftlift_activations", 0) + 1 + + server.newConnection.connect(activate) + app._riftlift_server = server + if getattr(sys, "frozen", False): + icon = Path(sys._MEIPASS) / "assets/riftlift.ico" + app.setWindowIcon(QtGui.QIcon(str(icon))) + return False diff --git a/tests/test_gui.py b/tests/test_gui.py index 7382e20..7d575c8 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -77,9 +77,10 @@ def test_gui_exposes_only_the_primary_library_actions(tmp_path: Path) -> None: "Sign In", "Steam Games", "Add Game", - "⟳", "View Activity", } <= buttons + assert window.refresh_button.accessibleName() == "Refresh library" + assert not window.refresh_button.icon().isNull() assert "Refresh Info" not in buttons assert "Store" not in buttons assert "Open in Rift Store ↗" in buttons From 04ba14de7c0e797c7ab92581edb966373bceac9c Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:56:38 -0400 Subject: [PATCH 11/12] Build a self-contained Windows app and per-user installer --- .github/workflows/release.yml | 11 +++- .github/workflows/windows-host.yml | 2 +- .github/workflows/windows-package.yml | 72 +++++++++++++++++++++++++++ RiftLift.cmd | 2 +- pyproject.toml | 2 + scripts/build-windows.ps1 | 35 +++++++++++++ scripts/prepare-windows-package.py | 38 ++++++++++++++ scripts/test-windows-install.ps1 | 46 +++++++++++++++++ scripts/windows-entry.py | 3 ++ scripts/windows-installer.iss | 62 +++++++++++++++++++++++ scripts/windows.spec | 25 ++++++++++ src/riftlift/windows_app.py | 49 ++++++++++++++++++ src/riftlift/windows_package_check.py | 54 ++++++++++++++++++++ 13 files changed, 397 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/windows-package.yml create mode 100644 scripts/build-windows.ps1 create mode 100644 scripts/prepare-windows-package.py create mode 100644 scripts/test-windows-install.ps1 create mode 100644 scripts/windows-entry.py create mode 100644 scripts/windows-installer.iss create mode 100644 scripts/windows.spec create mode 100644 src/riftlift/windows_app.py create mode 100644 src/riftlift/windows_package_check.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4975e3f..eacf8b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,9 @@ permissions: contents: read jobs: + windows-app: + uses: ./.github/workflows/windows-package.yml + python-release: runs-on: ubuntu-latest steps: @@ -197,10 +200,14 @@ jobs: riftlift-compat.zip.sha256 release-bundle: - needs: [python-release, compatibility, xrizer, dxvk] + needs: [python-release, compatibility, xrizer, dxvk, windows-app] runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + - uses: actions/download-artifact@v5 + with: + name: riftlift-windows-installer + path: release - uses: actions/download-artifact@v5 with: name: riftlift-python-release @@ -224,7 +231,7 @@ jobs: version=$(PYTHONPATH=src python -c 'from riftlift import __version__; print(__version__)') tag=$(python -c 'import re,sys; value=sys.argv[1]; match=re.fullmatch(r"(\d+(?:\.\d+){2,3})(?:a(\d+))?", value); assert match; print(f"v{match.group(1)}" + (f"-alpha.{match.group(2)}" if match.group(2) else ""))' "$version") scripts/generate-release-installer.sh "$tag" release release/riftlift-installer.sh - (cd release && sha256sum riftlift-* > SHA256SUMS) + (cd release && sha256sum riftlift-* RiftLift-Setup-*.exe > SHA256SUMS) (cd release && sha256sum -c SHA256SUMS) bash -n release/riftlift-installer.sh grep -F "release_tag='$tag'" release/riftlift-installer.sh diff --git a/.github/workflows/windows-host.yml b/.github/workflows/windows-host.yml index 2f224e8..f3d1e46 100644 --- a/.github/workflows/windows-host.yml +++ b/.github/workflows/windows-host.yml @@ -12,6 +12,6 @@ jobs: python-version: '3.12' - run: python -m pip install -e . pytest - run: python -m riftlift.cli --help - - run: python -m pytest tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py -q + - run: python -m pytest tests/test_windows_package.py tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py tests/test_platform_exports.py -q env: QT_QPA_PLATFORM: offscreen diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml new file mode 100644 index 0000000..fc26c2d --- /dev/null +++ b/.github/workflows/windows-package.yml @@ -0,0 +1,72 @@ +name: Windows installer + +on: + workflow_dispatch: + workflow_call: + pull_request: + paths: + - '.github/workflows/windows-package.yml' + - 'scripts/*windows*' + - 'src/riftlift/**' + - 'runtime/**' + - 'compat/**' + - 'pyproject.toml' + +permissions: + contents: read + +jobs: + package: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: python -m pip install . pyinstaller==6.22.0 pefile ninja cmake pytest + - uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + - name: Fetch pinned native dependencies + shell: bash + run: | + scripts/prepare-runtime-ci-deps.sh + git clone https://github.com/microsoft/Detours.git build/Detours + git -C build/Detours checkout e4bfd6b03e50de46b47abfbd1e46b384f0c5f833 + git clone https://github.com/KhronosGroup/OpenXR-SDK.git build/OpenXR-SDK + git -C build/OpenXR-SDK checkout 8899a91c17ce9618f565f42408b47db1d6e9ccc7 + - name: Prepare signed SDK files + shell: pwsh + run: | + Invoke-WebRequest 'https://securecdn.oculus.com/binaries/download/?id=4377593722298679' -OutFile build/sdk.zip + if ((Get-FileHash build/sdk.zip).Hash -ne '3c8aeb66c822af731a8d1f1cb1cf97ab706100bf0bd003ef126f4bb9812906b3') { throw 'SDK checksum mismatch' } + Expand-Archive build/sdk.zip runtime/Externals -Force + Invoke-WebRequest 'https://securecdn-atl3-3.oculus.com/binaries/download/?id=3766757683456363' -OutFile build/platform.zip + if ((Get-FileHash build/platform.zip).Hash -ne 'adbdc5f0285a2ac2ead6fdd34522de98de1bf6782017d9857ea4044b2d2fd009') { throw 'Platform checksum mismatch' } + Expand-Archive build/platform.zip build/meta-runtime -Force + - name: Build complete Windows native runtime + shell: pwsh + run: | + cmake -S runtime -B build/native -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DBUILD_TESTING=OFF "-DDETOURS_SOURCE_DIR=$PWD/build/Detours" "-DOPENXR_SDK_SOURCE_DIR=$PWD/build/OpenXR-SDK" "-DMETA_PLATFORM_IMPL=$PWD/build/meta-runtime/LibOVRPlatformImpl64_1.dll" + if ($LASTEXITCODE -ne 0) { throw 'Native configuration failed' } + cmake --build build/native -j 4 + if ($LASTEXITCODE -ne 0) { throw 'Native build failed' } + - name: Build self-contained app and per-user installer + run: ./scripts/build-windows.ps1 -RuntimeDirectory build/native/bin + - name: Check installation, upgrade, launch and uninstall + run: ./scripts/test-windows-install.ps1 + - name: Windows regression tests + run: python -m pytest tests/test_windows_package.py tests/test_windows_native.py tests/test_gui.py tests/test_playtime.py tests/test_util.py tests/test_library.py tests/test_platform_exports.py -q + env: + QT_QPA_PLATFORM: offscreen + - uses: actions/upload-artifact@v4 + with: + name: riftlift-windows-installer + path: | + dist/windows/RiftLift-Setup-*.exe + dist/windows/RiftLift-Setup-*.exe.sha256 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: riftlift-windows-install-checks + path: build/install-check/ diff --git a/RiftLift.cmd b/RiftLift.cmd index 3df3ef4..722ada1 100644 --- a/RiftLift.cmd +++ b/RiftLift.cmd @@ -6,7 +6,7 @@ if not exist "%~dp0.venv\Scripts\python.exe" ( exit /b 1 ) if "%~1"=="" ( - "%~dp0.venv\Scripts\python.exe" -m riftlift.cli gui + start "" "%~dp0.venv\Scripts\pythonw.exe" -m riftlift.windows_app ) else ( "%~dp0.venv\Scripts\python.exe" -m riftlift.cli %* ) diff --git a/pyproject.toml b/pyproject.toml index cee45db..feb65a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ xrizer = "https://github.com/Villagers654/xrizer" [project.scripts] riftlift = "riftlift.cli:main" + +[project.gui-scripts] riftlift-gui = "riftlift.gui:main" [tool.setuptools.packages.find] diff --git a/scripts/build-windows.ps1 b/scripts/build-windows.ps1 new file mode 100644 index 0000000..420caf6 --- /dev/null +++ b/scripts/build-windows.ps1 @@ -0,0 +1,35 @@ +param( + [Parameter(Mandatory = $true)][string]$RuntimeDirectory, + [string]$Python = 'python', + [string]$Iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + [switch]$AppOnly +) +$ErrorActionPreference = 'Stop' +$root = Split-Path $PSScriptRoot -Parent +$runtime = (Resolve-Path -LiteralPath $RuntimeDirectory).Path +$env:RIFTLIFT_PACKAGE_RUNTIME = $runtime +$env:RIFTLIFT_PACKAGE_ICON = Join-Path $root 'build\windows\riftlift.ico' +$originalPath = $env:PATH +$pythonExe = (Get-Command $Python -ErrorAction Stop).Source +# DLL discovery must not pull same-named libraries from unrelated developer tools. +$env:PATH = "$(Split-Path $pythonExe);$env:WINDIR\System32;$env:WINDIR" +Push-Location $root +try { + & $Python scripts/prepare-windows-package.py $runtime $env:RIFTLIFT_PACKAGE_ICON + if ($LASTEXITCODE -ne 0) { throw 'Windows package preparation failed' } + & $Python -m PyInstaller --noconfirm --clean --distpath dist/windows --workpath build/windows/pyinstaller scripts/windows.spec + if ($LASTEXITCODE -ne 0) { throw 'Windows application build failed' } + if ($AppOnly) { return } + $version = & $Python -c 'from riftlift import __version__; print(__version__)' + if ($LASTEXITCODE -ne 0) { throw 'Could not read RiftLift version' } + $bundle = Join-Path $root 'dist\windows\RiftLift' + & $Iscc "/DAppVersion=$version" "/DBundleDir=$bundle" "/O$root\dist\windows" scripts/windows-installer.iss + if ($LASTEXITCODE -ne 0) { throw 'Windows installer build failed' } + $installer = Join-Path $root "dist\windows\RiftLift-Setup-$version-x64.exe" + $hash = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $(Split-Path $installer -Leaf)" | Set-Content -Encoding ascii "$installer.sha256" + Write-Host "Installer ready: $installer" +} finally { + $env:PATH = $originalPath + Pop-Location +} diff --git a/scripts/prepare-windows-package.py b/scripts/prepare-windows-package.py new file mode 100644 index 0000000..b11fe57 --- /dev/null +++ b/scripts/prepare-windows-package.py @@ -0,0 +1,38 @@ +"""Validate the native payload and derive the Windows icon from our SVG.""" + +import os +import sys +from pathlib import Path + +from riftlift.util import sha256 +from riftlift.windows import FILES, PLATFORM_FILES, SDK_RUNTIME_SHA256 + +runtime, icon = map(Path, sys.argv[1:]) +missing = sorted( + name for name in FILES | PLATFORM_FILES if not (runtime / name).is_file() +) +if missing: + raise SystemExit("Incomplete native runtime: " + ", ".join(missing)) +if not list((runtime / "Input").glob("*.json")): + raise SystemExit("Missing native controller bindings") +if sha256(runtime / "LibOVRRT64_1.dll") != SDK_RUNTIME_SHA256: + raise SystemExit("The signed SDK discovery DLL does not match the pinned version") + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +from PIL import Image # noqa: E402 +from PySide6 import QtCore, QtGui, QtSvg # noqa: E402 + +app = QtGui.QGuiApplication([]) +svg = Path(__file__).resolve().parents[1] / "assets/io.github.villagers654.RiftLift.svg" +renderer = QtSvg.QSvgRenderer(str(svg)) +image = QtGui.QImage(256, 256, QtGui.QImage.Format_RGBA8888) +image.fill(QtCore.Qt.transparent) +painter = QtGui.QPainter(image) +renderer.render(painter) +painter.end() +icon.parent.mkdir(parents=True, exist_ok=True) +png = icon.with_suffix(".png") +image.save(str(png)) +Image.open(png).save( + icon, sizes=[(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (256, 256)] +) diff --git a/scripts/test-windows-install.ps1 b/scripts/test-windows-install.ps1 new file mode 100644 index 0000000..349f9dc --- /dev/null +++ b/scripts/test-windows-install.ps1 @@ -0,0 +1,46 @@ +param([string]$Installer) +$ErrorActionPreference = 'Stop' +if ($env:CI -ne 'true') { throw 'Run installer lifecycle tests on an ephemeral CI worker' } +$root = Split-Path $PSScriptRoot -Parent +if (-not $Installer) { + $Installer = (Get-ChildItem "$root\dist\windows\RiftLift-Setup-*.exe" | Select-Object -First 1).FullName +} +if (-not $Installer) { throw 'Build the Windows installer first' } +$evidence = Join-Path $root 'build\install-check' +$appDir = Join-Path $evidence 'App with spaces' +$env:RIFTLIFT_HOME = Join-Path $evidence 'User data' +$env:QT_QPA_PLATFORM = 'offscreen' +New-Item -ItemType Directory -Force $evidence | Out-Null +function Run-Bounded([string]$File, [string[]]$Arguments) { + $process = Start-Process -FilePath $File -ArgumentList $Arguments -WindowStyle Hidden -PassThru + if (-not $process.WaitForExit(60000)) { + Stop-Process -Id $process.Id + throw "Timed out: $File" + } + if ($process.ExitCode -ne 0) { throw "$File exited $($process.ExitCode)" } +} +# Use only an isolated installation on an ephemeral CI worker. +Run-Bounded $Installer @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/TASKS=', ('/DIR="' + $appDir + '"'), ('/LOG="' + $evidence + '\install.log"')) +try { + $exe = Join-Path $appDir 'RiftLift.exe' + $env:PATH = "$env:WINDIR\System32;$env:WINDIR" + Run-Bounded $exe @('--package-check', ('"' + $evidence + '\first-launch"')) + $result = Get-Content "$evidence\first-launch\result.json" | ConvertFrom-Json + if (-not $result.success -or -not $result.icon_loaded) { throw 'Installed app check failed' } + $shortcutPath = Join-Path ([Environment]::GetFolderPath('Programs')) 'RiftLift.lnk' + $shortcut = (New-Object -ComObject WScript.Shell).CreateShortcut($shortcutPath) + if ($shortcut.TargetPath -ne $exe) { throw 'Start menu shortcut points to the wrong executable' } + $marker = Join-Path $env:RIFTLIFT_HOME 'preserve-on-upgrade.txt' + Set-Content $marker 'preserve games and settings' + Run-Bounded $Installer @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', ('/DIR="' + $appDir + '"'), ('/LOG="' + $evidence + '\upgrade.log"')) + if ((Get-Content $marker) -ne 'preserve games and settings') { throw 'Upgrade changed user data' } + Run-Bounded $exe @('--package-check', ('"' + $evidence + '\after-upgrade"')) +} finally { + if (Test-Path "$appDir\unins000.exe") { + Run-Bounded "$appDir\unins000.exe" @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', ('/LOG="' + $evidence + '\uninstall.log"')) + } +} +if (Test-Path "$appDir\RiftLift.exe") { throw 'Uninstall left the application executable' } +if (Test-Path $shortcutPath) { throw 'Uninstall left the Start menu shortcut' } +if ((Get-Content $marker) -ne 'preserve games and settings') { throw 'Uninstall changed user data' } +Write-Host 'Install, upgrade, launch, shortcut and uninstall checks passed.' diff --git a/scripts/windows-entry.py b/scripts/windows-entry.py new file mode 100644 index 0000000..f49ab53 --- /dev/null +++ b/scripts/windows-entry.py @@ -0,0 +1,3 @@ +from riftlift.windows_app import main + +raise SystemExit(main()) diff --git a/scripts/windows-installer.iss b/scripts/windows-installer.iss new file mode 100644 index 0000000..8ad37b8 --- /dev/null +++ b/scripts/windows-installer.iss @@ -0,0 +1,62 @@ +#ifndef AppVersion + #error AppVersion must be supplied by build-windows.ps1 +#endif +#ifndef BundleDir + #error BundleDir must be supplied by build-windows.ps1 +#endif + +[Setup] +AppId={{76D8E09B-371D-4723-BB67-AC3481EE7712} +AppName=RiftLift +AppVersion={#AppVersion} +AppPublisher=Villagers654 +AppPublisherURL=https://github.com/Villagers654/RiftLift +DefaultDirName={localappdata}\Programs\RiftLift +DefaultGroupName=RiftLift +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +MinVersion=10.0.17763 +WizardStyle=modern +SetupIconFile={#BundleDir}\_internal\assets\riftlift.ico +UninstallDisplayIcon={app}\RiftLift.exe +OutputBaseFilename=RiftLift-Setup-{#AppVersion}-x64 +Compression=lzma2 +SolidCompression=yes +CloseApplications=yes +RestartApplications=no + +[Tasks] +Name: desktopicon; Description: "Create a &desktop shortcut"; GroupDescription: "Shortcuts:"; Flags: unchecked + +[Files] +Source: "{#BundleDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{userprograms}\RiftLift"; Filename: "{app}\RiftLift.exe"; WorkingDir: "{app}"; AppUserModelID: "Villagers654.RiftLift" +Name: "{userdesktop}\RiftLift"; Filename: "{app}\RiftLift.exe"; WorkingDir: "{app}"; Tasks: desktopicon; AppUserModelID: "Villagers654.RiftLift" + +[Run] +Filename: "{app}\RiftLift.exe"; Description: "Open RiftLift"; Flags: nowait postinstall skipifsilent + +; User games, settings and sign-in live outside {app} and survive uninstall. + +[Code] +procedure RemoveOwnedProtocol(Scheme: String); +var + Command: String; +begin + if RegQueryStringValue(HKCU, 'Software\Classes\' + Scheme + '\shell\open\command', '', Command) then + if (Pos('"' + Lowercase(ExpandConstant('{app}\RiftLift.exe')) + '"', Lowercase(Command)) = 1) or + (Pos(Lowercase(ExpandConstant('{app}\RiftLift.exe')) + ' ', Lowercase(Command)) = 1) then + RegDeleteKeyIncludingSubkeys(HKCU, 'Software\Classes\' + Scheme); +end; + +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usUninstall then begin + RemoveOwnedProtocol('oculus'); + RemoveOwnedProtocol('oculus-client'); + end; +end; diff --git a/scripts/windows.spec b/scripts/windows.spec new file mode 100644 index 0000000..ea721c8 --- /dev/null +++ b/scripts/windows.spec @@ -0,0 +1,25 @@ +# Build with scripts/build-windows.ps1 after building the native x64 runtime. +import os +from pathlib import Path + +from PyInstaller.utils.hooks import collect_data_files, copy_metadata + +root = Path(SPECPATH).parent +native = Path(os.environ["RIFTLIFT_PACKAGE_RUNTIME"]).resolve() +icon = Path(os.environ["RIFTLIFT_PACKAGE_ICON"]).resolve() +a = Analysis( + [str(root / "scripts/windows-entry.py")], + pathex=[str(root / "src")], + binaries=[], + datas=[(str(native), "native"), (str(icon), "assets")] + + collect_data_files("meta_pcvr_downloader") + + copy_metadata("meta-pcvr-downloader"), + hiddenimports=["PySide6.QtSvg", "PySide6.QtNetwork"], + excludes=["tkinter", "pytest"], +) +pyz = PYZ(a.pure) +exe = EXE( + pyz, a.scripts, [], exclude_binaries=True, name="RiftLift", + console=False, icon=str(icon), upx=False, +) +coll = COLLECT(exe, a.binaries, a.datas, name="RiftLift", upx=False) diff --git a/src/riftlift/windows_app.py b/src/riftlift/windows_app.py new file mode 100644 index 0000000..6de1326 --- /dev/null +++ b/src/riftlift/windows_app.py @@ -0,0 +1,49 @@ +"""Console-free Windows application entry point, also used by the installer.""" + +from __future__ import annotations + +import ctypes +import os +import sys +import traceback +from pathlib import Path + +from .config import Paths + + +def main() -> int: + # pythonw and PyInstaller's windowed bootloader have no standard streams. + # Keep startup failures accessible even before the Activity widget exists. + paths = Paths.defaults() + log = paths.data / "logs" / "riftlift.log" + log.parent.mkdir(parents=True, exist_ok=True) + if not sys.stdout or not sys.stderr: + if log.exists() and log.stat().st_size > 2 * 1024 * 1024: + log.replace(log.with_suffix(".previous.log")) + stream = log.open("a", encoding="utf-8", buffering=1) + sys.stdout = sys.stderr = stream + try: + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( + "Villagers654.RiftLift" + ) + if len(sys.argv) == 3 and sys.argv[1] == "--package-check": + from .windows_package_check import run + + return run(Path(sys.argv[2])) + from .windows import main as command + + return command(sys.argv[1:] or ["gui"]) + except Exception: + traceback.print_exc() + if os.environ.get("QT_QPA_PLATFORM") != "offscreen": + ctypes.windll.user32.MessageBoxW( + None, + f"RiftLift could not start. Please reinstall RiftLift.\n\nDetails: {log}", + "RiftLift", + 0x10, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/riftlift/windows_package_check.py b/src/riftlift/windows_package_check.py new file mode 100644 index 0000000..eeb842f --- /dev/null +++ b/src/riftlift/windows_package_check.py @@ -0,0 +1,54 @@ +"""Bounded, offscreen verification of the built Windows application.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + + +def run(output: Path) -> int: + # Never opens or drives the user's desktop. Uses the shipped Qt widgets. + os.environ["QT_QPA_PLATFORM"] = "offscreen" + from PySide6 import QtCore, QtGui, QtWidgets + + from .config import Paths, games + from .gui import main + from .windows import FILES, PLATFORM_FILES, runtime_dir + + output.mkdir(parents=True, exist_ok=True) + app = QtWidgets.QApplication([]) + # Offscreen Qt on Windows does not automatically discover system fonts. + fonts = Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" + for name in ("segoeui.ttf", "segoeuib.ttf"): + QtGui.QFontDatabase.addApplicationFont(str(fonts / name)) + result = {} + + def finish(): + try: + window = app._riftlift_window + if not window.grab().save(str(output / "library.png")): + raise OSError("Could not save the library render") + paths = Paths.defaults() + native = runtime_dir(paths) + missing = sorted( + name for name in FILES | PLATFORM_FILES if not (native / name).is_file() + ) + result.update( + success=not missing, + native=str(native), + missing=missing, + games=len(games(paths)), + activations=getattr(app, "_riftlift_activations", 0), + width=window.width(), + height=window.height(), + icon_loaded=not app.windowIcon().isNull(), + ) + except Exception as error: + result.update(success=False, error=str(error)) + (output / "result.json").write_text(json.dumps(result, indent=2)) + app.quit() + + QtCore.QTimer.singleShot(5000, finish) + main() + return 0 if result.get("success") else 1 From b814839f1144788505e7d8f250188425a1374e34 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:56:39 -0400 Subject: [PATCH 12/12] Document Windows installation and separate compatibility results by OS --- README.md | 20 ++++++++++++++------ docs/COMPATIBILITY.md | 34 +++++++++++++++++++--------------- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index e3c7e15..3252da7 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ use SteamVR directly or a Monado-based OpenXR setup. > [!WARNING] > **RiftLift is alpha software.** Game compatibility is still expanding. -See the [compatibility wiki](docs/COMPATIBILITY.md) for games tested with real VR output. +See the [compatibility wiki](docs/COMPATIBILITY.md) for Windows and Linux results. ## Quick start @@ -29,7 +29,14 @@ drivers or Monado. ### 1. Install RiftLift -Download `riftlift-installer.sh` from the latest GitHub release, then run: +**Windows:** Windows builds produce `RiftLift-Setup--x64.exe`. +Open the installer, choose **Install**, then launch **RiftLift** from the Start +menu. Python, Git, administrator access, and a separate runtime download are +not required. A desktop shortcut is optional. RiftLift selects your VR runtime +automatically. Re-running the installer updates the app; Windows **Installed +apps** can uninstall it while preserving your games and settings. + +**Linux:** Download `riftlift-installer.sh` from the latest GitHub release, then run: ```bash bash riftlift-installer.sh @@ -43,10 +50,11 @@ Source installs use the checkout and selected payloads directly; release artifact hashes are enforced by the generated all-in-one installer, not baked into the application source. -On Windows, run `./install-windows.ps1` from a source checkout with Python 3.12 -and Git installed, then open `RiftLift.cmd`. The same desktop app uses your -native Windows OpenXR or SteamVR runtime. Install games with Meta's PC app, -then choose **Add Game → Add a local game…** to add them to RiftLift. +For Windows source development, run `./install-windows.ps1` with Python 3.12 and +Git installed, then open `RiftLift.cmd`. To package a native build, install +PyInstaller 6.22.0 and Inno Setup 6, then run +`./scripts/build-windows.ps1 -RuntimeDirectory `. +The Windows installer workflow builds and checks the standalone distribution. ### 2. Check your setup and sign in diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md index fedf948..40befdf 100644 --- a/docs/COMPATIBILITY.md +++ b/docs/COMPATIBILITY.md @@ -1,22 +1,26 @@ # RiftLift compatibility wiki -These games have been tested successfully with RiftLift. Results can vary with Wine, driver, GPU, and game updates. +Compatibility results are listed separately for Windows and Linux. Results can vary with runtime, Wine, driver, GPU, and game updates. ## Tested games -| Game | Build tested | Status | Oculus exclusive? | Setup notes | -| --- | --- | --- | --- | --- | -| [Aircar](https://store.steampowered.com/app/1073390/Aircar/) | Steam Oculus mode | ✅ Working | No | — | -| Echo VR | Community PCVR installation | ✅ Working | Yes, originally | Requires the community installer and patch; follow the [Echo VR setup guide](https://gist.github.com/Villagers654/d5bf4d11f56fc60d1eab91e7bf3f41c5). | -| [Five Nights at Freddy's: Help Wanted](https://store.steampowered.com/app/732690/FIVE_NIGHTS_AT_FREDDYS_HELP_WANTED/) | Steam Oculus mode | ✅ Working | No | — | -| [Keep Talking and Nobody Explodes](https://store.steampowered.com/app/341800/Keep_Talking_and_Nobody_Explodes/) | Steam Oculus mode | ✅ Working | No | — | -| [Lone Echo](https://www.youtube.com/watch?v=2pmV2mwAV9k) | Meta Rift Store | ✅ Working | Yes | — | -| [Lone Echo 2](https://www.meta.com/experiences/pcvr/lone-echo-ii/1711938725528735/) | Meta Rift Store | ✅ Working | Yes | — | -| [Oculus First Contact](https://www.meta.com/experiences/pcvr/oculus-first-contact/1217155751659625/) | Meta Rift Store | ✅ Working | Yes | — | -| [StereoPaint](https://store.steampowered.com/app/1920760/StereoPaint/) | Steam | ✅ Working | Yes | — | -| [Stormland](https://www.meta.com/experiences/pcvr/stormland/1360938750683878/) | Meta Rift Store | ✅ Working | Yes | — | -| [SUPERHOT VR](https://store.steampowered.com/app/617830/SUPERHOT_VR/) | Steam Oculus mode | ✅ Working | No | — | -| [Vader Immortal: Episode I](https://www.playstation.com/en-us/games/vader-immortal-a-star-wars-vr-series/) | Meta Rift Store | ✅ Working | Yes | — | +✅ = renders a menu or scene without major rendering errors; ❌ = does not work; ⚠️ = known problems; Untested = not tested. A check does not require a full gameplay pass. + +| Game | Build tested | Windows | Linux | Oculus exclusive? | Setup notes | +| --- | --- | --- | --- | --- | --- | +| [Aircar](https://store.steampowered.com/app/1073390/Aircar/) | Steam Oculus mode | Untested | ✅ | No | — | +| Echo VR | Windows: original 34.4.636386.0; Linux: community PCVR installation | ✅ | ✅ | Yes, originally | Windows renders the original client's shutdown notice with hands. Online play requires the community installer and patch; follow the [Echo VR setup guide](https://gist.github.com/Villagers654/d5bf4d11f56fc60d1eab91e7bf3f41c5). Community client untested on Windows. | +| [Five Nights at Freddy's: Help Wanted](https://store.steampowered.com/app/732690/FIVE_NIGHTS_AT_FREDDYS_HELP_WANTED/) | Steam Oculus mode | Untested | ✅ | No | — | +| [Keep Talking and Nobody Explodes](https://store.steampowered.com/app/341800/Keep_Talking_and_Nobody_Explodes/) | Steam Oculus mode | Untested | ✅ | No | — | +| [Lone Echo](https://www.youtube.com/watch?v=2pmV2mwAV9k) | Meta Rift Store; Windows: 3.17.4 | ✅ | ✅ | Yes | Windows: stereo main menu and hands. | +| [Lone Echo 2](https://www.meta.com/experiences/pcvr/lone-echo-ii/1711938725528735/) | Meta Rift Store | Untested | ✅ | Yes | — | +| [Oculus First Contact](https://www.meta.com/experiences/pcvr/oculus-first-contact/1217155751659625/) | Meta Rift Store; Windows: 1.1.9 | ✅ | ✅ | Yes | Windows: stereo room and hands at standing height. | +| [StereoPaint](https://store.steampowered.com/app/1920760/StereoPaint/) | Steam | Untested | ✅ | Yes | — | +| [Stormland](https://www.meta.com/experiences/pcvr/stormland/1360938750683878/) | Meta Rift Store; Windows: Stormland_008 | ✅ | ✅ | Yes | Windows: stereo island scene. | +| [SUPERHOT VR](https://store.steampowered.com/app/617830/SUPERHOT_VR/) | Steam Oculus mode | Untested | ✅ | No | — | +| [Vader Immortal: Episode I](https://www.playstation.com/en-us/games/vader-immortal-a-star-wars-vr-series/) | Meta Rift Store; Windows: 1.1.0 | ✅ | ✅ | Yes | Windows: VR settings menu and hands; simulated button input advances setup. | + +Windows results use the local `windows-native` development build with a simulated headset. Images were inspected from game render textures while the desktop was locked; headset presentation and full interactive gameplay remain unverified. These results do not establish compatibility for the pinned release. ## What “Oculus exclusive” means here @@ -36,4 +40,4 @@ riftlift doctor The command includes concise setup details and recent launch evidence, then creates a shareable diagnostic paste. Attach that result when opening a [compatibility report](https://github.com/Villagers654/RiftLift/issues). -Last updated: August 2026. +Last updated: September 2, 2026.