tauri-runtime 0.1.0__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,79 @@
1
+ """`Channel`:命令执行期间由 Python 向前端 `Channel` 推送数据的流。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Generic, TypeVar
6
+
7
+ from .protocol import ChannelClose, ChannelData, Writer, split_payload
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ class Channel(Generic[T]):
13
+ """指向前端 `Channel` 的单向数据流。
14
+
15
+ 在命令处理器中以 `Channel[T]` 注解参数即可注入,参数名与前端传入的 `Channel` 参数名
16
+ 对应(camelCase 自动转为 snake_case,如 `onProgress` → `on_progress`)。通道仅在命令
17
+ 返回前有效;命令结束后通道被标记为已关闭,再次发送会抛出 `RuntimeError`。
18
+
19
+ `T` 为载荷类型,可以是任意可被 pydantic 序列化为 JSON 的值,也可以是 `Raw` / `bytes`
20
+ (以二进制帧体发送)。
21
+
22
+ Attributes:
23
+ id: 前端 `Channel` 的 id。
24
+ closed: 通道是否已关闭。
25
+
26
+ Example:
27
+ ```python
28
+ @app.command
29
+ async def count(n: int, on_item: Channel[int]) -> int:
30
+ for i in range(n):
31
+ await on_item.send(i)
32
+ return n
33
+ ```
34
+ """
35
+
36
+ def __init__(self, channel_id: int, writer: Writer) -> None:
37
+ self.id = channel_id
38
+ self.closed = False
39
+ self._writer = writer
40
+
41
+ def send_sync(self, value: T) -> None:
42
+ """同步发送一条数据,线程安全,供同步(`def`)命令处理器使用。
43
+
44
+ Args:
45
+ value: 载荷。
46
+
47
+ Raises:
48
+ RuntimeError: 通道已关闭。
49
+ ValueError: 载荷无法编码为合法 JSON(如包含 `NaN` / `±Infinity`)。
50
+ FrameTooLarge: 编码后超过帧大小上限。
51
+ """
52
+ if self.closed:
53
+ raise RuntimeError(f"channel {self.id} is closed")
54
+ fields, body = split_payload(value)
55
+ self._writer.write(ChannelData(channel=self.id, **fields), body)
56
+
57
+ async def send(self, value: T) -> None:
58
+ """发送一条数据,供异步(`async def`)命令处理器使用。
59
+
60
+ Args:
61
+ value: 载荷。
62
+
63
+ Raises:
64
+ RuntimeError: 通道已关闭。
65
+ ValueError: 载荷无法编码为合法 JSON(如包含 `NaN` / `±Infinity`)。
66
+ FrameTooLarge: 编码后超过帧大小上限。
67
+ """
68
+ self.send_sync(value)
69
+
70
+ def close_sync(self) -> None:
71
+ """同步关闭通道并通知前端;重复调用无副作用。"""
72
+ if self.closed:
73
+ return
74
+ self.closed = True
75
+ self._writer.write(ChannelClose(channel=self.id))
76
+
77
+ async def close(self) -> None:
78
+ """异步关闭通道并通知前端;重复调用无副作用。"""
79
+ self.close_sync()
@@ -0,0 +1,75 @@
1
+ """`tauri-runtime` 命令行入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from .bundle import BuildError, build
11
+ from .scaffold import MANAGERS, ScaffoldError, Source, create, init
12
+ from .templates import FRONTEND_SNIPPET
13
+
14
+
15
+ def add_source_options(parser: argparse.ArgumentParser) -> None:
16
+ group = parser.add_mutually_exclusive_group()
17
+ group.add_argument("--git", action="store_true", help="install the plugin from GitHub instead of the registries")
18
+ group.add_argument("--path", type=Path, metavar="REPO", help="install the plugin from a local checkout")
19
+
20
+
21
+ def source_of(options: argparse.Namespace) -> Source:
22
+ if options.path is not None:
23
+ return Source("path", options.path.resolve())
24
+ return Source("git") if options.git else Source()
25
+
26
+
27
+ def report(project: Path, hints: list[str]) -> None:
28
+ """打印手动步骤与前端调用示例。"""
29
+ for hint in hints:
30
+ print(f"tauri-runtime: manual step: {hint}", file=sys.stderr)
31
+ print(f"tauri-runtime: Python backend ready in {project / 'src-python'}")
32
+ print(f"call it from the frontend:\n\n{FRONTEND_SNIPPET}")
33
+
34
+
35
+ def parser() -> argparse.ArgumentParser:
36
+ root = argparse.ArgumentParser(prog="tauri-runtime")
37
+ commands = root.add_subparsers(dest="command", required=True)
38
+
39
+ build_parser = commands.add_parser("build", help="package the Python backend with Nuitka (standalone)")
40
+ build_parser.add_argument("--project", type=Path, default=Path.cwd(), help="Python project directory")
41
+
42
+ init_parser = commands.add_parser("init", help="add a Python backend (src-python) to an existing Tauri project")
43
+ init_parser.add_argument("--project", type=Path, default=Path.cwd(), help="Tauri project root")
44
+ init_parser.add_argument("--force", action="store_true", help="continue when src-python already exists")
45
+ add_source_options(init_parser)
46
+
47
+ create_parser = commands.add_parser("create", help="create a Tauri project with a Python backend")
48
+ create_parser.add_argument("name", help="project directory name")
49
+ create_parser.add_argument("--template", default="vue-ts", help="create-tauri-app template")
50
+ create_parser.add_argument("--manager", default="pnpm", choices=MANAGERS, help="frontend package manager")
51
+ add_source_options(create_parser)
52
+ return root
53
+
54
+
55
+ def main(argv: Sequence[str] | None = None) -> int:
56
+ """命令行入口:`tauri-runtime {build,init,create} ...`;`build` 的 `--` 之后的参数传给 Nuitka。"""
57
+ args = list(sys.argv[1:] if argv is None else argv)
58
+ extra: list[str] = []
59
+ if "--" in args:
60
+ index = args.index("--")
61
+ args, extra = args[:index], args[index + 1 :]
62
+ options = parser().parse_args(args)
63
+ try:
64
+ if options.command == "build":
65
+ print(f"tauri-runtime: built {build(options.project.resolve(), extra)}")
66
+ elif options.command == "init":
67
+ project = options.project.resolve()
68
+ report(project, init(project, source_of(options), force=options.force))
69
+ else:
70
+ project, hints = create(options.name, options.template, options.manager, source_of(options))
71
+ report(project, hints)
72
+ except (BuildError, ScaffoldError) as e:
73
+ print(f"tauri-runtime: {e}", file=sys.stderr)
74
+ return 1
75
+ return 0
@@ -0,0 +1,127 @@
1
+ """`tauri-runtime build`:用 Nuitka 将 Python 后端打包为 standalone 目录。
2
+
3
+ 配置读取项目 `pyproject.toml` 的 `[tool.tauri-runtime.build]`,产物布局固定为
4
+ `<output>/<name>/<name>`(Windows 为 `<name>.exe`),与 Rust 插件的 `Builder::bundle(name)` 对应。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib.util
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ import tempfile
15
+ import tomllib
16
+ from collections.abc import Callable, Sequence
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ NAME_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
22
+ KEYS = frozenset({"entry", "name", "output", "nuitka-args"})
23
+
24
+
25
+ class BuildError(Exception):
26
+ """打包失败(配置错误、缺少 Nuitka 或编译失败),消息直接展示给用户。"""
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class BuildConfig:
31
+ """`[tool.tauri-runtime.build]` 配置。
32
+
33
+ Attributes:
34
+ entry: 入口脚本,相对项目目录。
35
+ name: 可执行文件名与产物目录名,只能包含字母、数字、`.`、`_`、`-`。
36
+ output: 输出目录,相对项目目录。
37
+ nuitka_args: 透传给 Nuitka 的额外参数。
38
+ """
39
+
40
+ entry: str = "main.py"
41
+ name: str = "py-backend"
42
+ output: str = "dist"
43
+ nuitka_args: tuple[str, ...] = ()
44
+
45
+
46
+ def load_config(project: Path) -> BuildConfig:
47
+ """读取项目的打包配置;缺少配置段时使用默认值,未知键或非法值抛出 `BuildError`。"""
48
+ pyproject = project / "pyproject.toml"
49
+ if not pyproject.is_file():
50
+ raise BuildError(f"{pyproject} not found; run inside your Python project or pass --project")
51
+ with pyproject.open("rb") as f:
52
+ data: dict[str, Any] = tomllib.load(f)
53
+ table: dict[str, Any] = data.get("tool", {}).get("tauri-runtime", {}).get("build", {})
54
+ unknown = set(table) - KEYS
55
+ if unknown:
56
+ raise BuildError(f"unknown keys in [tool.tauri-runtime.build]: {', '.join(sorted(unknown))}")
57
+ config = BuildConfig(
58
+ entry=str(table.get("entry", BuildConfig.entry)),
59
+ name=str(table.get("name", BuildConfig.name)),
60
+ output=str(table.get("output", BuildConfig.output)),
61
+ nuitka_args=tuple(str(a) for a in table.get("nuitka-args", ())),
62
+ )
63
+ if not NAME_PATTERN.match(config.name):
64
+ raise BuildError(f"invalid name '{config.name}': use letters, digits, '.', '_' or '-'")
65
+ return config
66
+
67
+
68
+ def executable_name(name: str, platform: str = sys.platform) -> str:
69
+ """产物中可执行文件的文件名:Windows 追加 `.exe`。"""
70
+ return f"{name}.exe" if platform == "win32" else name
71
+
72
+
73
+ def nuitka_available() -> bool:
74
+ """当前环境能否导入 Nuitka。"""
75
+ return importlib.util.find_spec("nuitka") is not None
76
+
77
+
78
+ def nuitka_command(config: BuildConfig, build_dir: Path, extra: Sequence[str]) -> list[str]:
79
+ """构造 Nuitka standalone 编译命令;使用当前解释器,因此项目依赖均可被收集。"""
80
+ return [
81
+ sys.executable,
82
+ "-m",
83
+ "nuitka",
84
+ "--standalone",
85
+ "--assume-yes-for-downloads",
86
+ "--remove-output",
87
+ f"--output-dir={build_dir}",
88
+ f"--output-filename={config.name}",
89
+ *config.nuitka_args,
90
+ *extra,
91
+ config.entry,
92
+ ]
93
+
94
+
95
+ def build(
96
+ project: Path,
97
+ extra: Sequence[str] = (),
98
+ run: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
99
+ ) -> Path:
100
+ """编译项目并将产物整理到 `<output>/<name>`,返回可执行文件路径。
101
+
102
+ 在临时目录中编译,成功后才替换旧产物,失败时不会留下半成品。
103
+
104
+ Args:
105
+ project: Python 项目目录(包含 `pyproject.toml`)。
106
+ extra: 追加给 Nuitka 的命令行参数。
107
+ run: 执行子进程的函数,测试时可替换。
108
+
109
+ Raises:
110
+ BuildError: 配置错误、缺少 Nuitka 或入口脚本,或 Nuitka 以非零退出码结束。
111
+ """
112
+ config = load_config(project)
113
+ if not nuitka_available():
114
+ raise BuildError("Nuitka is not installed in this environment; run `uv add --dev nuitka`")
115
+ if not (project / config.entry).is_file():
116
+ raise BuildError(f"entry script {project / config.entry} not found")
117
+ target = project / config.output / config.name
118
+ with tempfile.TemporaryDirectory(prefix="tauri-runtime-build-") as tmp:
119
+ build_dir = Path(tmp)
120
+ result = run(nuitka_command(config, build_dir, extra), cwd=project, check=False)
121
+ if result.returncode != 0:
122
+ raise BuildError(f"nuitka failed with exit code {result.returncode}")
123
+ if target.exists():
124
+ shutil.rmtree(target)
125
+ target.parent.mkdir(parents=True, exist_ok=True)
126
+ shutil.move(str(build_dir / f"{Path(config.entry).stem}.dist"), str(target))
127
+ return target / executable_name(config.name)
@@ -0,0 +1,310 @@
1
+ """`tauri-runtime init` / `create`:为 Tauri 项目接入 Python 后端。
2
+
3
+ 文件修改均为纯函数(输入文本 → 输出文本),可重复执行;外部命令经可替换的 `run` 执行。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ from collections.abc import Callable, Sequence
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ from ..protocol import SDK_VERSION
17
+ from .templates import (
18
+ BUNDLE_DIR,
19
+ BUNDLE_NAME,
20
+ GITIGNORE,
21
+ MAIN_PY,
22
+ REPO_URL,
23
+ build_rs_snippet,
24
+ pyproject_toml,
25
+ rust_snippet,
26
+ )
27
+
28
+ Runner = Callable[..., Any]
29
+
30
+ CRATE = "tauri-plugin-py-runtime"
31
+ NPM_PACKAGE = "tauri-plugin-py-runtime-api"
32
+ GITHUB_SPEC = "github:InfernalAzazel/tauri-plugin-py-runtime"
33
+ BUILD_STEP = "cd src-python && uv run tauri-runtime build"
34
+ RESOURCE_SRC = f"{BUNDLE_DIR}/"
35
+ RESOURCE_DST = f"{BUNDLE_NAME}/"
36
+ PERMISSION = "py-runtime:default"
37
+ BUILDER_CALL = "tauri::Builder::default()"
38
+ BUILD_CALL = "tauri_build::build()"
39
+ MANAGERS = ("pnpm", "yarn", "bun", "npm")
40
+ LOCKFILES = (
41
+ ("pnpm-lock.yaml", "pnpm"),
42
+ ("yarn.lock", "yarn"),
43
+ ("bun.lock", "bun"),
44
+ ("bun.lockb", "bun"),
45
+ ("package-lock.json", "npm"),
46
+ )
47
+
48
+
49
+ class ScaffoldError(Exception):
50
+ """脚手架步骤失败(项目结构不符或外部命令出错)。"""
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Source:
55
+ """插件三个包的来源:发布版本、GitHub 仓库或本地仓库目录(`path`)。"""
56
+
57
+ kind: Literal["registry", "git", "path"] = "registry"
58
+ path: Path | None = None
59
+
60
+
61
+ def dump_json(data: object) -> str:
62
+ return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
63
+
64
+
65
+ def patch_tauri_conf(text: str) -> tuple[str, list[str]]:
66
+ """在 `beforeBuildCommand` 后追加 Nuitka 打包,并把产物加入 `bundle.resources`。
67
+
68
+ 返回新文本与无法自动处理、需用户手动完成的提示。
69
+ """
70
+ conf = json.loads(text)
71
+ hints: list[str] = []
72
+
73
+ build = conf.setdefault("build", {})
74
+ command = build.get("beforeBuildCommand")
75
+ if not command:
76
+ build["beforeBuildCommand"] = BUILD_STEP
77
+ elif isinstance(command, str):
78
+ if BUILD_STEP not in command:
79
+ build["beforeBuildCommand"] = f"{command} && {BUILD_STEP}"
80
+ else:
81
+ hints.append(f'append "{BUILD_STEP}" to build.beforeBuildCommand')
82
+
83
+ bundle = conf.setdefault("bundle", {})
84
+ resources = bundle.get("resources")
85
+ if resources is None:
86
+ bundle["resources"] = {RESOURCE_SRC: RESOURCE_DST}
87
+ elif isinstance(resources, dict):
88
+ resources[RESOURCE_SRC] = RESOURCE_DST
89
+ else:
90
+ hints.append(f'add "{RESOURCE_SRC}" -> "{RESOURCE_DST}" to bundle.resources (map form)')
91
+
92
+ return dump_json(conf), hints
93
+
94
+
95
+ def patch_capability(text: str) -> str:
96
+ """为 capability 文件的 `permissions` 加上插件默认权限。"""
97
+ conf = json.loads(text)
98
+ permissions = conf.setdefault("permissions", [])
99
+ if PERMISSION not in permissions:
100
+ permissions.append(PERMISSION)
101
+ return dump_json(conf)
102
+
103
+
104
+ def patch_lib_rs(text: str) -> str | None:
105
+ """在唯一的 `tauri::Builder::default()` 行后插入插件注册。
106
+
107
+ 已注册时原样返回;无法识别(没有或有多处该调用、调用不在行尾)时返回 None。
108
+ """
109
+ if "tauri_plugin_py_runtime" in text:
110
+ return text
111
+ if text.count(BUILDER_CALL) != 1:
112
+ return None
113
+ lines = text.splitlines(keepends=True)
114
+ for index, line in enumerate(lines):
115
+ if BUILDER_CALL in line:
116
+ if line.strip() != BUILDER_CALL:
117
+ return None
118
+ indent = line[: len(line) - len(line.lstrip())]
119
+ lines.insert(index + 1, rust_snippet(indent + " "))
120
+ return "".join(lines)
121
+ return None
122
+
123
+
124
+ def patch_build_rs(text: str) -> str | None:
125
+ """在唯一的 `tauri_build::build()` 前创建 Nuitka 产物目录。
126
+
127
+ tauri-build 在 dev / check 时也要求 `bundle.resources` 路径存在,而产物只在打包时生成。
128
+ 已处理时原样返回;无法识别时返回 None。
129
+ """
130
+ if BUNDLE_DIR in text:
131
+ return text
132
+ if text.count(BUILD_CALL) != 1:
133
+ return None
134
+ lines = text.splitlines(keepends=True)
135
+ for index, line in enumerate(lines):
136
+ if BUILD_CALL in line:
137
+ if line.strip() != BUILD_CALL:
138
+ return None
139
+ lines.insert(index, build_rs_snippet(line[: len(line) - len(line.lstrip())]))
140
+ return "".join(lines)
141
+ return None
142
+
143
+
144
+ def detect_package_manager(root: Path) -> str:
145
+ """依据锁文件判断前端包管理器;没有锁文件时看 `beforeDevCommand` 的首个单词,默认 npm。"""
146
+ for lockfile, manager in LOCKFILES:
147
+ if (root / lockfile).exists():
148
+ return manager
149
+ conf = root / "src-tauri" / "tauri.conf.json"
150
+ if conf.is_file():
151
+ command = json.loads(conf.read_text(encoding="utf-8")).get("build", {}).get("beforeDevCommand")
152
+ if isinstance(command, str) and command.split() and command.split()[0] in MANAGERS:
153
+ return command.split()[0]
154
+ return "npm"
155
+
156
+
157
+ def cargo_add_command(source: Source) -> list[str]:
158
+ """在项目根目录执行的 `cargo add` 命令。"""
159
+ if source.kind == "git":
160
+ spec = [CRATE, "--git", REPO_URL]
161
+ elif source.kind == "path":
162
+ assert source.path is not None
163
+ spec = [CRATE, "--path", str(source.path)]
164
+ else:
165
+ spec = [f"{CRATE}@{SDK_VERSION}"]
166
+ return ["cargo", "add", *spec, "--manifest-path", str(Path("src-tauri") / "Cargo.toml")]
167
+
168
+
169
+ def frontend_add_command(manager: str, source: Source) -> list[str]:
170
+ """安装前端 API 包的命令。"""
171
+ if source.kind == "git":
172
+ spec = GITHUB_SPEC
173
+ elif source.kind == "path":
174
+ assert source.path is not None
175
+ spec = f"{'link' if manager == 'pnpm' else 'file'}:{source.path}"
176
+ else:
177
+ spec = f"{NPM_PACKAGE}@{SDK_VERSION}"
178
+ if manager == "pnpm" and source.kind == "git":
179
+ # pnpm 10+ 默认禁止 git 依赖执行构建脚本(`prepare` 编译 dist-js),需显式放行。
180
+ return ["pnpm", "add", f"--allow-build={NPM_PACKAGE}", spec]
181
+ return [manager, "install" if manager == "npm" else "add", spec]
182
+
183
+
184
+ def create_command(name: str, template: str, manager: str) -> list[str]:
185
+ """调用 create-tauri-app 创建项目的命令。"""
186
+ options = [name, "--template", template, "--manager", manager, "--yes"]
187
+ if manager == "npm":
188
+ return ["npm", "create", "tauri-app@latest", "--", *options]
189
+ return [manager, "create", "tauri-app", *options]
190
+
191
+
192
+ def check_source(source: Source) -> None:
193
+ """本地来源须指向本仓库根目录(含 `python-sdk` 与 `Cargo.toml`)。"""
194
+ if source.kind != "path":
195
+ return
196
+ assert source.path is not None
197
+ if not ((source.path / "python-sdk").is_dir() and (source.path / "Cargo.toml").is_file()):
198
+ raise ScaffoldError(f"{source.path} is not a tauri-plugin-py-runtime checkout")
199
+
200
+
201
+ def run_step(run: Runner, cmd: Sequence[str], cwd: Path) -> None:
202
+ """执行外部命令,失败时抛出 ScaffoldError;经 `shutil.which` 解析以支持 Windows 的 `.cmd`。"""
203
+ print(f"tauri-runtime: $ {' '.join(cmd)}", flush=True)
204
+ program = shutil.which(cmd[0])
205
+ if program is None:
206
+ raise ScaffoldError(f"`{cmd[0]}` not found on PATH")
207
+ result = run([program, *cmd[1:]], cwd=cwd, check=False)
208
+ if result.returncode != 0:
209
+ raise ScaffoldError(f"`{' '.join(cmd)}` failed with exit code {result.returncode}")
210
+
211
+
212
+ def write_backend(directory: Path, source: Source) -> None:
213
+ """生成 `src-python`;已有文件保持不变。"""
214
+ directory.mkdir(parents=True, exist_ok=True)
215
+ files = {"pyproject.toml": pyproject_toml(source), "main.py": MAIN_PY, ".gitignore": GITIGNORE}
216
+ for name, content in files.items():
217
+ path = directory / name
218
+ if not path.exists():
219
+ path.write_text(content, encoding="utf-8")
220
+
221
+
222
+ def patch_file(path: Path, patch: Callable[[str], str]) -> None:
223
+ text = path.read_text(encoding="utf-8")
224
+ patched = patch(text)
225
+ if patched != text:
226
+ path.write_text(patched, encoding="utf-8")
227
+
228
+
229
+ def init(
230
+ project: Path,
231
+ source: Source | None = None,
232
+ force: bool = False,
233
+ manager: str | None = None,
234
+ run: Runner = subprocess.run,
235
+ ) -> list[str]:
236
+ """为已有 Tauri 项目接入 Python 后端,返回需用户手动完成的提示。"""
237
+ source = source or Source()
238
+ check_source(source)
239
+ tauri_dir = project / "src-tauri"
240
+ if not tauri_dir.is_dir():
241
+ raise ScaffoldError(f"{tauri_dir} not found; run inside a Tauri project or pass --project")
242
+ backend = project / "src-python"
243
+ if backend.exists() and not force:
244
+ raise ScaffoldError(f"{backend} already exists; pass --force to keep it and finish the setup")
245
+ hints: list[str] = []
246
+
247
+ write_backend(backend, source)
248
+ run_step(run, ["uv", "sync"], backend)
249
+
250
+ conf = tauri_dir / "tauri.conf.json"
251
+ if conf.is_file():
252
+ text, conf_hints = patch_tauri_conf(conf.read_text(encoding="utf-8"))
253
+ conf.write_text(text, encoding="utf-8")
254
+ hints += [f"tauri.conf.json: {hint}" for hint in conf_hints]
255
+ else:
256
+ hints.append(
257
+ f'Tauri config: append "{BUILD_STEP}" to build.beforeBuildCommand and map '
258
+ f'"{RESOURCE_SRC}" to "{RESOURCE_DST}" in bundle.resources'
259
+ )
260
+
261
+ capability = tauri_dir / "capabilities" / "default.json"
262
+ if capability.is_file():
263
+ patch_file(capability, patch_capability)
264
+ else:
265
+ hints.append(f'add "{PERMISSION}" to the permissions of your capability file')
266
+
267
+ run_step(run, cargo_add_command(source), project)
268
+
269
+ if (project / "package.json").is_file():
270
+ run_step(run, frontend_add_command(manager or detect_package_manager(project), source), project)
271
+ if source.kind == "path" and source.path is not None and not (source.path / "dist-js").is_dir():
272
+ hints.append(f"build the frontend package first: run `pnpm install && pnpm build` in {source.path}")
273
+ else:
274
+ hints.append(f"install the frontend package {NPM_PACKAGE}")
275
+
276
+ lib_rs = tauri_dir / "src" / "lib.rs"
277
+ patched = patch_lib_rs(lib_rs.read_text(encoding="utf-8")) if lib_rs.is_file() else None
278
+ if patched is None:
279
+ hints.append(f"register the plugin on your tauri::Builder:\n{rust_snippet(' ')}")
280
+ else:
281
+ patch_file(lib_rs, lambda _: patched)
282
+
283
+ build_rs = tauri_dir / "build.rs"
284
+ patched = patch_build_rs(build_rs.read_text(encoding="utf-8")) if build_rs.is_file() else None
285
+ if patched is None:
286
+ hints.append(f"create the Python bundle directory before tauri_build::build() in build.rs:\n{build_rs_snippet(' ')}")
287
+ else:
288
+ patch_file(build_rs, lambda _: patched)
289
+ return hints
290
+
291
+
292
+ def create(
293
+ name: str,
294
+ template: str = "vue-ts",
295
+ manager: str = "pnpm",
296
+ source: Source | None = None,
297
+ cwd: Path | None = None,
298
+ run: Runner = subprocess.run,
299
+ ) -> tuple[Path, list[str]]:
300
+ """用 create-tauri-app 新建项目后执行 `init`,返回项目目录与提示。"""
301
+ source = source or Source()
302
+ check_source(source)
303
+ cwd = cwd or Path.cwd()
304
+ project = cwd / name
305
+ if project.exists():
306
+ raise ScaffoldError(f"{project} already exists")
307
+ run_step(run, create_command(name, template, manager), cwd)
308
+ if not project.is_dir():
309
+ raise ScaffoldError(f"create-tauri-app did not create {project}")
310
+ return project, init(project, source, manager=manager, run=run)
@@ -0,0 +1,92 @@
1
+ """`tauri-runtime init` 生成的文件模板与打印给用户的代码片段。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from .scaffold import Source
10
+
11
+ REPO_URL = "https://github.com/InfernalAzazel/tauri-plugin-py-runtime"
12
+ BUNDLE_NAME = "py-backend"
13
+ BUNDLE_DIR = f"../src-python/dist/{BUNDLE_NAME}"
14
+
15
+ MAIN_PY = '''\
16
+ from tauri_runtime import App
17
+
18
+ app = App()
19
+
20
+
21
+ @app.command
22
+ async def greet(name: str) -> str:
23
+ return f"Hello {name}, from Python!"
24
+
25
+
26
+ app.run()
27
+ '''
28
+
29
+ GITIGNORE = """\
30
+ .venv/
31
+ dist/
32
+ __pycache__/
33
+ """
34
+
35
+ FRONTEND_SNIPPET = """\
36
+ import { pyInvoke } from "tauri-plugin-py-runtime-api";
37
+
38
+ const message = await pyInvoke<string>("greet", { name: "Tauri" });
39
+ """
40
+
41
+
42
+ def rust_snippet(indent: str = "") -> str:
43
+ """注册插件的链式调用片段,每行加 `indent` 前缀。"""
44
+ lines = [
45
+ ".plugin(",
46
+ " tauri_plugin_py_runtime::Builder::new()",
47
+ ' .script("../src-python/main.py")',
48
+ f' .bundle("{BUNDLE_NAME}")',
49
+ " .build(),",
50
+ ")",
51
+ ]
52
+ return "".join(f"{indent}{line}\n" for line in lines)
53
+
54
+
55
+ def build_rs_snippet(indent: str = "") -> str:
56
+ """`build.rs` 中创建 Nuitka 产物目录的语句,供 tauri-build 校验 `bundle.resources`。"""
57
+ lines = [
58
+ "// tauri-build 要求 bundle.resources 的路径存在;Nuitka 产物由 `tauri-runtime build` 生成。",
59
+ f'std::fs::create_dir_all("{BUNDLE_DIR}").expect("failed to create the Python bundle directory");',
60
+ ]
61
+ return "".join(f"{indent}{line}\n" for line in lines)
62
+
63
+
64
+ def uv_source(source: Source) -> str | None:
65
+ """`[tool.uv.sources]` 中 `tauri-runtime` 的取值;registry 来源无需此项。"""
66
+ if source.kind == "git":
67
+ return f'{{ git = "{REPO_URL}", subdirectory = "python-sdk" }}'
68
+ if source.kind == "path":
69
+ assert source.path is not None
70
+ return f"{{ path = {json.dumps((source.path / 'python-sdk').as_posix())}, editable = true }}"
71
+ return None
72
+
73
+
74
+ def pyproject_toml(source: Source) -> str:
75
+ """`src-python/pyproject.toml`:依赖 SDK、dev 组含 Nuitka,打包名与 Rust `bundle` 对应。"""
76
+ text = """\
77
+ [project]
78
+ name = "backend"
79
+ version = "0.1.0"
80
+ requires-python = ">=3.11"
81
+ dependencies = ["tauri-runtime"]
82
+
83
+ [dependency-groups]
84
+ dev = ["nuitka"]
85
+
86
+ [tool.uv]
87
+ package = false
88
+ """
89
+ value = uv_source(source)
90
+ if value is not None:
91
+ text += f"\n[tool.uv.sources]\ntauri-runtime = {value}\n"
92
+ return text + f'\n[tool.tauri-runtime.build]\nname = "{BUNDLE_NAME}"\n'