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,483 @@
1
+ """线协议(v2):消息模型、帧编解码与 stdio 接管。
2
+
3
+ 每一帧的布局为::
4
+
5
+ [u32 BE inner_len][u32 BE header_len][header JSON][body]
6
+
7
+ `inner_len` 计入其后的全部字节(不含自身),上限为 `MAX_FRAME_SIZE`。header 是一个以
8
+ `type` 字段区分的 JSON 对象,在 Python 侧由 pydantic 模型组成的可辨识联合 `Message` 表示,
9
+ 与 Rust 侧的 `bridge::protocol::Message` 枚举一一对应。
10
+
11
+ 载荷有两种形式:JSON 值内联在 header 的 `value` 字段中;二进制数据放在帧体 `body` 中,
12
+ 并在 header 中设置 `raw: true`。未设置 `raw` 时帧体必须为空。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import struct
20
+ import sys
21
+ import threading
22
+ from dataclasses import dataclass
23
+ from typing import Annotated, Any, BinaryIO, Literal, NoReturn, Union
24
+
25
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
26
+
27
+ PROTOCOL_VERSION = 2
28
+ SDK_VERSION = "0.1.0"
29
+ MAX_FRAME_SIZE = 64 * 1024 * 1024
30
+
31
+ _U32 = struct.Struct(">I")
32
+
33
+
34
+ class ProtocolError(Exception):
35
+ """协议层错误的基类。
36
+
37
+ 直接抛出该类型(而非子类)表示字节流已损坏或意外结束(如帧内 EOF),无法继续读取。
38
+ """
39
+
40
+
41
+ class MalformedFrame(ProtocolError):
42
+ """已完整读取一帧但无法解码;字节流仍保持帧对齐,可以跳过该帧继续读取。"""
43
+
44
+
45
+ class FrameTooLarge(MalformedFrame):
46
+ """帧大小超过上限。
47
+
48
+ 读取时超限帧的内容会被完整丢弃以保持对齐;写入时则拒绝发送。
49
+
50
+ Attributes:
51
+ size: 超限帧的字节数(不含 4 字节外层长度前缀)。
52
+ """
53
+
54
+ def __init__(self, size: int) -> None:
55
+ super().__init__(f"frame of {size} bytes exceeds limit")
56
+ self.size = size
57
+
58
+
59
+ class Raw:
60
+ """二进制载荷。
61
+
62
+ 作为命令参数注解时,接收前端传入的 `Uint8Array` / `ArrayBuffer`;作为返回值或经
63
+ `Channel` / `App.emit` 发送时,数据放入帧体并设置 `raw: true`,前端收到 `Uint8Array`。
64
+
65
+ Args:
66
+ data: 原始字节,会被复制为不可变的 `bytes`。
67
+
68
+ Attributes:
69
+ bytes: 载荷内容。
70
+
71
+ Example:
72
+ ```python
73
+ @app.command
74
+ def reverse(data: Raw) -> Raw:
75
+ return Raw(data.bytes[::-1])
76
+ ```
77
+ """
78
+
79
+ __slots__ = ("bytes",)
80
+
81
+ def __init__(self, data: bytes | bytearray | memoryview) -> None:
82
+ self.bytes = bytes(data)
83
+
84
+ def __eq__(self, other: object) -> bool:
85
+ return isinstance(other, Raw) and other.bytes == self.bytes
86
+
87
+ def __repr__(self) -> str:
88
+ return f"Raw({len(self.bytes)} bytes)"
89
+
90
+
91
+ # 字段声明顺序即线上 JSON 的字段顺序,须与 Rust `Message` 枚举保持一致(golden frames 按字节比对)。
92
+
93
+
94
+ class _Message(BaseModel):
95
+ """所有协议消息的基类:严格类型校验,允许按字段名或别名填充。"""
96
+
97
+ model_config = ConfigDict(strict=True, populate_by_name=True)
98
+
99
+
100
+ class Hello(_Message):
101
+ """握手消息(Python → Rust),启动后发送的第一帧。
102
+
103
+ Attributes:
104
+ protocol: Python 侧的协议版本,须与 Rust 侧一致,否则启动失败(`protocol_mismatch`)。
105
+ sdk: SDK 版本号。
106
+ commands: 已注册的命令名列表(已排序)。
107
+ """
108
+
109
+ type: Literal["hello"] = "hello"
110
+ protocol: int
111
+ sdk: str = SDK_VERSION
112
+ commands: list[str] = []
113
+
114
+
115
+ class AppInfo(_Message):
116
+ """宿主 Tauri 应用的基本信息,随 `Ready` 下发。
117
+
118
+ Attributes:
119
+ name: 应用名称。
120
+ version: 应用版本。
121
+ """
122
+
123
+ name: str
124
+ version: str
125
+
126
+
127
+ class Ready(_Message):
128
+ """握手确认(Rust → Python);收到后运行 `ready` 钩子。
129
+
130
+ Attributes:
131
+ protocol: Rust 侧的协议版本。
132
+ app: 宿主应用信息。
133
+ """
134
+
135
+ type: Literal["ready"] = "ready"
136
+ protocol: int
137
+ app: AppInfo
138
+
139
+
140
+ class Shutdown(_Message):
141
+ """关闭通知(Rust → Python)。
142
+
143
+ Attributes:
144
+ timeout_ms: 关闭预算(毫秒),线上字段名为 `timeoutMs`。类型刻意放宽为 `Any`,
145
+ 以便非法值不会导致整帧被丢弃,而是由运行时回退到默认的 3 秒。
146
+ """
147
+
148
+ type: Literal["shutdown"] = "shutdown"
149
+ timeout_ms: Any = Field(None, alias="timeoutMs")
150
+
151
+
152
+ class Request(_Message):
153
+ """命令调用请求(Rust → Python)。
154
+
155
+ Attributes:
156
+ id: 调用 id,后续的 `Response` / `Error` / `Cancel` 均以此关联。
157
+ cmd: 命令名。
158
+ channels: 参数名(camelCase)到前端 `Channel` id 的映射。
159
+ window: 发起调用的 webview 标签。
160
+ value: 内联的 JSON 参数对象。
161
+ raw: 为 `True` 时参数为帧体中的二进制数据。
162
+ """
163
+
164
+ type: Literal["request"] = "request"
165
+ id: int
166
+ cmd: str
167
+ channels: dict[str, int] = {}
168
+ window: str | None = None
169
+ value: Any = None
170
+ raw: bool | None = None
171
+
172
+
173
+ class Response(_Message):
174
+ """命令成功返回(Python → Rust)。
175
+
176
+ Attributes:
177
+ id: 对应请求的调用 id。
178
+ value: 内联的 JSON 返回值。
179
+ raw: 为 `True` 时返回值为帧体中的二进制数据。
180
+ """
181
+
182
+ type: Literal["response"] = "response"
183
+ id: int
184
+ value: Any = None
185
+ raw: bool | None = None
186
+
187
+
188
+ class Error(_Message):
189
+ """命令失败返回(Python → Rust)。
190
+
191
+ Attributes:
192
+ id: 对应请求的调用 id。
193
+ code: 错误码,原样透传给前端(如 `invalid_args`、`internal` 或 `TauriError` 的自定义码)。
194
+ message: 可读的错误描述。
195
+ data: 附加的 JSON 数据,可选。
196
+ """
197
+
198
+ type: Literal["error"] = "error"
199
+ id: int
200
+ code: str | None = None
201
+ message: str | None = None
202
+ data: Any = None
203
+
204
+
205
+ class Cancel(_Message):
206
+ """取消调用(Rust → Python)。
207
+
208
+ Attributes:
209
+ id: 要取消的调用 id。
210
+ reason: 取消原因,`"client"`(前端主动中止)或 `"timeout"`(调用超时)。
211
+ """
212
+
213
+ type: Literal["cancel"] = "cancel"
214
+ id: int
215
+ reason: str | None = None
216
+
217
+
218
+ class Event(_Message):
219
+ """事件消息(双向)。
220
+
221
+ Python → Rust 方向使用 `target`,Rust → Python 方向使用 `window`。
222
+
223
+ Attributes:
224
+ event: 事件名。
225
+ target: 目标 webview 标签;为空时广播。
226
+ window: 发出事件的 webview 标签。
227
+ value: 内联的 JSON 载荷。
228
+ raw: 为 `True` 时载荷为帧体中的二进制数据。
229
+ """
230
+
231
+ type: Literal["event"] = "event"
232
+ event: str
233
+ target: str | None = None
234
+ window: str | None = None
235
+ value: Any = None
236
+ raw: bool | None = None
237
+
238
+
239
+ class ChannelData(_Message):
240
+ """向前端 `Channel` 推送一条数据(Python → Rust)。
241
+
242
+ Attributes:
243
+ channel: 前端 `Channel` 的 id。
244
+ value: 内联的 JSON 载荷。
245
+ raw: 为 `True` 时载荷为帧体中的二进制数据。
246
+ """
247
+
248
+ type: Literal["channel_data"] = "channel_data"
249
+ channel: int
250
+ value: Any = None
251
+ raw: bool | None = None
252
+
253
+
254
+ class ChannelClose(_Message):
255
+ """关闭前端 `Channel`(Python → Rust)。
256
+
257
+ Attributes:
258
+ channel: 前端 `Channel` 的 id。
259
+ """
260
+
261
+ type: Literal["channel_close"] = "channel_close"
262
+ channel: int
263
+
264
+
265
+ Message = Annotated[
266
+ Union[Hello, Ready, Shutdown, Request, Response, Error, Cancel, Event, ChannelData, ChannelClose],
267
+ Field(discriminator="type"),
268
+ ]
269
+ # NaN / ±Infinity 不是合法 JSON。让 pydantic 将其序列化为 JSON 常量(嵌套的用户模型同样适用),
270
+ # encode() 才能检测并拒绝;否则 pydantic 默认会静默写成 null。
271
+ MESSAGE: TypeAdapter[Message] = TypeAdapter(Message, config=ConfigDict(ser_json_inf_nan="constants"))
272
+
273
+
274
+ @dataclass
275
+ class Frame:
276
+ """一个完整的协议帧。
277
+
278
+ Attributes:
279
+ message: 帧头中的协议消息。
280
+ body: 帧体;仅当消息设置了 `raw: true` 时非空。
281
+ """
282
+
283
+ message: Message
284
+ body: bytes = b""
285
+
286
+
287
+ def split_payload(value: Any) -> tuple[dict[str, Any], bytes]:
288
+ """将载荷拆分为消息字段与帧体。
289
+
290
+ `Raw` / `bytes` / `bytearray` / `memoryview` 走帧体并设置 `raw: True`,其余值内联为 `value`。
291
+
292
+ Args:
293
+ value: 待发送的载荷。
294
+
295
+ Returns:
296
+ 二元组 `(fields, body)`:`fields` 可直接展开为消息构造参数,`body` 为帧体字节。
297
+ """
298
+ if isinstance(value, Raw):
299
+ return {"raw": True}, value.bytes
300
+ if isinstance(value, (bytes, bytearray, memoryview)):
301
+ return {"raw": True}, bytes(value)
302
+ return {"value": value}, b""
303
+
304
+
305
+ def join_payload(message: Request | Response | Event | ChannelData, body: bytes) -> Any:
306
+ """`split_payload` 的逆操作:由消息与帧体还原载荷。
307
+
308
+ Args:
309
+ message: 携带载荷的消息。
310
+ body: 帧体。
311
+
312
+ Returns:
313
+ `raw` 为真时返回 `Raw`,否则返回内联的 `value`(可能为 `None`)。
314
+
315
+ Raises:
316
+ MalformedFrame: `raw` 与内联 `value` 同时出现,或未设置 `raw` 却带有帧体。
317
+ """
318
+ if message.raw:
319
+ if message.value is not None:
320
+ raise MalformedFrame("raw payload with an inline value")
321
+ return Raw(body)
322
+ if body:
323
+ raise MalformedFrame("frame body without the raw flag")
324
+ return message.value
325
+
326
+
327
+ def _reject_constant(name: str) -> NoReturn:
328
+ raise ValueError(f"{name} is not valid JSON")
329
+
330
+
331
+ def encode(frame: Frame) -> bytes:
332
+ """将帧编码为线上字节(含 4 字节外层长度前缀)。
333
+
334
+ 值为 `None` 的字段不会写出;字段名使用别名(如 `timeoutMs`)。
335
+
336
+ Args:
337
+ frame: 待编码的帧。
338
+
339
+ Returns:
340
+ 完整的帧字节。
341
+
342
+ Raises:
343
+ ValueError: 载荷包含 `NaN` / `±Infinity`,或无法序列化为 JSON。
344
+ """
345
+ message = frame.message
346
+ header = MESSAGE.dump_json(message, by_alias=True, exclude={k for k, v in message if v is None})
347
+ if b"NaN" in header or b"Infinity" in header:
348
+ json.loads(header, parse_constant=_reject_constant)
349
+ inner_len = 4 + len(header) + len(frame.body)
350
+ return b"".join((_U32.pack(inner_len), _U32.pack(len(header)), header, frame.body))
351
+
352
+
353
+ def decode(buf: bytes) -> Frame:
354
+ """解码一帧的内部字节(即外层长度前缀之后的部分)。
355
+
356
+ Args:
357
+ buf: `[u32 BE header_len][header JSON][body]`。
358
+
359
+ Returns:
360
+ 解码后的帧。
361
+
362
+ Raises:
363
+ MalformedFrame: 长度不足、header 长度越界,或 header 无法校验为已知消息。
364
+ """
365
+ if len(buf) < 4:
366
+ raise MalformedFrame("frame shorter than 4 bytes")
367
+ (header_len,) = _U32.unpack_from(buf)
368
+ end = 4 + header_len
369
+ if end > len(buf):
370
+ raise MalformedFrame("header length exceeds frame")
371
+ try:
372
+ message = MESSAGE.validate_json(buf[4:end])
373
+ except ValidationError as e:
374
+ raise MalformedFrame(f"invalid header: {e}") from None
375
+ return Frame(message, bytes(buf[end:]))
376
+
377
+
378
+ def _read_exact(stream: BinaryIO, n: int) -> bytes | None:
379
+ """精确读取 `n` 字节;在读到任何字节前遇到 EOF 返回 `None`,读到一半遇到 EOF 抛 `ProtocolError`。"""
380
+ chunks: list[bytes] = []
381
+ remaining = n
382
+ while remaining:
383
+ chunk = stream.read(remaining)
384
+ if not chunk:
385
+ if remaining == n:
386
+ return None
387
+ raise ProtocolError("unexpected EOF inside frame")
388
+ chunks.append(chunk)
389
+ remaining -= len(chunk)
390
+ return b"".join(chunks)
391
+
392
+
393
+ def read_frame(stream: BinaryIO, max_size: int = MAX_FRAME_SIZE) -> Frame | None:
394
+ """从阻塞的二进制流中读取一帧。
395
+
396
+ Args:
397
+ stream: 输入流。
398
+ max_size: 单帧内部字节数上限。
399
+
400
+ Returns:
401
+ 解码后的帧;若流在帧边界处正常结束则返回 `None`。
402
+
403
+ Raises:
404
+ FrameTooLarge: 帧超过 `max_size`;其内容已被读出丢弃,流仍对齐。
405
+ MalformedFrame: 帧读取完整但无法解码;流仍对齐。
406
+ ProtocolError: 帧内出现 EOF,流已不可用。
407
+ """
408
+ prefix = _read_exact(stream, 4)
409
+ if prefix is None:
410
+ return None
411
+ (size,) = _U32.unpack(prefix)
412
+ if size > max_size:
413
+ # 分块丢弃而不是一次性读入,避免为超限帧分配大块内存,同时保持流对齐。
414
+ remaining = size
415
+ while remaining:
416
+ chunk = stream.read(min(remaining, 1 << 16))
417
+ if not chunk:
418
+ raise ProtocolError("unexpected EOF inside frame")
419
+ remaining -= len(chunk)
420
+ raise FrameTooLarge(size)
421
+ body = _read_exact(stream, size)
422
+ if body is None:
423
+ raise ProtocolError("unexpected EOF inside frame")
424
+ return decode(body)
425
+
426
+
427
+ class Writer:
428
+ """线程安全的帧写入器。
429
+
430
+ 事件循环与线程池中的同步处理器会并发写出,整帧写入与 flush 在同一把锁内完成,
431
+ 保证帧不会交错。
432
+
433
+ Args:
434
+ stream: 协议输出流。
435
+
436
+ Attributes:
437
+ max_size: 单帧内部字节数上限,默认 `MAX_FRAME_SIZE`。
438
+ """
439
+
440
+ def __init__(self, stream: BinaryIO) -> None:
441
+ self._stream = stream
442
+ self._lock = threading.Lock()
443
+ self.max_size = MAX_FRAME_SIZE
444
+
445
+ def write(self, message: Message, body: bytes = b"") -> None:
446
+ """编码并写出一帧,随即 flush。
447
+
448
+ Args:
449
+ message: 协议消息。
450
+ body: 帧体。
451
+
452
+ Raises:
453
+ FrameTooLarge: 编码后超过 `max_size`,不会写出任何字节。
454
+ ValueError: 载荷无法编码为合法 JSON(见 `encode`)。
455
+ """
456
+ data = encode(Frame(message, body))
457
+ if len(data) - 4 > self.max_size:
458
+ raise FrameTooLarge(len(data) - 4)
459
+ with self._lock:
460
+ self._stream.write(data)
461
+ self._stream.flush()
462
+
463
+
464
+ def take_over_stdio() -> tuple[BinaryIO, BinaryIO]:
465
+ """接管 stdio:fd 0/1 专供协议使用,其余输出改道到 stderr / devnull。
466
+
467
+ 复制原 fd 0/1 作为协议流后,fd 0 指向 devnull,fd 1 指向 stderr,`sys.stdin` 换成
468
+ devnull,`sys.stdout` 换成 `sys.stderr`。这样 `print()` 及子进程的 stdout 都不会污染
469
+ 协议流,而是进入 stderr,由宿主按行转发到日志。
470
+
471
+ Returns:
472
+ 二元组 `(proto_in, proto_out)`:协议输入流与输出流。
473
+ """
474
+ sys.stdout.flush()
475
+ proto_in = os.fdopen(os.dup(0), "rb")
476
+ proto_out = os.fdopen(os.dup(1), "wb")
477
+ null = os.open(os.devnull, os.O_RDONLY)
478
+ os.dup2(null, 0)
479
+ os.close(null)
480
+ os.dup2(2, 1)
481
+ sys.stdin = open(os.devnull, encoding="utf-8")
482
+ sys.stdout = sys.stderr
483
+ return proto_in, proto_out
tauri_runtime/py.typed ADDED
File without changes
@@ -0,0 +1,147 @@
1
+ """命令注册:对外的分组路由 `Router`,以及运行时内部的命令表 `CommandTable`。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ from .command import Handler
9
+
10
+ LIFECYCLE = ("startup", "ready", "shutdown")
11
+
12
+
13
+ def join_name(prefix: str, name: str) -> str:
14
+ """用 `.` 拼接命令名前缀与命令名;前缀为空时原样返回命令名。"""
15
+ return f"{prefix}.{name}" if prefix else name
16
+
17
+
18
+ def check_hook(hook: str) -> None:
19
+ """校验生命周期钩子名;未知钩子名抛出 `ValueError`。"""
20
+ if hook not in LIFECYCLE:
21
+ raise ValueError(f"unknown lifecycle hook '{hook}', expected one of {LIFECYCLE}")
22
+
23
+
24
+ class Router:
25
+ """命令、事件监听器与生命周期钩子的分组,用于按模块组织大型项目。
26
+
27
+ 业务模块只需创建并填充自己的 `Router`,无需导入 `App` 实例,由入口文件统一通过
28
+ `App.include()` 挂载,从而避免循环导入。`prefix` 以 `.` 拼接到命令名之前,嵌套
29
+ `include()` 时逐级拼接;事件名与钩子不受前缀影响。
30
+
31
+ 注册只做收集:处理器签名的校验与命令名重复检查在挂载到 `App` 时进行,挂载时取当时
32
+ 已注册内容的快照,之后再向该 `Router` 添加的内容不会生效。
33
+
34
+ Example:
35
+ ```python
36
+ # user/commands.py
37
+ from tauri_runtime import Router
38
+
39
+ router = Router(prefix="user")
40
+
41
+ @router.command
42
+ async def login(name: str) -> str: # 前端调用名为 "user.login"
43
+ return f"welcome {name}"
44
+
45
+ def logout() -> None: ...
46
+
47
+ router.add_command(logout)
48
+
49
+ # main.py
50
+ from tauri_runtime import App
51
+ from user.commands import router as user_router
52
+
53
+ app = App()
54
+ app.include(user_router)
55
+ app.run()
56
+ ```
57
+
58
+ Args:
59
+ prefix: 命令名前缀,如 `"user"`;空字符串表示不加前缀。不能以 `.` 开头或结尾。
60
+ """
61
+
62
+ def __init__(self, prefix: str = "") -> None:
63
+ if prefix.startswith(".") or prefix.endswith("."):
64
+ raise ValueError(f"invalid router prefix '{prefix}': must not start or end with '.'")
65
+ self.prefix = prefix
66
+ self.commands: list[tuple[str, Callable[..., Any]]] = []
67
+ self.listeners: list[tuple[str, Callable[..., Any]]] = []
68
+ self.hooks: list[tuple[str, Callable[..., Any]]] = []
69
+
70
+ # 命令式注册 ---------------------------------------------------------
71
+
72
+ def add_command(self, func: Callable[..., Any], name: str | None = None) -> None:
73
+ """注册命令;`name` 省略时使用函数名,最终命令名会加上本 `Router` 的前缀。"""
74
+ self.commands.append((join_name(self.prefix, name or func.__name__), func))
75
+
76
+ def add_commands(self, *funcs: Callable[..., Any]) -> None:
77
+ """批量注册命令,命令名均取函数名。"""
78
+ for func in funcs:
79
+ self.add_command(func)
80
+
81
+ def add_listener(self, event: str, func: Callable[..., Any]) -> None:
82
+ """注册事件监听器;事件名不加前缀。"""
83
+ self.listeners.append((event, func))
84
+
85
+ def add_hook(self, hook: str, func: Callable[..., Any]) -> None:
86
+ """注册生命周期钩子(`"startup"` / `"ready"` / `"shutdown"`)。"""
87
+ check_hook(hook)
88
+ self.hooks.append((hook, func))
89
+
90
+ def include(self, router: Router) -> None:
91
+ """把另一个 `Router` 的内容并入本 `Router`,其命令名再加上本 `Router` 的前缀。"""
92
+ self.commands.extend((join_name(self.prefix, name), func) for name, func in router.commands)
93
+ self.listeners.extend(router.listeners)
94
+ self.hooks.extend(router.hooks)
95
+
96
+ # 装饰器 -------------------------------------------------------------
97
+
98
+ def command(self, name_or_func: Any = None) -> Any:
99
+ """注册命令的装饰器,用法同 `App.command`:`@router.command` 或 `@router.command("name")`。"""
100
+ if callable(name_or_func):
101
+ self.add_command(name_or_func)
102
+ return name_or_func
103
+
104
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
105
+ self.add_command(func, name_or_func)
106
+ return func
107
+
108
+ return decorator
109
+
110
+ def listen(self, event: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
111
+ """注册事件监听器的装饰器,用法同 `App.listen`。"""
112
+
113
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
114
+ self.add_listener(event, func)
115
+ return func
116
+
117
+ return decorator
118
+
119
+ def on(self, hook: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
120
+ """注册生命周期钩子的装饰器,用法同 `App.on`。"""
121
+ check_hook(hook)
122
+
123
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
124
+ self.add_hook(hook, func)
125
+ return func
126
+
127
+ return decorator
128
+
129
+
130
+ class CommandTable:
131
+ """运行时内部的命令名到 `Handler` 的映射;命令名不可重复注册。"""
132
+
133
+ def __init__(self) -> None:
134
+ self._handlers: dict[str, Handler] = {}
135
+
136
+ def add(self, handler: Handler) -> None:
137
+ """注册处理器;命令名已存在时抛出 `ValueError`。"""
138
+ if handler.name in self._handlers:
139
+ raise ValueError(f"command '{handler.name}' already registered")
140
+ self._handlers[handler.name] = handler
141
+
142
+ def get(self, name: str) -> Handler | None:
143
+ return self._handlers.get(name)
144
+
145
+ def names(self) -> list[str]:
146
+ """已注册的命令名(排序后),用于 `Hello` 握手。"""
147
+ return sorted(self._handlers)