diff --git a/src-tauri/installer.nsh b/src-tauri/installer.nsh new file mode 100644 index 0000000..b02ce27 --- /dev/null +++ b/src-tauri/installer.nsh @@ -0,0 +1,44 @@ +; Tauri 2 NSIS installer hooks for the Windows ChaosEngineAI bundle. +; +; Tauri's default NSIS template installs the app under +; %LOCALAPPDATA%\\ and the uninstaller removes that tree on +; uninstall. The GPU runtime bundle (torch + diffusers + transformers, +; ~2.5 GB) is intentionally written to a sibling directory: +; +; %LOCALAPPDATA%\ChaosEngineAI\extras\cp{major}{minor}\site-packages +; +; The path is namespaced by Python ABI tag (commit 24518af, v0.7.0-rc.5) +; so a runtime upgrade that changes Python minor versions cannot shadow +; the wheels from the previous tag. +; +; CRITICAL: this directory MUST survive an uninstall + reinstall cycle. +; Re-downloading 2.5 GB of CUDA wheels every time the user upgrades the +; desktop app is unacceptable, both for users on slow links and for the +; PyPI mirrors that serve the bundle. +; +; The hooks below are intentionally empty as a guardrail. If anyone +; later adds custom uninstall behaviour: +; +; 1. NEVER ``RMDir /r "$LOCALAPPDATA\ChaosEngineAI\extras"`` here. +; 2. Test that ``setup.py:_extras_site_packages()`` resolves the same +; path before AND after a clean uninstall + reinstall on Windows. +; 3. Mirror any change in ``src-tauri/src/lib.rs::chaosengine_extras_root``. + +!macro NSIS_HOOK_PREINSTALL + ; Reserved — currently a no-op. See contract above before adding code. +!macroend + +!macro NSIS_HOOK_POSTINSTALL + ; Reserved — currently a no-op. See contract above before adding code. +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + ; Reserved — currently a no-op. See contract above before adding code. +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + ; Reserved — currently a no-op. The persistent GPU runtime tree at + ; %LOCALAPPDATA%\ChaosEngineAI\extras MUST be left intact so an + ; immediate reinstall can pick it up without re-downloading 2.5 GB. + ; See contract above before adding code. +!macroend diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4f29137..ddbe60b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -697,6 +697,12 @@ fn apply_embedded_runtime_env(command: &mut Command, runtime: &EmbeddedRuntime) /// Returns ``None`` if we can't resolve a home directory at all (headless /// environments). Callers treat that as "no extras available". fn chaosengine_extras_root() -> Option { + // The extras tree lives OUTSIDE the Tauri install directory so it + // survives uninstall + reinstall cycles — re-downloading the 2.5 GB + // GPU bundle on every desktop upgrade is unacceptable. The Windows + // NSIS installer is told to leave this path alone via the empty + // hooks in ``src-tauri/installer.nsh``; if anyone changes either + // side the other MUST be kept in sync. let base = if cfg!(windows) { env::var_os("LOCALAPPDATA") .map(PathBuf::from) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ea1faa0..c38d18f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -52,6 +52,11 @@ "hardenedRuntime": true, "entitlements": "macos/ChaosEngineAI.entitlements" }, + "windows": { + "nsis": { + "installerHooks": "./installer.nsh" + } + }, "resources": { "resources/": "" } diff --git a/src/styles.css b/src/styles.css index ec62ad7..d68b611 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6269,6 +6269,15 @@ select.text-input { width: 100%; max-width: 100%; overflow: hidden; + /* Establishes a stacking context so the streaming pip output stays + * above the Prompt + Recent Outputs cards in Image Studio and Video + * Studio. Without these the panel renders behind those siblings on + * Windows during a long GPU bundle install — the log is still alive + * but the user can't see it. ``z-index: 5`` is enough to win against + * the surrounding ``.panel`` cards (which set no z-index of their + * own) without fighting the global tooltip portal (z-index: 1000+). */ + position: relative; + z-index: 5; } .install-log-summary { cursor: pointer; diff --git a/tests/test_extras_path.py b/tests/test_extras_path.py new file mode 100644 index 0000000..aac78ab --- /dev/null +++ b/tests/test_extras_path.py @@ -0,0 +1,63 @@ +"""Pin the persistent GPU extras path on Windows / Linux / macOS. + +The desktop installer is configured to leave this directory alone on +uninstall (``src-tauri/installer.nsh`` on Windows; macOS uninstall is +``rm /Applications/ChaosEngineAI.app`` which doesn't touch +``~/Library/Application Support``). If the path computed by +``_extras_site_packages`` ever drifts from what the installer hooks +expect, the uninstall safety net breaks. + +The tests below pin both halves of the contract — the parent directory +and the ABI tag layout — so any future move is loud. +""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path +from unittest import mock + +from backend_service.routes import setup as setup_routes + + +class ExtrasSitePackagesTests(unittest.TestCase): + def setUp(self) -> None: + self._env_patcher = mock.patch.dict(os.environ, {}, clear=False) + self._env_patcher.start() + # Drop the override knob — the explicit env path is for tests + # that pin a custom location, not for the cross-OS shape check. + os.environ.pop("CHAOSENGINE_EXTRAS_SITE_PACKAGES", None) + + def tearDown(self) -> None: + self._env_patcher.stop() + + def test_path_includes_chaosengine_extras_namespace(self) -> None: + path = setup_routes._extras_site_packages() + self.assertIsNotNone(path) + assert path is not None # type narrow + parts = path.parts + # 'ChaosEngineAI/extras/cp{maj}{min}/site-packages' suffix. + # The tree above (LOCALAPPDATA / Library/Application Support / + # XDG_DATA_HOME) is platform-specific; we only assert the tail. + self.assertEqual(parts[-4], "ChaosEngineAI") + self.assertEqual(parts[-3], "extras") + self.assertTrue(parts[-2].startswith("cp")) + self.assertEqual(parts[-1], "site-packages") + + def test_python_abi_tag_matches_runtime(self) -> None: + path = setup_routes._extras_site_packages() + assert path is not None + expected_tag = f"cp{sys.version_info.major}{sys.version_info.minor}" + self.assertEqual(path.parts[-2], expected_tag) + + def test_env_override_wins(self) -> None: + override = "/tmp/chaosengine-extras-override" + os.environ["CHAOSENGINE_EXTRAS_SITE_PACKAGES"] = override + path = setup_routes._extras_site_packages() + self.assertEqual(path, Path(override)) + + +if __name__ == "__main__": + unittest.main()