diff --git a/tools/duckstation_oracle.py b/tools/duckstation_oracle.py index ba172102..85a88a29 100644 --- a/tools/duckstation_oracle.py +++ b/tools/duckstation_oracle.py @@ -159,13 +159,23 @@ def log(msg: str) -> None: # --------------------------------------------------------------------------- def data_root() -> Path: - """The RetComM shared data root — same one the launcher and Studio use.""" + """The RetComM shared data root — same one the launcher and Studio use. + + The precedence must match studio_runner.cpp's retcomm_data_dir() exactly, + XDG_DATA_HOME included. If the two disagree, the GUI reports an oracle + that is not where this tool installed it, and neither side is obviously + wrong — the worst kind of bug to chase. + """ env = os.environ.get("RETCOMM_DATA_DIR") if env: return Path(env).expanduser() if sys.platform == "win32": - base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + base = (os.environ.get("LOCALAPPDATA") + or os.environ.get("USERPROFILE", str(Path.home())) + "/AppData/Local") return Path(base) / "retcomm" + xdg = os.environ.get("XDG_DATA_HOME") + if xdg: + return Path(xdg).expanduser() / "retcomm" return Path.home() / ".local" / "share" / "retcomm" @@ -958,8 +968,64 @@ def state(lay: Layout) -> Dict[str, str]: return out +def status_doc(lay: Layout) -> Dict[str, Any]: + """Everything a caller (or a GUI) needs to decide what button to offer.""" + pin = load_pin() + doc: Dict[str, Any] = {} + if lay.manifest.is_file(): + try: + with open(lay.manifest, "r", encoding="utf-8") as f: + doc.update(json.load(f)) + except (OSError, json.JSONDecodeError): + pass + st = state(lay) + running = st["running"] != "no" + answering = st["running"].startswith("yes") + doc.update({ + # Set AFTER the manifest merge: oracle.json carries its own `kind` + # ("psxrecomp-duckstation-oracle") and would otherwise overwrite this + # one, so every installed oracle would report a document Studio then + # rejects as foreign — while an uninstalled one looked fine. + "kind": "psxrecomp-oracle-status", + "version": 1, + "root": str(lay.root), + "app": str(lay.app), + "binary": str(lay.binary), + "launcher": str(lay.launcher), + "port": int(pin.get("oracle_port", 4371)), + "source": st["source"] == "present", + "deps": st["deps"] == "present", + "patch_applied": st["patch"] == "applied", + "built": st["built"] == "yes", + "installed": st["installed"] == "yes", + "running": running, + "answering": answering, + "running_detail": st["running"], + "container_needed": bool(refused_environment()), + "container_reason": refused_environment(), + "container_engine": container_engine(), + }) + # One word for a status line, so a GUI does not re-derive the ladder. + if answering: + doc["state"] = "answering" + elif running: + doc["state"] = "port-busy" + elif doc["installed"]: + doc["state"] = "installed" + elif doc["built"]: + doc["state"] = "built" + elif doc["source"]: + doc["state"] = "fetched" + else: + doc["state"] = "absent" + return doc + + def cmd_status(args: argparse.Namespace) -> int: lay = Layout(Path(args.root) if args.root else None) + if args.json: + print(json.dumps(status_doc(lay), indent=1)) + return 0 print(f"root: {lay.root}") if lay.manifest.is_file(): with open(lay.manifest, "r", encoding="utf-8") as f: @@ -1141,6 +1207,8 @@ def main(argv=None) -> int: p.set_defaults(func=cmd_stop) p = sub.add_parser("status", help="where it is and what state it is in") + p.add_argument("--json", action="store_true", + help="machine-readable, for RetComM Studio") p.set_defaults(func=cmd_status) p = sub.add_parser("path", help="print the install root") @@ -1151,7 +1219,7 @@ def main(argv=None) -> int: for attr, default in (("cc", None), ("cxx", None), ("jobs", 0), ("lto", False), ("reconfigure", False), ("cmake_arg", None), ("force", False), ("bios", None), ("root", None), - ("wayland", False), ("container", None)): + ("wayland", False), ("container", None), ("json", False)): if not hasattr(args, attr): setattr(args, attr, default) try: diff --git a/tools/tests/test_duckstation_oracle.py b/tools/tests/test_duckstation_oracle.py index e92df96b..67e73618 100644 --- a/tools/tests/test_duckstation_oracle.py +++ b/tools/tests/test_duckstation_oracle.py @@ -50,6 +50,24 @@ class TestLocations(EnvGuard): self.assertEqual(DSO.oracle_root(), Path("/tmp/rc-test/oracle/duckstation")) self.assertEqual(DSO.download_cache(), Path("/tmp/rc-test/downloads")) + def test_xdg_data_home_is_honoured_like_studio_does(self): + """studio_runner.cpp's retcomm_data_dir() checks XDG_DATA_HOME before + ~/.local/share. If this tool did not, the GUI would look for an oracle + somewhere other than where the tool installed it, and neither side would + look wrong.""" + os.environ.pop("RETCOMM_ORACLE_DIR", None) + os.environ.pop("RETCOMM_DATA_DIR", None) + os.environ["XDG_DATA_HOME"] = "/tmp/xdg-test" + self.assertEqual(DSO.data_root(), Path("/tmp/xdg-test/retcomm")) + self.assertEqual(DSO.oracle_root(), + Path("/tmp/xdg-test/retcomm/oracle/duckstation")) + + def test_retcomm_data_dir_beats_xdg(self): + os.environ.pop("RETCOMM_ORACLE_DIR", None) + os.environ["XDG_DATA_HOME"] = "/tmp/xdg-test" + os.environ["RETCOMM_DATA_DIR"] = "/tmp/explicit" + self.assertEqual(DSO.data_root(), Path("/tmp/explicit")) + def test_oracle_dir_override_wins(self): os.environ["RETCOMM_DATA_DIR"] = "/tmp/rc-test" os.environ["RETCOMM_ORACLE_DIR"] = "/tmp/elsewhere" @@ -174,6 +192,55 @@ class TestSettingsMerge(unittest.TestCase): self.assertIn("Renderer = Software", p.read_text()) +class TestStatusDoc(EnvGuard): + """The status document is the whole contract with RetComM Studio.""" + + def test_absent_install_reports_absent(self): + os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp() + doc = DSO.status_doc(DSO.Layout()) + self.assertEqual(doc["kind"], "psxrecomp-oracle-status") + self.assertEqual(doc["state"], "absent") + for k in ("source", "deps", "built", "installed", "running", "answering"): + self.assertFalse(doc[k], k) + self.assertIn("port", doc) + self.assertIn("container_needed", doc) + + def test_state_ladder_is_ordered(self): + """A GUI picks its button off `state`, so the ladder must not skip.""" + os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp() + lay = DSO.Layout() + lay.src.mkdir(parents=True) + (lay.src / "CMakeLists.txt").write_text("") + self.assertEqual(DSO.status_doc(lay)["state"], "fetched") + (lay.build / "bin").mkdir(parents=True) + (lay.build / "bin" / DSO.exe_name()).write_text("") + self.assertEqual(DSO.status_doc(lay)["state"], "built") + lay.app.mkdir(parents=True) + (lay.app / DSO.exe_name()).write_text("") + self.assertEqual(DSO.status_doc(lay)["state"], "installed") + + def test_kind_survives_an_existing_manifest(self): + """oracle.json has its own `kind`. Merging it must not overwrite the + status document's, or every INSTALLED oracle reports a document Studio + rejects as foreign — while an uninstalled one parses fine, so the bug + only appears once things are working.""" + os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp() + lay = DSO.Layout() + lay.root.mkdir(parents=True, exist_ok=True) + lay.manifest.write_text(json.dumps({ + "kind": "psxrecomp-duckstation-oracle", + "version": 1, + "upstream_base": "deadbeef", + })) + doc = DSO.status_doc(lay) + self.assertEqual(doc["kind"], "psxrecomp-oracle-status") + self.assertEqual(doc["upstream_base"], "deadbeef") # manifest data kept + + def test_json_is_serialisable(self): + os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp() + json.dumps(DSO.status_doc(DSO.Layout())) + + class TestPin(unittest.TestCase): def test_pin_is_the_single_source_of_truth(self): pin = DSO.load_pin()