diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b43fee..e52a6c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## Unreleased +### Fixed +- Windows portable ZIPs now keep Portkey Drop data alongside the extracted app folder so settings, sites, sounds, and portable credentials stay with the portable copy. + ### Changed - Development builds now report the next 0.5.1 development version after the 0.5.0 stable release. diff --git a/installer/build.py b/installer/build.py index 3cb1ebd..78369e1 100644 --- a/installer/build.py +++ b/installer/build.py @@ -38,6 +38,9 @@ DIST_DIR = ROOT / "dist" BUILD_DIR = ROOT / "build" RESOURCES_DIR = SRC_DIR / "portkeydrop" / "resources" +DEFAULT_SOUNDPACKS_DIR = SRC_DIR / "portkeydrop" / "default_soundpacks" +PORTABLE_DEFAULT_SOUNDPACK_DIR = Path("data") / "soundpacks" / "default" +PORTABLE_DEFAULT_SOUNDPACK_MANIFEST = PORTABLE_DEFAULT_SOUNDPACK_DIR / "pack.json" # Platform detection IS_WINDOWS = platform.system() == "Windows" @@ -396,29 +399,23 @@ def create_portable_zip() -> bool: version = get_version() - staging_dir: Path | None = None - if IS_WINDOWS: # Look for directory distribution first, then single exe - source_dir = DIST_DIR / "PortkeyDrop_dir" - if not source_dir.exists(): + app_source_dir = DIST_DIR / "PortkeyDrop_dir" + source_dir = DIST_DIR / "PortkeyDrop" + if source_dir.exists(): + shutil.rmtree(source_dir) + if app_source_dir.exists(): + shutil.copytree(app_source_dir, source_dir) + else: # Single exe - create a directory for it exe_path = DIST_DIR / "PortkeyDrop.exe" if exe_path.exists(): - source_dir = DIST_DIR / "PortkeyDrop_portable" source_dir.mkdir(exist_ok=True) shutil.copy2(exe_path, source_dir / "PortkeyDrop.exe") - staging_dir = source_dir else: print("Error: No build output found") return False - else: - # Keep installer input untouched; stage a separate portable tree. - staging_dir = DIST_DIR / "PortkeyDrop_portable" - if staging_dir.exists(): - shutil.rmtree(staging_dir, ignore_errors=True) - shutil.copytree(source_dir, staging_dir) - source_dir = staging_dir zip_name = f"PortkeyDrop_Portable_v{version}" elif IS_MACOS: @@ -440,21 +437,56 @@ def create_portable_zip() -> bool: if Path(f"{zip_path}.zip").exists(): Path(f"{zip_path}.zip").unlink() - try: - # Create data/ directory to activate portable mode after extraction. - data_dir = source_dir / "data" - data_dir.mkdir(exist_ok=True) + if IS_WINDOWS: + (source_dir / ".portable").write_text("1\n", encoding="utf-8") + (source_dir / "data").mkdir(exist_ok=True) + try: + _stage_default_soundpack_for_portable(source_dir) + _assert_portable_soundpack_staged(source_dir) + except RuntimeError as exc: + print(f"Error: {exc}") + return False - # Create zip - shutil.make_archive(str(zip_path), "zip", source_dir.parent, source_dir.name) - finally: - if staging_dir and staging_dir.exists(): - shutil.rmtree(staging_dir, ignore_errors=True) + shutil.make_archive(str(zip_path), "zip", source_dir.parent, source_dir.name) print(f"\n✓ Portable ZIP created: {zip_path}.zip") return True +def _candidate_default_soundpack_dirs(portable_root: Path) -> list[Path]: + return [ + portable_root / PORTABLE_DEFAULT_SOUNDPACK_DIR, + portable_root / "portkeydrop" / "default_soundpacks" / "default", + DEFAULT_SOUNDPACKS_DIR / "default", + ] + + +def _stage_default_soundpack_for_portable(portable_root: Path) -> Path: + """Ensure the portable layout contains data/soundpacks/default/pack.json.""" + target_dir = portable_root / PORTABLE_DEFAULT_SOUNDPACK_DIR + for candidate in _candidate_default_soundpack_dirs(portable_root): + if candidate == target_dir or not (candidate / "pack.json").is_file(): + continue + if target_dir.exists(): + shutil.rmtree(target_dir) + target_dir.parent.mkdir(parents=True, exist_ok=True) + shutil.copytree(candidate, target_dir) + return target_dir + return target_dir + + +def _assert_portable_soundpack_staged(portable_root: Path) -> Path: + """Fail loudly when the staged portable tree lacks the default sound pack manifest.""" + manifest_path = portable_root / PORTABLE_DEFAULT_SOUNDPACK_MANIFEST + if not manifest_path.is_file(): + searched = ", ".join(str(path) for path in _candidate_default_soundpack_dirs(portable_root)) + raise RuntimeError( + "Portable ZIP is missing default/pack.json at the expected portable path " + f"{manifest_path}. Searched: {searched}" + ) + return manifest_path + + def clean_build() -> None: """Clean all build artifacts.""" print("Cleaning build artifacts...") diff --git a/scripts/verify_portable_zip.py b/scripts/verify_portable_zip.py index 9386e72..84608fb 100644 --- a/scripts/verify_portable_zip.py +++ b/scripts/verify_portable_zip.py @@ -65,17 +65,23 @@ def verify_portable_zip(zip_path: Path) -> tuple[bool, list[str]]: errors: list[str] = [] + if "PortkeyDrop/PortkeyDrop.exe" not in entries: + errors.append("missing PortkeyDrop/PortkeyDrop.exe") + + if "PortkeyDrop/.portable" not in entries: + errors.append("missing PortkeyDrop/.portable marker") + has_portable_data_dir = any( - name == "data/" or name.startswith("data/") or "/data/" in name for name in entries + name == "PortkeyDrop/data/" or name.startswith("PortkeyDrop/data/") for name in entries ) if not has_portable_data_dir: - errors.append("missing required data/ directory contents") + errors.append("missing required PortkeyDrop/data/ directory contents") if errors: return False, errors print(f"Validated portable zip: {zip_path}") - print("Found data/ directory contents for portable mode") + print("Found PortkeyDrop/data/ directory contents for portable mode") return True, [] diff --git a/src/portkeydrop/portable.py b/src/portkeydrop/portable.py index 0a16570..d703399 100644 --- a/src/portkeydrop/portable.py +++ b/src/portkeydrop/portable.py @@ -9,11 +9,15 @@ def is_portable_mode() -> bool: """Return True if the app is running in portable mode. - Portable mode is active when a ``data/`` directory or a ``portable.txt`` - file exists alongside ``sys.executable``. + Portable mode is active when a ``.portable`` marker, ``data/`` directory, + or legacy ``portable.txt`` file exists alongside ``sys.executable``. """ exe_dir = Path(sys.executable).parent - return (exe_dir / "data").is_dir() or (exe_dir / "portable.txt").is_file() + return ( + (exe_dir / ".portable").is_file() + or (exe_dir / "data").is_dir() + or (exe_dir / "portable.txt").is_file() + ) def get_config_dir() -> Path: diff --git a/tests/test_nuitka_build.py b/tests/test_nuitka_build.py index f515146..c386f30 100644 --- a/tests/test_nuitka_build.py +++ b/tests/test_nuitka_build.py @@ -276,5 +276,39 @@ def test_windows_portable_zip_uses_separate_staging_dir(tmp_path, monkeypatch) - with zipfile.ZipFile(zip_path) as archive: names = set(archive.namelist()) - assert "PortkeyDrop_portable/PortkeyDrop.exe" in names - assert "PortkeyDrop_portable/data/" in names + assert "PortkeyDrop/PortkeyDrop.exe" in names + assert "PortkeyDrop/.portable" in names + assert "PortkeyDrop/data/" in names + + +def test_windows_portable_zip_stages_accessiweather_style_layout(tmp_path, monkeypatch) -> None: + dist_dir = tmp_path / "dist" + installer_stage = dist_dir / "PortkeyDrop_dir" + packaged_default = installer_stage / "portkeydrop" / "default_soundpacks" / "default" + packaged_default.mkdir(parents=True) + (installer_stage / "PortkeyDrop.exe").write_bytes(b"fake-exe") + (packaged_default / "pack.json").write_text("{}", encoding="utf-8") + + monkeypatch.setattr(build, "DIST_DIR", dist_dir) + monkeypatch.setattr(build, "IS_WINDOWS", True) + monkeypatch.setattr(build, "IS_MACOS", False) + monkeypatch.setattr(build, "IS_LINUX", False) + + assert build.create_portable_zip() is True + + portable_stage = dist_dir / "PortkeyDrop" + zip_path = dist_dir / f"PortkeyDrop_Portable_v{build.get_version()}.zip" + assert portable_stage.is_dir() + assert (portable_stage / ".portable").is_file() + assert (portable_stage / "data").is_dir() + assert (portable_stage / "data" / "soundpacks" / "default" / "pack.json").is_file() + + assert not (installer_stage / ".portable").exists() + assert not (installer_stage / "data").exists() + + with zipfile.ZipFile(zip_path) as archive: + names = set(archive.namelist()) + + assert "PortkeyDrop/PortkeyDrop.exe" in names + assert "PortkeyDrop/.portable" in names + assert "PortkeyDrop/data/soundpacks/default/pack.json" in names diff --git a/tests/test_portable.py b/tests/test_portable.py index 03eb58f..b27dc0b 100644 --- a/tests/test_portable.py +++ b/tests/test_portable.py @@ -9,6 +9,14 @@ class TestIsPortableMode: + def test_true_when_dot_portable_marker_exists(self, tmp_path: Path): + (tmp_path / ".portable").write_text("1") + fake_exe = tmp_path / "portkeydrop.exe" + fake_exe.touch() + with patch("portkeydrop.portable.sys") as mock_sys: + mock_sys.executable = str(fake_exe) + assert is_portable_mode() is True + def test_true_when_data_dir_exists(self, tmp_path: Path): (tmp_path / "data").mkdir() fake_exe = tmp_path / "portkeydrop.exe" diff --git a/tests/test_verify_portable_zip.py b/tests/test_verify_portable_zip.py index 5e32ea4..437cb8c 100644 --- a/tests/test_verify_portable_zip.py +++ b/tests/test_verify_portable_zip.py @@ -5,13 +5,26 @@ from scripts.verify_portable_zip import verify_portable_zip -def test_verify_portable_zip_accepts_nested_portable_data_dir(tmp_path) -> None: +def test_verify_portable_zip_accepts_accessiweather_style_portable_layout(tmp_path) -> None: zip_path = tmp_path / "PortkeyDrop_Portable_v0.3.0.zip" with zipfile.ZipFile(zip_path, "w") as archive: - archive.writestr("PortkeyDrop_portable/PortkeyDrop.exe", b"fake") - archive.writestr("PortkeyDrop_portable/data/", b"") + archive.writestr("PortkeyDrop/PortkeyDrop.exe", b"fake") + archive.writestr("PortkeyDrop/.portable", "1\n") + archive.writestr("PortkeyDrop/data/", b"") ok, errors = verify_portable_zip(zip_path) assert ok is True assert errors == [] + + +def test_verify_portable_zip_rejects_legacy_portable_root_without_marker(tmp_path) -> None: + zip_path = tmp_path / "PortkeyDrop_Portable_v0.3.0.zip" + with zipfile.ZipFile(zip_path, "w") as archive: + archive.writestr("PortkeyDrop_portable/PortkeyDrop.exe", b"fake") + archive.writestr("PortkeyDrop_portable/data/", b"") + + ok, errors = verify_portable_zip(zip_path) + + assert ok is False + assert "missing PortkeyDrop/.portable marker" in errors