caspian-native 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- caspian_native/__init__.py +8 -0
- caspian_native/__main__.py +5 -0
- caspian_native/backend.py +118 -0
- caspian_native/cli.py +119 -0
- caspian_native/config.py +165 -0
- caspian_native/doctor.py +57 -0
- caspian_native/errors.py +3 -0
- caspian_native/project.py +48 -0
- caspian_native/runner.py +168 -0
- caspian_native/scaffold.py +471 -0
- caspian_native/schema.py +54 -0
- caspian_native/security.py +81 -0
- caspian_native-0.0.1.dist-info/METADATA +158 -0
- caspian_native-0.0.1.dist-info/RECORD +18 -0
- caspian_native-0.0.1.dist-info/WHEEL +5 -0
- caspian_native-0.0.1.dist-info/entry_points.txt +2 -0
- caspian_native-0.0.1.dist-info/licenses/LICENSE +22 -0
- caspian_native-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .errors import NativeError
|
|
10
|
+
from .project import CaspianProject
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rust_host_triple() -> str:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
["rustc", "-vV"], capture_output=True, text=True, check=False, encoding="utf-8"
|
|
16
|
+
)
|
|
17
|
+
if result.returncode:
|
|
18
|
+
raise NativeError("rustc is required to name the Tauri sidecar target")
|
|
19
|
+
for line in result.stdout.splitlines():
|
|
20
|
+
if line.startswith("host: "):
|
|
21
|
+
return line.removeprefix("host: ").strip()
|
|
22
|
+
raise NativeError("rustc did not report a host target triple")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_backend(project: CaspianProject) -> Path:
|
|
26
|
+
uv = shutil.which("uv")
|
|
27
|
+
if uv is None and shutil.which("pyinstaller") is None:
|
|
28
|
+
raise NativeError(
|
|
29
|
+
"embedded backend builds require uv (the normal Caspian environment manager) "
|
|
30
|
+
"or PyInstaller in the current Python environment"
|
|
31
|
+
)
|
|
32
|
+
public = project.root / "public"
|
|
33
|
+
files_list = project.root / "settings" / "files-list.json"
|
|
34
|
+
if not public.is_dir():
|
|
35
|
+
raise NativeError("the Caspian project has no public directory; run its build first")
|
|
36
|
+
if not files_list.is_file():
|
|
37
|
+
raise NativeError("settings/files-list.json is missing; run the Caspian project build first")
|
|
38
|
+
|
|
39
|
+
work = project.native_dir / ".backend-build"
|
|
40
|
+
dist = work / "dist"
|
|
41
|
+
spec = work / "spec"
|
|
42
|
+
binaries = project.native_dir / "binaries"
|
|
43
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
binaries.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
launcher = work / "launcher.py"
|
|
46
|
+
launcher.write_text(
|
|
47
|
+
"from caspian_native.runner import main\n\nif __name__ == '__main__':\n main()\n",
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
add_data = [
|
|
52
|
+
(project.root / "main.py", "app"),
|
|
53
|
+
(project.root / "src", "app/src"),
|
|
54
|
+
(public, "app/public"),
|
|
55
|
+
(project.root / "caspian.config.json", "app"),
|
|
56
|
+
(files_list, "app/settings"),
|
|
57
|
+
]
|
|
58
|
+
component_map = project.root / "settings" / "component-map.json"
|
|
59
|
+
if component_map.is_file():
|
|
60
|
+
add_data.append((component_map, "app/settings"))
|
|
61
|
+
|
|
62
|
+
separator = ";" if os.name == "nt" else ":"
|
|
63
|
+
if uv:
|
|
64
|
+
command = [
|
|
65
|
+
uv,
|
|
66
|
+
"run",
|
|
67
|
+
"--project",
|
|
68
|
+
str(project.root),
|
|
69
|
+
"--with",
|
|
70
|
+
"pyinstaller>=6",
|
|
71
|
+
"python",
|
|
72
|
+
"-m",
|
|
73
|
+
"PyInstaller",
|
|
74
|
+
]
|
|
75
|
+
else:
|
|
76
|
+
command = [sys.executable, "-m", "PyInstaller"]
|
|
77
|
+
command.extend(
|
|
78
|
+
[
|
|
79
|
+
"--noconfirm",
|
|
80
|
+
"--clean",
|
|
81
|
+
"--onefile",
|
|
82
|
+
"--name",
|
|
83
|
+
"caspian-server",
|
|
84
|
+
"--distpath",
|
|
85
|
+
str(dist),
|
|
86
|
+
"--workpath",
|
|
87
|
+
str(work / "work"),
|
|
88
|
+
"--specpath",
|
|
89
|
+
str(spec),
|
|
90
|
+
"--paths",
|
|
91
|
+
str(project.root),
|
|
92
|
+
"--paths",
|
|
93
|
+
str(Path(__file__).resolve().parents[1]),
|
|
94
|
+
"--hidden-import",
|
|
95
|
+
"main",
|
|
96
|
+
"--collect-submodules",
|
|
97
|
+
"src",
|
|
98
|
+
"--collect-submodules",
|
|
99
|
+
"caspian_native",
|
|
100
|
+
"--collect-all",
|
|
101
|
+
"casp",
|
|
102
|
+
]
|
|
103
|
+
)
|
|
104
|
+
for source, destination in add_data:
|
|
105
|
+
command.extend(["--add-data", f"{source}{separator}{destination}"])
|
|
106
|
+
command.append(str(launcher))
|
|
107
|
+
|
|
108
|
+
result = subprocess.run(command, cwd=project.root, check=False)
|
|
109
|
+
if result.returncode:
|
|
110
|
+
raise NativeError("PyInstaller failed to build the Caspian backend")
|
|
111
|
+
|
|
112
|
+
suffix = ".exe" if os.name == "nt" else ""
|
|
113
|
+
built = dist / f"caspian-server{suffix}"
|
|
114
|
+
if not built.is_file():
|
|
115
|
+
raise NativeError(f"PyInstaller completed but did not produce {built}")
|
|
116
|
+
target = binaries / f"caspian-server-{rust_host_triple()}{suffix}"
|
|
117
|
+
shutil.copy2(built, target)
|
|
118
|
+
return target
|
caspian_native/cli.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .backend import build_backend
|
|
10
|
+
from .config import BackendConfig, NativeConfig, SecurityConfig, WindowConfig
|
|
11
|
+
from .doctor import inspect
|
|
12
|
+
from .errors import NativeError
|
|
13
|
+
from .project import CaspianProject
|
|
14
|
+
from .scaffold import write_generated
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parser() -> argparse.ArgumentParser:
|
|
18
|
+
root = argparse.ArgumentParser(prog="caspian-native")
|
|
19
|
+
root.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
20
|
+
root.add_argument("--project", type=Path, help="Caspian project root")
|
|
21
|
+
commands = root.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
init = commands.add_parser("init", help="create or refresh the native Tauri shell")
|
|
24
|
+
init.add_argument("--identifier", required=True)
|
|
25
|
+
init.add_argument("--product-name")
|
|
26
|
+
init.add_argument("--app-version", default="0.0.1")
|
|
27
|
+
init.add_argument("--windows", action="store_true")
|
|
28
|
+
init.add_argument("--android", action="store_true")
|
|
29
|
+
init.add_argument("--remote-url")
|
|
30
|
+
init.add_argument("--desktop-mode", choices=("embedded", "remote"), default="embedded")
|
|
31
|
+
init.add_argument("--force", action="store_true")
|
|
32
|
+
|
|
33
|
+
doctor = commands.add_parser("doctor", help="check native packaging prerequisites")
|
|
34
|
+
doctor.add_argument("--target", choices=("windows", "android"), default="windows")
|
|
35
|
+
|
|
36
|
+
backend = commands.add_parser("backend", help="build the embedded Caspian sidecar")
|
|
37
|
+
backend.add_argument("action", choices=("build",))
|
|
38
|
+
|
|
39
|
+
dev = commands.add_parser("dev", help="run the generated Tauri application")
|
|
40
|
+
dev.add_argument("--target", choices=("windows", "android"), default="windows")
|
|
41
|
+
|
|
42
|
+
build = commands.add_parser("build", help="build a native installer or Android package")
|
|
43
|
+
build.add_argument("--target", choices=("windows", "android"), default="windows")
|
|
44
|
+
build.add_argument("--debug", action="store_true")
|
|
45
|
+
return root
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main(argv: list[str] | None = None) -> None:
|
|
49
|
+
args = parser().parse_args(argv)
|
|
50
|
+
try:
|
|
51
|
+
project = (
|
|
52
|
+
CaspianProject.from_root(args.project)
|
|
53
|
+
if args.project
|
|
54
|
+
else CaspianProject.discover()
|
|
55
|
+
)
|
|
56
|
+
if args.command == "init":
|
|
57
|
+
_init(project, args)
|
|
58
|
+
elif args.command == "doctor":
|
|
59
|
+
_doctor(project, args.target)
|
|
60
|
+
elif args.command == "backend":
|
|
61
|
+
print(build_backend(project))
|
|
62
|
+
elif args.command in {"dev", "build"}:
|
|
63
|
+
_tauri(project, args.command, args.target, getattr(args, "debug", False))
|
|
64
|
+
except NativeError as exc:
|
|
65
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
66
|
+
raise SystemExit(2) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _init(project: CaspianProject, args: argparse.Namespace) -> None:
|
|
70
|
+
targets = [target for target, enabled in (("windows", args.windows), ("android", args.android)) if enabled]
|
|
71
|
+
if not targets:
|
|
72
|
+
targets = ["windows"]
|
|
73
|
+
product = args.product_name or project.name.replace("-", " ").title()
|
|
74
|
+
config = NativeConfig(
|
|
75
|
+
product_name=product,
|
|
76
|
+
identifier=args.identifier,
|
|
77
|
+
version=args.app_version,
|
|
78
|
+
targets=targets,
|
|
79
|
+
window=WindowConfig(title=product),
|
|
80
|
+
backend=BackendConfig(desktop_mode=args.desktop_mode, remote_url=args.remote_url),
|
|
81
|
+
security=SecurityConfig(loopback_token=True),
|
|
82
|
+
)
|
|
83
|
+
config.validate()
|
|
84
|
+
for path, outcome in write_generated(project, config, force=args.force):
|
|
85
|
+
print(f"{outcome:9} {path}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _doctor(project: CaspianProject, target: str) -> None:
|
|
89
|
+
config = NativeConfig.load(project.native_config)
|
|
90
|
+
checks = inspect(project, config, target)
|
|
91
|
+
for check in checks:
|
|
92
|
+
print(f"{'OK' if check.ok else 'MISSING':7} {check.name}: {check.detail}")
|
|
93
|
+
if not all(check.ok for check in checks):
|
|
94
|
+
raise NativeError(f"{target} prerequisites are incomplete")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _tauri(project: CaspianProject, action: str, target: str, debug: bool) -> None:
|
|
98
|
+
config = NativeConfig.load(project.native_config)
|
|
99
|
+
if target not in config.targets:
|
|
100
|
+
raise NativeError(f"target {target} is not enabled in caspian.native.json")
|
|
101
|
+
if target == "android" and not config.backend.remote_url:
|
|
102
|
+
raise NativeError("Android requires backend.remoteUrl in caspian-native 0.0.1")
|
|
103
|
+
if target == "windows" and config.backend.desktop_mode == "embedded":
|
|
104
|
+
build_backend(project)
|
|
105
|
+
|
|
106
|
+
command = ["cargo", "tauri"]
|
|
107
|
+
if target == "android":
|
|
108
|
+
command.extend(["android", action])
|
|
109
|
+
else:
|
|
110
|
+
command.append(action)
|
|
111
|
+
if debug and action == "build":
|
|
112
|
+
command.append("--debug")
|
|
113
|
+
result = subprocess.run(command, cwd=project.native_dir, check=False)
|
|
114
|
+
if result.returncode:
|
|
115
|
+
raise NativeError(f"Tauri {action} failed for {target}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main()
|
caspian_native/config.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import NativeError
|
|
10
|
+
|
|
11
|
+
SCHEMA_VERSION = 1
|
|
12
|
+
TARGETS = frozenset({"windows", "android"})
|
|
13
|
+
MODES = frozenset({"embedded", "remote"})
|
|
14
|
+
IDENTIFIER_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)+$")
|
|
15
|
+
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class WindowConfig:
|
|
20
|
+
title: str = "Caspian"
|
|
21
|
+
width: int = 1200
|
|
22
|
+
height: int = 800
|
|
23
|
+
resizable: bool = True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class BackendConfig:
|
|
28
|
+
desktop_mode: str = "embedded"
|
|
29
|
+
remote_url: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class SecurityConfig:
|
|
34
|
+
loopback_token: bool = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class NativeConfig:
|
|
39
|
+
product_name: str
|
|
40
|
+
identifier: str
|
|
41
|
+
version: str = "0.0.1"
|
|
42
|
+
targets: list[str] = field(default_factory=lambda: ["windows"])
|
|
43
|
+
window: WindowConfig = field(default_factory=WindowConfig)
|
|
44
|
+
backend: BackendConfig = field(default_factory=BackendConfig)
|
|
45
|
+
security: SecurityConfig = field(default_factory=SecurityConfig)
|
|
46
|
+
schema: int = SCHEMA_VERSION
|
|
47
|
+
schema_ref: str = "./caspian.native.schema.json"
|
|
48
|
+
|
|
49
|
+
def validate(self) -> None:
|
|
50
|
+
if self.schema != SCHEMA_VERSION:
|
|
51
|
+
raise NativeError(
|
|
52
|
+
f"caspian.native.json schema {self.schema} is unsupported; expected {SCHEMA_VERSION}"
|
|
53
|
+
)
|
|
54
|
+
if not self.product_name.strip():
|
|
55
|
+
raise NativeError("productName cannot be blank")
|
|
56
|
+
if not IDENTIFIER_RE.fullmatch(self.identifier):
|
|
57
|
+
raise NativeError(
|
|
58
|
+
"identifier must be reverse-DNS with legal Java identifiers, "
|
|
59
|
+
"for example com.example.myapp"
|
|
60
|
+
)
|
|
61
|
+
if not VERSION_RE.fullmatch(self.version):
|
|
62
|
+
raise NativeError("version must be a semantic version such as 0.0.1")
|
|
63
|
+
if not self.targets or any(target not in TARGETS for target in self.targets):
|
|
64
|
+
raise NativeError("targets must contain windows, android, or both")
|
|
65
|
+
if self.backend.desktop_mode not in MODES:
|
|
66
|
+
raise NativeError("backend.desktopMode must be embedded or remote")
|
|
67
|
+
if self.backend.remote_url and not self.backend.remote_url.startswith(("https://", "http://")):
|
|
68
|
+
raise NativeError("backend.remoteUrl must be an http or https URL")
|
|
69
|
+
if "android" in self.targets and not self.backend.remote_url:
|
|
70
|
+
raise NativeError(
|
|
71
|
+
"Android requires backend.remoteUrl in caspian-native 0.0.1; "
|
|
72
|
+
"an embedded Android Python runtime is not shipped yet"
|
|
73
|
+
)
|
|
74
|
+
if self.backend.desktop_mode == "remote" and not self.backend.remote_url:
|
|
75
|
+
raise NativeError("remote desktop mode requires backend.remoteUrl")
|
|
76
|
+
if self.window.width < 320 or self.window.height < 320:
|
|
77
|
+
raise NativeError("window width and height must be at least 320")
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def load(cls, path: Path) -> NativeConfig:
|
|
81
|
+
try:
|
|
82
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
except FileNotFoundError as exc:
|
|
84
|
+
raise NativeError(f"native configuration not found: {path}") from exc
|
|
85
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
86
|
+
raise NativeError(f"cannot read {path}: {exc}") from exc
|
|
87
|
+
if not isinstance(raw, dict):
|
|
88
|
+
raise NativeError(f"{path} must contain a JSON object")
|
|
89
|
+
return cls.from_dict(raw)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, raw: dict[str, Any]) -> NativeConfig:
|
|
93
|
+
window_raw = _mapping(raw.get("window"), "window")
|
|
94
|
+
backend_raw = _mapping(raw.get("backend"), "backend")
|
|
95
|
+
security_raw = _mapping(raw.get("security"), "security")
|
|
96
|
+
config = cls(
|
|
97
|
+
product_name=_string(raw.get("productName"), "productName"),
|
|
98
|
+
identifier=_string(raw.get("identifier"), "identifier"),
|
|
99
|
+
version=_string(raw.get("version", "0.0.1"), "version"),
|
|
100
|
+
targets=list(raw.get("targets", ["windows"])),
|
|
101
|
+
window=WindowConfig(
|
|
102
|
+
title=str(window_raw.get("title", raw.get("productName", "Caspian"))),
|
|
103
|
+
width=int(window_raw.get("width", 1200)),
|
|
104
|
+
height=int(window_raw.get("height", 800)),
|
|
105
|
+
resizable=bool(window_raw.get("resizable", True)),
|
|
106
|
+
),
|
|
107
|
+
backend=BackendConfig(
|
|
108
|
+
desktop_mode=str(backend_raw.get("desktopMode", "embedded")),
|
|
109
|
+
remote_url=_optional_string(backend_raw.get("remoteUrl")),
|
|
110
|
+
),
|
|
111
|
+
security=SecurityConfig(
|
|
112
|
+
loopback_token=bool(security_raw.get("loopbackToken", True))
|
|
113
|
+
),
|
|
114
|
+
schema=int(raw.get("schema", SCHEMA_VERSION)),
|
|
115
|
+
schema_ref=str(raw.get("$schema", "./caspian.native.schema.json")),
|
|
116
|
+
)
|
|
117
|
+
config.validate()
|
|
118
|
+
return config
|
|
119
|
+
|
|
120
|
+
def to_dict(self) -> dict[str, Any]:
|
|
121
|
+
return {
|
|
122
|
+
"$schema": self.schema_ref,
|
|
123
|
+
"schema": self.schema,
|
|
124
|
+
"productName": self.product_name,
|
|
125
|
+
"identifier": self.identifier,
|
|
126
|
+
"version": self.version,
|
|
127
|
+
"targets": self.targets,
|
|
128
|
+
"window": {
|
|
129
|
+
"title": self.window.title,
|
|
130
|
+
"width": self.window.width,
|
|
131
|
+
"height": self.window.height,
|
|
132
|
+
"resizable": self.window.resizable,
|
|
133
|
+
},
|
|
134
|
+
"backend": {
|
|
135
|
+
"desktopMode": self.backend.desktop_mode,
|
|
136
|
+
"remoteUrl": self.backend.remote_url,
|
|
137
|
+
},
|
|
138
|
+
"security": {"loopbackToken": self.security.loopback_token},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def write(self, path: Path) -> None:
|
|
142
|
+
path.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _mapping(value: object, name: str) -> dict[str, Any]:
|
|
146
|
+
if value is None:
|
|
147
|
+
return {}
|
|
148
|
+
if not isinstance(value, dict):
|
|
149
|
+
raise NativeError(f"{name} must be a JSON object")
|
|
150
|
+
return value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _string(value: object, name: str) -> str:
|
|
154
|
+
if not isinstance(value, str) or not value.strip():
|
|
155
|
+
raise NativeError(f"{name} must be a non-empty string")
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _optional_string(value: object) -> str | None:
|
|
160
|
+
if value is None or value == "":
|
|
161
|
+
return None
|
|
162
|
+
if not isinstance(value, str):
|
|
163
|
+
raise NativeError("backend.remoteUrl must be a string or null")
|
|
164
|
+
return value.rstrip("/")
|
|
165
|
+
|
caspian_native/doctor.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .config import NativeConfig
|
|
8
|
+
from .project import CaspianProject
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class Check:
|
|
13
|
+
name: str
|
|
14
|
+
ok: bool
|
|
15
|
+
detail: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def inspect(project: CaspianProject, config: NativeConfig, target: str) -> list[Check]:
|
|
19
|
+
checks = [
|
|
20
|
+
Check("Caspian config", (project.root / "caspian.config.json").is_file(), "caspian.config.json"),
|
|
21
|
+
Check("ASGI entry", (project.root / "main.py").is_file(), "main.py exports app"),
|
|
22
|
+
Check("routes", (project.root / "src" / "app").is_dir(), "src/app"),
|
|
23
|
+
Check("public assets", (project.root / "public").is_dir(), "public/"),
|
|
24
|
+
executable_check("cargo", ["cargo", "--version"]),
|
|
25
|
+
executable_check("rustc", ["rustc", "--version"]),
|
|
26
|
+
executable_check("Tauri CLI", ["cargo", "tauri", "--version"]),
|
|
27
|
+
]
|
|
28
|
+
if target == "windows" and config.backend.desktop_mode == "embedded":
|
|
29
|
+
checks.extend(
|
|
30
|
+
[
|
|
31
|
+
executable_check("uv", ["uv", "--version"]),
|
|
32
|
+
Check(
|
|
33
|
+
"route index",
|
|
34
|
+
(project.root / "settings" / "files-list.json").is_file(),
|
|
35
|
+
"run the Caspian project build to generate settings/files-list.json",
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
if target == "android":
|
|
40
|
+
checks.append(
|
|
41
|
+
Check(
|
|
42
|
+
"remote backend",
|
|
43
|
+
bool(config.backend.remote_url),
|
|
44
|
+
"Android is remote-backend only in caspian-native 0.0.1",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
checks.append(executable_check("ADB", ["adb", "version"]))
|
|
48
|
+
return checks
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def executable_check(name: str, command: list[str]) -> Check:
|
|
52
|
+
executable = command[0]
|
|
53
|
+
if shutil.which(executable) is None:
|
|
54
|
+
return Check(name, False, f"{executable} was not found on PATH")
|
|
55
|
+
result = subprocess.run(command, capture_output=True, text=True, check=False)
|
|
56
|
+
detail = (result.stdout or result.stderr).splitlines()
|
|
57
|
+
return Check(name, result.returncode == 0, detail[0] if detail else "available")
|
caspian_native/errors.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .errors import NativeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class CaspianProject:
|
|
12
|
+
root: Path
|
|
13
|
+
name: str
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def native_config(self) -> Path:
|
|
17
|
+
return self.root / "caspian.native.json"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def native_dir(self) -> Path:
|
|
21
|
+
return self.root / "native"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def discover(cls, start: Path | None = None) -> CaspianProject:
|
|
25
|
+
current = (start or Path.cwd()).resolve()
|
|
26
|
+
for candidate in (current, *current.parents):
|
|
27
|
+
config_path = candidate / "caspian.config.json"
|
|
28
|
+
if config_path.is_file():
|
|
29
|
+
return cls.from_root(candidate)
|
|
30
|
+
raise NativeError(
|
|
31
|
+
"not inside a Caspian project: no caspian.config.json was found in this directory "
|
|
32
|
+
"or its parents"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_root(cls, root: Path) -> CaspianProject:
|
|
37
|
+
root = root.resolve()
|
|
38
|
+
required = [root / "caspian.config.json", root / "main.py", root / "src" / "app"]
|
|
39
|
+
missing = [str(path.relative_to(root)) for path in required if not path.exists()]
|
|
40
|
+
if missing:
|
|
41
|
+
raise NativeError(f"not a complete Caspian project; missing: {', '.join(missing)}")
|
|
42
|
+
try:
|
|
43
|
+
raw = json.loads((root / "caspian.config.json").read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
45
|
+
raise NativeError(f"cannot read caspian.config.json: {exc}") from exc
|
|
46
|
+
name = str(raw.get("projectName") or root.name)
|
|
47
|
+
return cls(root=root, name=name)
|
|
48
|
+
|