Skip to content

Commit 09ab410

Browse files
authored
[codex] fix: portable runtime layout (#111)
* fix: portable runtime layout * fix: tighten portable layout review follow-ups * fix: align portable layout paths in tests * fix: derive portable resource aliases from tauri config * fix: align bundle resource alias validation * fix: normalize windows resource alias keys
1 parent 757a820 commit 09ab410

File tree

5 files changed

+326
-27
lines changed

5 files changed

+326
-27
lines changed

scripts/ci/package_windows_portable.py

Lines changed: 87 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from dataclasses import dataclass
77
from datetime import datetime
88
import json
9+
import os
910
import pathlib
1011
import re
1112
import shutil
@@ -36,6 +37,7 @@
3637
"""
3738
TAURI_CONFIG_RELATIVE_PATH = pathlib.Path("src-tauri") / "tauri.conf.json"
3839
CARGO_TOML_RELATIVE_PATH = pathlib.Path("src-tauri") / "Cargo.toml"
40+
# These point to the source resource directories inside the repository checkout.
3941
BACKEND_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "backend"
4042
WEBUI_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "webui"
4143
WINDOWS_CLEANUP_SCRIPT_RELATIVE_PATH = (
@@ -52,6 +54,8 @@ class ProjectConfig:
5254
product_name: str
5355
binary_name: str
5456
portable_marker_name: str
57+
backend_layout_relative_path: pathlib.Path
58+
webui_layout_relative_path: pathlib.Path
5559

5660

5761
def normalize_arch(arch: str) -> str:
@@ -99,16 +103,79 @@ def load_portable_runtime_marker(project_root: pathlib.Path) -> str:
99103
return marker_name
100104

101105

106+
def resolve_bundle_resource_alias_from_tauri_config(
107+
project_root: pathlib.Path,
108+
tauri_config: dict,
109+
source_relative_path: pathlib.Path,
110+
) -> pathlib.Path:
111+
# Keep validation rules aligned with src-tauri/build.rs::load_bundle_resource_alias.
112+
bundle_table = tauri_config.get("bundle")
113+
if not isinstance(bundle_table, dict):
114+
raise ValueError(f"Missing bundle object in {TAURI_CONFIG_RELATIVE_PATH}")
115+
116+
resources_table = bundle_table.get("resources")
117+
if not isinstance(resources_table, dict):
118+
raise ValueError(
119+
f"Missing bundle.resources object in {TAURI_CONFIG_RELATIVE_PATH}"
120+
)
121+
122+
tauri_config_dir = (project_root / TAURI_CONFIG_RELATIVE_PATH).parent.resolve()
123+
expected_source_path = (project_root / source_relative_path).resolve()
124+
expected_source_key = pathlib.PureWindowsPath(
125+
os.path.relpath(expected_source_path, tauri_config_dir)
126+
).as_posix()
127+
alias_text = resources_table.get(expected_source_key)
128+
if alias_text is None:
129+
raise ValueError(
130+
"Missing bundle.resources alias for "
131+
f"{expected_source_key} in {TAURI_CONFIG_RELATIVE_PATH}"
132+
)
133+
134+
if not isinstance(alias_text, str):
135+
raise ValueError(
136+
"bundle.resources alias for "
137+
f"{expected_source_key} must be a string in {TAURI_CONFIG_RELATIVE_PATH}"
138+
)
139+
140+
alias_path = pathlib.Path(alias_text.strip())
141+
if not alias_path.parts or alias_path.is_absolute():
142+
raise ValueError(
143+
"bundle.resources alias for "
144+
f"{expected_source_key} must be a relative path in "
145+
f"{TAURI_CONFIG_RELATIVE_PATH}: {alias_text}"
146+
)
147+
if any(part in (".", "..") for part in alias_path.parts):
148+
raise ValueError(
149+
"bundle.resources alias for "
150+
f"{expected_source_key} must be a relative path without traversal in "
151+
f"{TAURI_CONFIG_RELATIVE_PATH}: {alias_text}"
152+
)
153+
return alias_path
154+
155+
102156
def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig:
103157
project_root = resolve_project_root_from(start_path)
104-
product_name = resolve_product_name(project_root)
158+
tauri_config = load_tauri_config(project_root)
159+
product_name = resolve_product_name_from_tauri_config(tauri_config)
105160
binary_name = load_binary_name_from_cargo(project_root)
106161
portable_marker_name = load_portable_runtime_marker(project_root)
162+
backend_layout_relative_path = resolve_bundle_resource_alias_from_tauri_config(
163+
project_root,
164+
tauri_config,
165+
BACKEND_RESOURCE_RELATIVE_PATH,
166+
)
167+
webui_layout_relative_path = resolve_bundle_resource_alias_from_tauri_config(
168+
project_root,
169+
tauri_config,
170+
WEBUI_RESOURCE_RELATIVE_PATH,
171+
)
107172
return ProjectConfig(
108173
root=project_root,
109174
product_name=product_name,
110175
binary_name=binary_name,
111176
portable_marker_name=portable_marker_name,
177+
backend_layout_relative_path=backend_layout_relative_path,
178+
webui_layout_relative_path=webui_layout_relative_path,
112179
)
113180

114181

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

214281

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

229295

296+
def resolve_product_name(project_root: pathlib.Path) -> str:
297+
return resolve_product_name_from_tauri_config(load_tauri_config(project_root))
298+
299+
230300
def resolve_release_dir(bundle_dir: pathlib.Path) -> pathlib.Path:
231301
return bundle_dir.parent.parent
232302

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

266-
resources_root = destination_root / "resources"
267336
backend_src = project_config.root / BACKEND_RESOURCE_RELATIVE_PATH
268337
if not backend_src.is_dir():
269338
raise FileNotFoundError(f"Required directory not found: {backend_src}")
270-
shutil.copytree(backend_src, resources_root / "backend")
339+
shutil.copytree(
340+
backend_src, destination_root / project_config.backend_layout_relative_path
341+
)
271342

272343
webui_src = project_config.root / WEBUI_RESOURCE_RELATIVE_PATH
273344
if not webui_src.is_dir():
274345
raise FileNotFoundError(f"Required directory not found: {webui_src}")
275-
shutil.copytree(webui_src, resources_root / "webui")
346+
shutil.copytree(
347+
webui_src, destination_root / project_config.webui_layout_relative_path
348+
)
276349

277350
add_portable_runtime_files(destination_root, project_config)
278-
validate_portable_root(destination_root)
351+
validate_portable_root(destination_root, project_config)
279352

280353

281354
def add_portable_runtime_files(
@@ -290,10 +363,14 @@ def add_portable_runtime_files(
290363
)
291364

292365

293-
def validate_portable_root(destination_root: pathlib.Path) -> None:
366+
def validate_portable_root(
367+
destination_root: pathlib.Path, project_config: ProjectConfig | None = None
368+
) -> None:
369+
if project_config is None:
370+
project_config = load_project_config()
294371
expected_paths = [
295-
destination_root / "resources" / "backend" / "runtime-manifest.json",
296-
destination_root / "resources" / "webui" / "index.html",
372+
destination_root / project_config.backend_layout_relative_path / "runtime-manifest.json",
373+
destination_root / project_config.webui_layout_relative_path / "index.html",
297374
]
298375
missing = [
299376
str(path.relative_to(destination_root))

scripts/ci/test_package_windows_portable.py

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import tempfile
44
import unittest
55
from pathlib import Path
6+
from unittest import mock
67

78
from scripts.ci import package_windows_portable as MODULE
89

@@ -14,17 +15,30 @@ def make_project_layout(
1415
product_name: str = "AstrBot",
1516
cargo_toml: str = '[package]\nname = "astrbot-desktop-tauri"\n',
1617
marker_name: str = "portable.flag\n",
18+
tauri_resources: dict[str, str] | None = None,
1719
) -> dict[str, Path]:
1820
project_root = Path(self.enterContext(tempfile.TemporaryDirectory()))
1921
script_path = project_root / "scripts" / "ci" / "package_windows_portable.py"
2022
tauri_config_path = project_root / "src-tauri" / "tauri.conf.json"
2123
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
2224
marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH
25+
if tauri_resources is None:
26+
tauri_resources = {
27+
"../resources/backend": "backend",
28+
"../resources/webui": "webui",
29+
}
2330

2431
script_path.parent.mkdir(parents=True)
2532
script_path.write_text("# placeholder")
2633
tauri_config_path.parent.mkdir(parents=True)
27-
tauri_config_path.write_text(json.dumps({"productName": product_name}))
34+
tauri_config_path.write_text(
35+
json.dumps(
36+
{
37+
"productName": product_name,
38+
"bundle": {"resources": tauri_resources},
39+
}
40+
)
41+
)
2842
cargo_toml_path.write_text(cargo_toml)
2943
marker_path.parent.mkdir(parents=True, exist_ok=True)
3044
marker_path.write_text(marker_name)
@@ -198,6 +212,54 @@ def test_load_project_config_from_returns_root_product_and_marker(self):
198212
self.assertEqual(project_config.product_name, "AstrBot")
199213
self.assertEqual(project_config.binary_name, "astrbot-desktop-tauri")
200214
self.assertEqual(project_config.portable_marker_name, "portable.flag")
215+
self.assertEqual(project_config.backend_layout_relative_path, Path("backend"))
216+
self.assertEqual(project_config.webui_layout_relative_path, Path("webui"))
217+
218+
def test_load_project_config_from_reads_portable_layout_aliases_from_tauri_resources(
219+
self,
220+
):
221+
layout = self.make_project_layout(
222+
tauri_resources={
223+
"../resources/backend": "runtime/backend",
224+
"../resources/webui": "runtime/webui",
225+
}
226+
)
227+
228+
project_config = MODULE.load_project_config_from(layout["script_path"])
229+
230+
self.assertEqual(
231+
project_config.backend_layout_relative_path, Path("runtime/backend")
232+
)
233+
self.assertEqual(
234+
project_config.webui_layout_relative_path, Path("runtime/webui")
235+
)
236+
237+
def test_load_project_config_from_requires_exact_tauri_resource_source_keys(self):
238+
layout = self.make_project_layout(
239+
tauri_resources={
240+
"./../resources/backend": "runtime/backend",
241+
"../resources/webui": "runtime/webui",
242+
}
243+
)
244+
245+
with self.assertRaisesRegex(
246+
ValueError,
247+
re.escape("Missing bundle.resources alias for ../resources/backend"),
248+
):
249+
MODULE.load_project_config_from(layout["script_path"])
250+
251+
def test_load_project_config_from_normalizes_windows_relpath_separators(self):
252+
layout = self.make_project_layout()
253+
254+
with mock.patch.object(
255+
MODULE.os.path,
256+
"relpath",
257+
side_effect=[r"..\resources\backend", r"..\resources\webui"],
258+
):
259+
project_config = MODULE.load_project_config_from(layout["script_path"])
260+
261+
self.assertEqual(project_config.backend_layout_relative_path, Path("backend"))
262+
self.assertEqual(project_config.webui_layout_relative_path, Path("webui"))
201263

202264
def test_normalize_legacy_nightly_version_returns_base_version_and_suffix(self):
203265
self.assertEqual(
@@ -360,6 +422,8 @@ def test_resolve_main_executable_path_uses_binary_name_not_product_name(self):
360422
product_name="AstrBot",
361423
binary_name="astrbot-desktop-tauri",
362424
portable_marker_name="portable.flag",
425+
backend_layout_relative_path=Path("backend"),
426+
webui_layout_relative_path=Path("webui"),
363427
)
364428

365429
self.assertEqual(
@@ -424,12 +488,20 @@ def test_populate_portable_root_copies_release_bundle_contents(self):
424488
self.assertTrue((destination_root / "WebView2Loader.dll").is_file())
425489
self.assertTrue(
426490
(
427-
destination_root / "resources" / "backend" / "runtime-manifest.json"
491+
destination_root
492+
/ project_config.backend_layout_relative_path
493+
/ "runtime-manifest.json"
428494
).is_file()
429495
)
430496
self.assertTrue(
431-
(destination_root / "resources" / "webui" / "index.html").is_file()
497+
(
498+
destination_root
499+
/ project_config.webui_layout_relative_path
500+
/ "index.html"
501+
).is_file()
432502
)
503+
self.assertFalse((destination_root / "resources" / "backend").exists())
504+
self.assertFalse((destination_root / "resources" / "webui").exists())
433505
self.assertTrue((destination_root / "kill-backend-processes.ps1").is_file())
434506
self.assertTrue((destination_root / "portable.flag").is_file())
435507
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):
466538
product_name="AstrBot",
467539
binary_name="astrbot-desktop-tauri",
468540
portable_marker_name="portable.flag",
541+
backend_layout_relative_path=Path("backend"),
542+
webui_layout_relative_path=Path("webui"),
469543
)
470544

471545
MODULE.add_portable_runtime_files(root, project_config)
@@ -477,34 +551,78 @@ def test_add_portable_runtime_files_writes_marker_and_readme(self):
477551
)
478552

479553
def test_validate_portable_root_accepts_expected_layout(self):
554+
layout = self.make_project_layout()
555+
project_config = MODULE.load_project_config_from(layout["script_path"])
556+
480557
with tempfile.TemporaryDirectory() as tmpdir:
481558
root = Path(tmpdir)
482559
(root / "AstrBot.exe").write_text("binary")
483-
(root / "resources" / "backend").mkdir(parents=True)
484-
(root / "resources" / "webui").mkdir(parents=True)
485-
(root / "resources" / "backend" / "runtime-manifest.json").write_text("{}")
486-
(root / "resources" / "webui" / "index.html").write_text("<html></html>")
560+
(root / project_config.backend_layout_relative_path).mkdir(parents=True)
561+
(root / project_config.webui_layout_relative_path).mkdir(parents=True)
562+
(
563+
root / project_config.backend_layout_relative_path / "runtime-manifest.json"
564+
).write_text("{}")
565+
(root / project_config.webui_layout_relative_path / "index.html").write_text(
566+
"<html></html>"
567+
)
568+
569+
MODULE.validate_portable_root(root, project_config)
487570

488-
MODULE.validate_portable_root(root)
571+
def test_validate_portable_root_accepts_nested_alias_layout(self):
572+
layout = self.make_project_layout(
573+
tauri_resources={
574+
"../resources/backend": "runtime/backend",
575+
"../resources/webui": "runtime/webui",
576+
}
577+
)
578+
project_config = MODULE.load_project_config_from(layout["script_path"])
579+
580+
with tempfile.TemporaryDirectory() as tmpdir:
581+
root = Path(tmpdir)
582+
(root / "AstrBot.exe").write_text("binary")
583+
(root / project_config.portable_marker_name).write_text("marker")
584+
(root / project_config.backend_layout_relative_path).mkdir(parents=True)
585+
(root / project_config.webui_layout_relative_path).mkdir(parents=True)
586+
(
587+
root / project_config.backend_layout_relative_path / "runtime-manifest.json"
588+
).write_text("{}")
589+
(root / project_config.webui_layout_relative_path / "index.html").write_text(
590+
"<html></html>"
591+
)
592+
593+
MODULE.validate_portable_root(root, project_config)
594+
595+
self.assertFalse((root / "backend").exists())
596+
self.assertFalse((root / "webui").exists())
489597

490598
def test_validate_portable_root_requires_expected_files(self):
599+
layout = self.make_project_layout()
600+
project_config = MODULE.load_project_config_from(layout["script_path"])
601+
491602
with tempfile.TemporaryDirectory() as tmpdir:
492603
root = Path(tmpdir)
493604
(root / "AstrBot.exe").write_text("binary")
494605

495606
with self.assertRaisesRegex(ValueError, "runtime-manifest.json"):
496-
MODULE.validate_portable_root(root)
607+
MODULE.validate_portable_root(root, project_config)
497608

498609
def test_validate_portable_root_requires_top_level_exe(self):
610+
layout = self.make_project_layout()
611+
project_config = MODULE.load_project_config_from(layout["script_path"])
612+
499613
with tempfile.TemporaryDirectory() as tmpdir:
500614
root = Path(tmpdir)
501-
(root / "resources" / "backend").mkdir(parents=True)
502-
(root / "resources" / "webui").mkdir(parents=True)
503-
(root / "resources" / "backend" / "runtime-manifest.json").write_text("{}")
504-
(root / "resources" / "webui" / "index.html").write_text("<html></html>")
615+
(root / project_config.backend_layout_relative_path).mkdir(parents=True)
616+
(root / project_config.webui_layout_relative_path).mkdir(parents=True)
617+
(
618+
root / project_config.backend_layout_relative_path / "runtime-manifest.json"
619+
).write_text("{}")
620+
(root / project_config.webui_layout_relative_path / "index.html").write_text(
621+
"<html></html>"
622+
)
505623

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

509627

510628
if __name__ == "__main__":

0 commit comments

Comments
 (0)