diff --git a/scripts/ci/package_windows_portable.py b/scripts/ci/package_windows_portable.py index 5f4d0c0..801d6c4 100644 --- a/scripts/ci/package_windows_portable.py +++ b/scripts/ci/package_windows_portable.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import datetime import json +import os import pathlib import re import shutil @@ -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 = ( @@ -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: @@ -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, ) @@ -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}") @@ -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 @@ -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( @@ -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)) diff --git a/scripts/ci/test_package_windows_portable.py b/scripts/ci/test_package_windows_portable.py index 74ec187..1ed11fc 100644 --- a/scripts/ci/test_package_windows_portable.py +++ b/scripts/ci/test_package_windows_portable.py @@ -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 @@ -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) @@ -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( @@ -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( @@ -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()) @@ -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) @@ -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("") + (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( + "" + ) + + 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( + "" + ) + + 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("") + (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( + "" + ) with self.assertRaisesRegex(ValueError, r"top-level \*\.exe"): - MODULE.validate_portable_root(root) + MODULE.validate_portable_root(root, project_config) if __name__ == "__main__": diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3066bb2..2019a70 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,7 @@ rust-version = "1.86" build = "build.rs" [build-dependencies] +serde_json = "1.0" tauri-build = { version = "2.0", features = [] } [dependencies] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 34927bf..ecc07d8 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,8 +1,70 @@ -use std::{fs, path::Path}; +use serde_json::Value; +use std::{ + fs, + path::{Component, Path}, +}; + +const TAURI_CONFIG_PATH: &str = "tauri.conf.json"; +const BACKEND_RESOURCE_SOURCE: &str = "../resources/backend"; +const WEBUI_RESOURCE_SOURCE: &str = "../resources/webui"; + +fn load_bundle_resource_alias(tauri_config: &Value, source_relative_path: &str) -> String { + // Keep validation rules aligned with + // scripts/ci/package_windows_portable.py::resolve_bundle_resource_alias_from_tauri_config. + let bundle = tauri_config + .get("bundle") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("missing bundle object in {TAURI_CONFIG_PATH}")); + let resources = bundle + .get("resources") + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("missing bundle.resources object in {TAURI_CONFIG_PATH}")); + + let alias_value = resources.get(source_relative_path).unwrap_or_else(|| { + panic!( + "missing bundle.resources alias for {} in {}", + source_relative_path, TAURI_CONFIG_PATH + ) + }); + let alias = alias_value.as_str().map(str::trim).unwrap_or_else(|| { + panic!( + "bundle.resources alias for {} must be a string in {}", + source_relative_path, TAURI_CONFIG_PATH + ) + }); + assert!( + !alias.is_empty(), + "bundle.resources alias for {} is empty in {}", + source_relative_path, + TAURI_CONFIG_PATH + ); + + let alias_path = Path::new(alias); + assert!( + !alias_path.is_absolute() + && alias_path.components().all(|component| { + !matches!( + component, + Component::CurDir + | Component::ParentDir + | Component::Prefix(_) + | Component::RootDir + ) + }), + "bundle.resources alias for {} must be a relative path without traversal in {}: {}", + source_relative_path, + TAURI_CONFIG_PATH, + alias + ); + + alias.to_string() +} fn main() { let marker_path = Path::new("windows").join("portable-runtime-marker.txt"); + let tauri_config_path = Path::new(TAURI_CONFIG_PATH); println!("cargo:rerun-if-changed={}", marker_path.display()); + println!("cargo:rerun-if-changed={}", tauri_config_path.display()); let marker = fs::read_to_string(&marker_path) .unwrap_or_else(|error| panic!("failed to read {}: {error}", marker_path.display())); @@ -14,5 +76,15 @@ fn main() { ); println!("cargo:rustc-env=ASTRBOT_PORTABLE_RUNTIME_MARKER={marker}"); + let tauri_config_text = fs::read_to_string(tauri_config_path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", tauri_config_path.display())); + let tauri_config: Value = serde_json::from_str(&tauri_config_text) + .unwrap_or_else(|error| panic!("failed to parse {}: {error}", tauri_config_path.display())); + + let backend_resource_alias = load_bundle_resource_alias(&tauri_config, BACKEND_RESOURCE_SOURCE); + let webui_resource_alias = load_bundle_resource_alias(&tauri_config, WEBUI_RESOURCE_SOURCE); + println!("cargo:rustc-env=ASTRBOT_BACKEND_RESOURCE_ALIAS={backend_resource_alias}"); + println!("cargo:rustc-env=ASTRBOT_WEBUI_RESOURCE_ALIAS={webui_resource_alias}"); + tauri_build::build() } diff --git a/src-tauri/src/launch_plan.rs b/src-tauri/src/launch_plan.rs index 215e940..cb421bb 100644 --- a/src-tauri/src/launch_plan.rs +++ b/src-tauri/src/launch_plan.rs @@ -7,6 +7,13 @@ use tauri::AppHandle; use crate::{packaged_webui, runtime_paths, LaunchPlan, RuntimeManifest}; +const BACKEND_RESOURCE_ALIAS: &str = env!("ASTRBOT_BACKEND_RESOURCE_ALIAS"); +const WEBUI_RESOURCE_ALIAS: &str = env!("ASTRBOT_WEBUI_RESOURCE_ALIAS"); + +fn build_packaged_resource_relative_path(resource_alias: &str, leaf_name: &str) -> PathBuf { + PathBuf::from(resource_alias).join(leaf_name) +} + pub fn resolve_custom_launch(custom_cmd: String) -> Result { let mut pieces = shlex::split(&custom_cmd) .ok_or_else(|| format!("Invalid ASTRBOT_BACKEND_CMD: {custom_cmd}"))?; @@ -41,8 +48,11 @@ pub fn resolve_packaged_launch( where F: Fn(&str) + Copy, { + let manifest_relative_path = + build_packaged_resource_relative_path(BACKEND_RESOURCE_ALIAS, "runtime-manifest.json"); + let manifest_relative_path_string = manifest_relative_path.to_string_lossy().to_string(); let manifest_path = - match runtime_paths::resolve_resource_path(app, "backend/runtime-manifest.json", log) { + match runtime_paths::resolve_resource_path(app, &manifest_relative_path_string, log) { Some(path) if path.is_file() => path, _ => return Ok(None), }; @@ -112,7 +122,11 @@ where .ok() .map(PathBuf::from) .or_else(|| { - runtime_paths::resolve_resource_path(app, "webui/index.html", log) + let webui_index_relative_path = + build_packaged_resource_relative_path(WEBUI_RESOURCE_ALIAS, "index.html"); + let webui_index_relative_path_string = + webui_index_relative_path.to_string_lossy().to_string(); + runtime_paths::resolve_resource_path(app, &webui_index_relative_path_string, log) .and_then(|index_path| index_path.parent().map(Path::to_path_buf)) }); let webui_dir = packaged_webui::resolve_packaged_webui_dir( @@ -172,3 +186,20 @@ pub fn resolve_dev_launch() -> Result { packaged_mode: false, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_packaged_resource_relative_path_joins_alias_and_leaf_name() { + assert_eq!( + build_packaged_resource_relative_path("runtime/backend", "runtime-manifest.json"), + PathBuf::from("runtime/backend").join("runtime-manifest.json") + ); + assert_eq!( + build_packaged_resource_relative_path("runtime/webui", "index.html"), + PathBuf::from("runtime/webui").join("index.html") + ); + } +}