Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
76 changes: 54 additions & 22 deletions installer/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand All @@ -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...")
Expand Down
12 changes: 9 additions & 3 deletions scripts/verify_portable_zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, []


Expand Down
10 changes: 7 additions & 3 deletions src/portkeydrop/portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
38 changes: 36 additions & 2 deletions tests/test_nuitka_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions tests/test_portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
19 changes: 16 additions & 3 deletions tests/test_verify_portable_zip.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading