Skip to content

Commit 97842f8

Browse files
committed
Add launcher and direct application distribution
1 parent a0411ad commit 97842f8

80 files changed

Lines changed: 11827 additions & 101 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/launcher-release.yml

Lines changed: 516 additions & 0 deletions
Large diffs are not rendered by default.

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ env/
1515

1616
# Rust build artifacts
1717
native/target/
18+
launcher/target/
19+
launcher/.dev-runtime/
20+
21+
# Tauri frontend build artifacts
22+
launcher/tauri/node_modules/
23+
launcher/tauri/dist/
24+
launcher/tauri/src-tauri/gen/
1825

1926
# Local E2E run output
2027
artifacts/

build/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ uv run --project ./build --locked ruff check ./build/sbc_packager
6868
uv run --project ./build --locked pyright ./build/sbc_packager
6969
```
7070

71+
## Standalone launcher bundle
72+
73+
`sbc-packager-standalone` assembles already-built CLI and Tauri launcher
74+
binaries, a native plugin, a local engine install, and the packaged SBC game
75+
into one portable directory and a matching ZIP archive. The `just
76+
bundle-launcher-linux` recipe is the local development entrypoint. Production
77+
AppImage, NSIS, and CLI release packaging is described in
78+
[`launcher/docs/distribution.md`](../launcher/docs/distribution.md).
79+
80+
The standalone launch configuration retains the generated-map values from
81+
`dist_cfg/config.json`; Spring creates `sb_initial_blank_10x8` at launch, so a
82+
map archive is neither bundled nor downloaded.
83+
7184
## Linux AppImage sandbox note
7285

7386
`make-package-json` injects `afterPack: build/sbc_after_pack.cjs`.

build/pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,15 @@ dependencies = ["pydantic>=2.12.5", "typer>=0.23.1"]
99
sbc-packager = "sbc_packager.cli:main"
1010
sbc-packager-prepare = "sbc_packager.cli_prepare:main"
1111
sbc-packager-download-engine = "sbc_packager.cli_download_engine:main"
12+
sbc-packager-editor = "sbc_packager.cli_editor:main"
13+
sbc-packager-release-config = "sbc_packager.cli_release_config:main"
14+
sbc-packager-cli-download = "sbc_packager.cli_download:main"
15+
sbc-packager-tauri-config = "sbc_packager.cli_tauri_config:main"
16+
sbc-packager-release-manifests = "sbc_packager.cli_release_manifests:main"
1217
sbc-packager-make-package-json = "sbc_packager.cli_make_package_json:main"
1318
sbc-packager-package = "sbc_packager.cli_package:main"
19+
sbc-packager-standalone = "sbc_packager.cli_standalone:main"
20+
sbc-packager-application = "sbc_packager.cli_application:main"
1421

1522
[build-system]
1623
requires = ["hatchling"]

build/sbc_packager/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
"""Packaging CLI for SpringBoard Core."""
2-
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from __future__ import annotations
2+
3+
import shutil
4+
from pathlib import Path
5+
6+
7+
def create_application_archive(application_dir: Path, platform: str) -> Path:
8+
archive_format = "gztar" if platform == "linux" else "zip"
9+
suffix = ".tar.gz" if platform == "linux" else ".zip"
10+
expected = application_dir.with_name(application_dir.name + suffix)
11+
if expected.exists():
12+
raise RuntimeError(f"Refusing to overwrite existing application archive: {expected}")
13+
return Path(
14+
shutil.make_archive(
15+
str(application_dir),
16+
archive_format,
17+
root_dir=application_dir.parent,
18+
base_dir=application_dir.name,
19+
)
20+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from __future__ import annotations
2+
3+
import shutil
4+
import subprocess
5+
from pathlib import Path
6+
7+
PRUNED_ENGINE_DIRECTORIES = ("AI",)
8+
PRUNED_ENGINE_EXECUTABLES = ("pr-downloader", "spring-dedicated", "spring-headless")
9+
10+
11+
def install_engine(*, destination: Path, engine_dir: Path | None, engine_archive: Path | None) -> None:
12+
if (engine_dir is None) == (engine_archive is None):
13+
raise RuntimeError("Specify exactly one of --engine-dir or --engine-archive")
14+
15+
if engine_dir is not None:
16+
shutil.copytree(engine_dir.resolve(), destination, symlinks=True)
17+
return
18+
19+
if engine_archive is None:
20+
raise RuntimeError("Engine archive was not provided")
21+
seven_zip = shutil.which("7z")
22+
if seven_zip is None:
23+
raise RuntimeError("7z is required to extract the engine archive")
24+
destination.mkdir(parents=True)
25+
subprocess.run(
26+
[seven_zip, "x", "-y", f"-o{destination}", str(engine_archive.resolve())],
27+
check=True,
28+
)
29+
30+
31+
def prune_engine(application_dir: Path, platform: str) -> None:
32+
executable_suffix = "" if platform == "linux" else ".exe"
33+
for executable in PRUNED_ENGINE_EXECUTABLES:
34+
(application_dir / f"{executable}{executable_suffix}").unlink(missing_ok=True)
35+
36+
for directory in PRUNED_ENGINE_DIRECTORIES:
37+
path = application_dir / directory
38+
if path.is_symlink() or path.is_file():
39+
path.unlink()
40+
elif path.is_dir():
41+
shutil.rmtree(path)
42+
43+
44+
def rename_engine(application_dir: Path, platform: str) -> Path:
45+
source_name = "spring" if platform == "linux" else "spring.exe"
46+
destination_name = "SpringBoard" if platform == "linux" else "SpringBoard.exe"
47+
source = application_dir / source_name
48+
destination = application_dir / destination_name
49+
if not source.is_file():
50+
raise RuntimeError(f"Engine executable is missing: {source}")
51+
if destination.exists():
52+
raise RuntimeError(f"Application executable already exists: {destination}")
53+
source.rename(destination)
54+
if platform == "linux" and not destination.stat().st_mode & 0o111:
55+
raise RuntimeError(f"Engine executable lost its executable mode: {destination}")
56+
return destination
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
import gzip
4+
import hashlib
5+
from pathlib import Path
6+
7+
8+
def write_file_manifest(application_dir: Path) -> Path:
9+
manifest_path = application_dir / "files.md5.gz"
10+
entries = []
11+
for path in sorted(application_dir.rglob("*")):
12+
if path == manifest_path or path.is_symlink() or not path.is_file() or path.suffix == ".dbg":
13+
continue
14+
relative = path.relative_to(application_dir).as_posix()
15+
entries.append(f"{file_md5(path)} ./{relative}\n")
16+
17+
with (
18+
manifest_path.open("wb") as output,
19+
gzip.GzipFile(filename="", mode="wb", fileobj=output, mtime=0) as compressed,
20+
):
21+
compressed.write("".join(entries).encode())
22+
return manifest_path
23+
24+
25+
def file_md5(path: Path) -> str:
26+
digest = hashlib.md5(usedforsecurity=False)
27+
with path.open("rb") as source:
28+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
29+
digest.update(chunk)
30+
return digest.hexdigest()

build/sbc_packager/artifacts.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
from pathlib import Path
5+
from urllib.parse import quote
6+
7+
8+
def sha256(path: Path) -> str:
9+
digest = hashlib.sha256()
10+
with path.open("rb") as artifact:
11+
for block in iter(lambda: artifact.read(1024 * 1024), b""):
12+
digest.update(block)
13+
return digest.hexdigest()
14+
15+
16+
def write_sha256(path: Path) -> Path:
17+
checksum = path.with_suffix(path.suffix + ".sha256")
18+
checksum.write_text(f"{sha256(path)} {path.name}\n", encoding="utf-8")
19+
return checksum
20+
21+
22+
def github_release_url(repository: str, tag: str, asset: str) -> str:
23+
repository_path = "/".join(quote(part, safe="") for part in repository.split("/"))
24+
return (
25+
f"https://github.com/{repository_path}/releases/download/"
26+
f"{quote(tag, safe='')}/{quote(asset, safe='')}"
27+
)

build/sbc_packager/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from .cli_make_package_json import make_package_json_command
77
from .cli_package import package_command
88
from .cli_prepare import prepare_command
9+
from .cli_standalone import standalone_command
910

1011
app = typer.Typer(add_completion=False, no_args_is_help=True)
1112

1213
app.command("prepare")(prepare_command)
14+
app.command("standalone")(standalone_command)
1315
app.command("download-engine")(download_engine_command)
1416
app.command("make-package-json")(make_package_json_command)
1517
app.command("package")(package_command)

0 commit comments

Comments
 (0)