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.
@@ -0,0 +1,8 @@
1
+ """Native WebView packaging for Caspian applications."""
2
+
3
+ from .config import NativeConfig
4
+
5
+ __all__ = ["NativeConfig", "__version__"]
6
+
7
+ __version__ = "0.0.1"
8
+
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
5
+
@@ -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()
@@ -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
+
@@ -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")
@@ -0,0 +1,3 @@
1
+ class NativeError(RuntimeError):
2
+ """A user-actionable native packaging failure."""
3
+
@@ -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
+