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
97 changes: 87 additions & 10 deletions scripts/ci/package_windows_portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass
from datetime import datetime
import json
import os
import pathlib
import re
import shutil
Expand Down Expand Up @@ -36,6 +37,7 @@
"""
TAURI_CONFIG_RELATIVE_PATH = pathlib.Path("src-tauri") / "tauri.conf.json"
CARGO_TOML_RELATIVE_PATH = pathlib.Path("src-tauri") / "Cargo.toml"
# These point to the source resource directories inside the repository checkout.
BACKEND_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "backend"
WEBUI_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "webui"
WINDOWS_CLEANUP_SCRIPT_RELATIVE_PATH = (
Expand All @@ -52,6 +54,8 @@ class ProjectConfig:
product_name: str
binary_name: str
portable_marker_name: str
backend_layout_relative_path: pathlib.Path
webui_layout_relative_path: pathlib.Path


def normalize_arch(arch: str) -> str:
Expand Down Expand Up @@ -99,16 +103,79 @@ def load_portable_runtime_marker(project_root: pathlib.Path) -> str:
return marker_name


def resolve_bundle_resource_alias_from_tauri_config(
project_root: pathlib.Path,
tauri_config: dict,
source_relative_path: pathlib.Path,
) -> pathlib.Path:
# Keep validation rules aligned with src-tauri/build.rs::load_bundle_resource_alias.
bundle_table = tauri_config.get("bundle")
if not isinstance(bundle_table, dict):
raise ValueError(f"Missing bundle object in {TAURI_CONFIG_RELATIVE_PATH}")

resources_table = bundle_table.get("resources")
if not isinstance(resources_table, dict):
raise ValueError(
f"Missing bundle.resources object in {TAURI_CONFIG_RELATIVE_PATH}"
)

tauri_config_dir = (project_root / TAURI_CONFIG_RELATIVE_PATH).parent.resolve()
expected_source_path = (project_root / source_relative_path).resolve()
expected_source_key = pathlib.PureWindowsPath(
os.path.relpath(expected_source_path, tauri_config_dir)
).as_posix()
alias_text = resources_table.get(expected_source_key)
if alias_text is None:
raise ValueError(
"Missing bundle.resources alias for "
f"{expected_source_key} in {TAURI_CONFIG_RELATIVE_PATH}"
)

if not isinstance(alias_text, str):
raise ValueError(
"bundle.resources alias for "
f"{expected_source_key} must be a string in {TAURI_CONFIG_RELATIVE_PATH}"
)

alias_path = pathlib.Path(alias_text.strip())
if not alias_path.parts or alias_path.is_absolute():
raise ValueError(
"bundle.resources alias for "
f"{expected_source_key} must be a relative path in "
f"{TAURI_CONFIG_RELATIVE_PATH}: {alias_text}"
)
if any(part in (".", "..") for part in alias_path.parts):
raise ValueError(
"bundle.resources alias for "
f"{expected_source_key} must be a relative path without traversal in "
f"{TAURI_CONFIG_RELATIVE_PATH}: {alias_text}"
)
return alias_path


def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig:
project_root = resolve_project_root_from(start_path)
product_name = resolve_product_name(project_root)
tauri_config = load_tauri_config(project_root)
product_name = resolve_product_name_from_tauri_config(tauri_config)
binary_name = load_binary_name_from_cargo(project_root)
portable_marker_name = load_portable_runtime_marker(project_root)
backend_layout_relative_path = resolve_bundle_resource_alias_from_tauri_config(
project_root,
tauri_config,
BACKEND_RESOURCE_RELATIVE_PATH,
)
webui_layout_relative_path = resolve_bundle_resource_alias_from_tauri_config(
project_root,
tauri_config,
WEBUI_RESOURCE_RELATIVE_PATH,
)
return ProjectConfig(
root=project_root,
product_name=product_name,
binary_name=binary_name,
portable_marker_name=portable_marker_name,
backend_layout_relative_path=backend_layout_relative_path,
webui_layout_relative_path=webui_layout_relative_path,
)


Expand Down Expand Up @@ -212,8 +279,7 @@ def load_binary_name_from_cargo(project_root: pathlib.Path) -> str:
return binary_name


def resolve_product_name(project_root: pathlib.Path) -> str:
config = load_tauri_config(project_root)
def resolve_product_name_from_tauri_config(config: dict) -> str:
product_name = str(config.get("productName", "")).strip()
if not product_name:
raise ValueError(f"Missing productName in {TAURI_CONFIG_RELATIVE_PATH}")
Expand All @@ -227,6 +293,10 @@ def resolve_product_name(project_root: pathlib.Path) -> str:
return product_name


def resolve_product_name(project_root: pathlib.Path) -> str:
return resolve_product_name_from_tauri_config(load_tauri_config(project_root))


def resolve_release_dir(bundle_dir: pathlib.Path) -> pathlib.Path:
return bundle_dir.parent.parent

Expand Down Expand Up @@ -263,19 +333,22 @@ def populate_portable_root(
if cleanup_script.is_file():
shutil.copy2(cleanup_script, destination_root / "kill-backend-processes.ps1")

resources_root = destination_root / "resources"
backend_src = project_config.root / BACKEND_RESOURCE_RELATIVE_PATH
if not backend_src.is_dir():
raise FileNotFoundError(f"Required directory not found: {backend_src}")
shutil.copytree(backend_src, resources_root / "backend")
shutil.copytree(
backend_src, destination_root / project_config.backend_layout_relative_path
)

webui_src = project_config.root / WEBUI_RESOURCE_RELATIVE_PATH
if not webui_src.is_dir():
raise FileNotFoundError(f"Required directory not found: {webui_src}")
shutil.copytree(webui_src, resources_root / "webui")
shutil.copytree(
webui_src, destination_root / project_config.webui_layout_relative_path
)

add_portable_runtime_files(destination_root, project_config)
validate_portable_root(destination_root)
validate_portable_root(destination_root, project_config)


def add_portable_runtime_files(
Expand All @@ -290,10 +363,14 @@ def add_portable_runtime_files(
)


def validate_portable_root(destination_root: pathlib.Path) -> None:
def validate_portable_root(
destination_root: pathlib.Path, project_config: ProjectConfig | None = None
) -> None:
if project_config is None:
project_config = load_project_config()
expected_paths = [
destination_root / "resources" / "backend" / "runtime-manifest.json",
destination_root / "resources" / "webui" / "index.html",
destination_root / project_config.backend_layout_relative_path / "runtime-manifest.json",
destination_root / project_config.webui_layout_relative_path / "index.html",
]
missing = [
str(path.relative_to(destination_root))
Expand Down
146 changes: 132 additions & 14 deletions scripts/ci/test_package_windows_portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import tempfile
import unittest
from pathlib import Path
from unittest import mock

from scripts.ci import package_windows_portable as MODULE

Expand All @@ -14,17 +15,30 @@ def make_project_layout(
product_name: str = "AstrBot",
cargo_toml: str = '[package]\nname = "astrbot-desktop-tauri"\n',
marker_name: str = "portable.flag\n",
tauri_resources: dict[str, str] | None = None,
) -> dict[str, Path]:
project_root = Path(self.enterContext(tempfile.TemporaryDirectory()))
script_path = project_root / "scripts" / "ci" / "package_windows_portable.py"
tauri_config_path = project_root / "src-tauri" / "tauri.conf.json"
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH
if tauri_resources is None:
tauri_resources = {
"../resources/backend": "backend",
"../resources/webui": "webui",
}

script_path.parent.mkdir(parents=True)
script_path.write_text("# placeholder")
tauri_config_path.parent.mkdir(parents=True)
tauri_config_path.write_text(json.dumps({"productName": product_name}))
tauri_config_path.write_text(
json.dumps(
{
"productName": product_name,
"bundle": {"resources": tauri_resources},
}
)
)
cargo_toml_path.write_text(cargo_toml)
marker_path.parent.mkdir(parents=True, exist_ok=True)
marker_path.write_text(marker_name)
Expand Down Expand Up @@ -198,6 +212,54 @@ def test_load_project_config_from_returns_root_product_and_marker(self):
self.assertEqual(project_config.product_name, "AstrBot")
self.assertEqual(project_config.binary_name, "astrbot-desktop-tauri")
self.assertEqual(project_config.portable_marker_name, "portable.flag")
self.assertEqual(project_config.backend_layout_relative_path, Path("backend"))
self.assertEqual(project_config.webui_layout_relative_path, Path("webui"))

def test_load_project_config_from_reads_portable_layout_aliases_from_tauri_resources(
self,
):
layout = self.make_project_layout(
tauri_resources={
"../resources/backend": "runtime/backend",
"../resources/webui": "runtime/webui",
}
)

project_config = MODULE.load_project_config_from(layout["script_path"])

self.assertEqual(
project_config.backend_layout_relative_path, Path("runtime/backend")
)
self.assertEqual(
project_config.webui_layout_relative_path, Path("runtime/webui")
)

def test_load_project_config_from_requires_exact_tauri_resource_source_keys(self):
layout = self.make_project_layout(
tauri_resources={
"./../resources/backend": "runtime/backend",
"../resources/webui": "runtime/webui",
}
)

with self.assertRaisesRegex(
ValueError,
re.escape("Missing bundle.resources alias for ../resources/backend"),
):
MODULE.load_project_config_from(layout["script_path"])

def test_load_project_config_from_normalizes_windows_relpath_separators(self):
layout = self.make_project_layout()

with mock.patch.object(
MODULE.os.path,
"relpath",
side_effect=[r"..\resources\backend", r"..\resources\webui"],
):
project_config = MODULE.load_project_config_from(layout["script_path"])

self.assertEqual(project_config.backend_layout_relative_path, Path("backend"))
self.assertEqual(project_config.webui_layout_relative_path, Path("webui"))

def test_normalize_legacy_nightly_version_returns_base_version_and_suffix(self):
self.assertEqual(
Expand Down Expand Up @@ -360,6 +422,8 @@ def test_resolve_main_executable_path_uses_binary_name_not_product_name(self):
product_name="AstrBot",
binary_name="astrbot-desktop-tauri",
portable_marker_name="portable.flag",
backend_layout_relative_path=Path("backend"),
webui_layout_relative_path=Path("webui"),
)

self.assertEqual(
Expand Down Expand Up @@ -424,12 +488,20 @@ def test_populate_portable_root_copies_release_bundle_contents(self):
self.assertTrue((destination_root / "WebView2Loader.dll").is_file())
self.assertTrue(
(
destination_root / "resources" / "backend" / "runtime-manifest.json"
destination_root
/ project_config.backend_layout_relative_path
/ "runtime-manifest.json"
).is_file()
)
self.assertTrue(
(destination_root / "resources" / "webui" / "index.html").is_file()
(
destination_root
/ project_config.webui_layout_relative_path
/ "index.html"
).is_file()
)
self.assertFalse((destination_root / "resources" / "backend").exists())
self.assertFalse((destination_root / "resources" / "webui").exists())
self.assertTrue((destination_root / "kill-backend-processes.ps1").is_file())
self.assertTrue((destination_root / "portable.flag").is_file())
self.assertTrue((destination_root / MODULE.PORTABLE_README_NAME).is_file())
Expand Down Expand Up @@ -466,6 +538,8 @@ def test_add_portable_runtime_files_writes_marker_and_readme(self):
product_name="AstrBot",
binary_name="astrbot-desktop-tauri",
portable_marker_name="portable.flag",
backend_layout_relative_path=Path("backend"),
webui_layout_relative_path=Path("webui"),
)

MODULE.add_portable_runtime_files(root, project_config)
Expand All @@ -477,34 +551,78 @@ def test_add_portable_runtime_files_writes_marker_and_readme(self):
)

def test_validate_portable_root_accepts_expected_layout(self):
layout = self.make_project_layout()
project_config = MODULE.load_project_config_from(layout["script_path"])

with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "AstrBot.exe").write_text("binary")
(root / "resources" / "backend").mkdir(parents=True)
(root / "resources" / "webui").mkdir(parents=True)
(root / "resources" / "backend" / "runtime-manifest.json").write_text("{}")
(root / "resources" / "webui" / "index.html").write_text("<html></html>")
(root / project_config.backend_layout_relative_path).mkdir(parents=True)
(root / project_config.webui_layout_relative_path).mkdir(parents=True)
(
Comment on lines 553 to +562
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a validation test where backend/webui aliases are nested (e.g. runtime/backend)

The current test_validate_portable_root_* cases still only cover the default backend / webui layout. Since load_project_config_from now reads aliases from bundle.resources, please add an end-to-end validation test using non-trivial aliases (e.g. runtime/backend, runtime/webui). For example, call make_project_layout with tauri_resources mapping "../resources/backend" → "runtime/backend" and "../resources/webui" → "runtime/webui", build a portable root using those nested paths, and assert that MODULE.validate_portable_root(root, project_config) passes without requiring resources/backend or resources/webui directories. This will verify that validate_portable_root respects non-root aliases and protect against regressions that assume fixed backend/ / webui/ locations.

Suggested implementation:

            MODULE.add_portable_runtime_files(root, project_config)

    def test_validate_portable_root_accepts_expected_layout(self):
        layout = self.make_project_layout()
        project_config = MODULE.load_project_config_from(layout["script_path"])

        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            # default layout: backend/ and webui/ at the portable root
            (root / "AstrBot.exe").write_text("binary")
            (root / "portable.flag").write_text("marker")
            (root / "backend").mkdir()
            (root / "webui").mkdir()

            MODULE.validate_portable_root(root, project_config)

    def test_validate_portable_root_accepts_nested_alias_layout(self):
        layout = self.make_project_layout(
            tauri_resources={
                "../resources/backend": "runtime/backend",
                "../resources/webui": "runtime/webui",
            }
        )
        project_config = MODULE.load_project_config_from(layout["script_path"])

        with tempfile.TemporaryDirectory() as tmpdir:
            root = Path(tmpdir)
            # binary + portable marker at root
            (root / "AstrBot.exe").write_text("binary")
            (root / "portable.flag").write_text("marker")

            # resources are placed under their aliased nested locations
            (root / "runtime" / "backend").mkdir(parents=True)
            (root / "runtime" / "webui").mkdir(parents=True)

            # Intentionally do NOT create root-level backend/ or webui/ directories.
            # validate_portable_root should honor the aliased paths instead.
            MODULE.validate_portable_root(root, project_config)

If make_project_layout uses a different keyword than tauri_resources for the resources mapping, update the argument name in test_validate_portable_root_accepts_nested_alias_layout accordingly.
If validate_portable_root requires additional files or directories beyond those shown (for example, specific backend/webui content rather than just directories), mirror whatever the other test_validate_portable_root_* tests create under the aliased runtime/backend and runtime/webui paths so this test exercises the same invariants with nested aliases instead of the default layout.

root / project_config.backend_layout_relative_path / "runtime-manifest.json"
).write_text("{}")
(root / project_config.webui_layout_relative_path / "index.html").write_text(
"<html></html>"
)

MODULE.validate_portable_root(root, project_config)

MODULE.validate_portable_root(root)
def test_validate_portable_root_accepts_nested_alias_layout(self):
layout = self.make_project_layout(
tauri_resources={
"../resources/backend": "runtime/backend",
"../resources/webui": "runtime/webui",
}
)
project_config = MODULE.load_project_config_from(layout["script_path"])

with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "AstrBot.exe").write_text("binary")
(root / project_config.portable_marker_name).write_text("marker")
(root / project_config.backend_layout_relative_path).mkdir(parents=True)
(root / project_config.webui_layout_relative_path).mkdir(parents=True)
(
root / project_config.backend_layout_relative_path / "runtime-manifest.json"
).write_text("{}")
(root / project_config.webui_layout_relative_path / "index.html").write_text(
"<html></html>"
)

MODULE.validate_portable_root(root, project_config)

self.assertFalse((root / "backend").exists())
self.assertFalse((root / "webui").exists())

def test_validate_portable_root_requires_expected_files(self):
layout = self.make_project_layout()
project_config = MODULE.load_project_config_from(layout["script_path"])

with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "AstrBot.exe").write_text("binary")

with self.assertRaisesRegex(ValueError, "runtime-manifest.json"):
MODULE.validate_portable_root(root)
MODULE.validate_portable_root(root, project_config)

def test_validate_portable_root_requires_top_level_exe(self):
layout = self.make_project_layout()
project_config = MODULE.load_project_config_from(layout["script_path"])

with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "resources" / "backend").mkdir(parents=True)
(root / "resources" / "webui").mkdir(parents=True)
(root / "resources" / "backend" / "runtime-manifest.json").write_text("{}")
(root / "resources" / "webui" / "index.html").write_text("<html></html>")
(root / project_config.backend_layout_relative_path).mkdir(parents=True)
(root / project_config.webui_layout_relative_path).mkdir(parents=True)
(
root / project_config.backend_layout_relative_path / "runtime-manifest.json"
).write_text("{}")
(root / project_config.webui_layout_relative_path / "index.html").write_text(
"<html></html>"
)

with self.assertRaisesRegex(ValueError, r"top-level \*\.exe"):
MODULE.validate_portable_root(root)
MODULE.validate_portable_root(root, project_config)


if __name__ == "__main__":
Expand Down
Loading