Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@

### Fixed

- Windows 设备检测改为从本地化 `netstat` 原始字节解析监听端口,HDC 输出固定按
UTF-8 解码,避免 `PYTHONUTF8=1` 或系统 GBK 文本模式导致设备检测及安装后校验失败。
- Agent CLI 会拒绝空白 HDC serial,避免退回隐式设备选择;`auth` 仅在 Token
缓存成功落盘后返回成功;`devices list` 不再把退出码为 0 的 HDC `[Fail]`
输出误报为空设备列表。
Expand Down
25 changes: 19 additions & 6 deletions hapsign/signing/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

_HDC_SERVER_HOST = "127.0.0.1"
_HDC_SERVER_PORT = 8710
_HDC_OUTPUT_ENCODING = "utf-8"
_HDC_OUTPUT_ERRORS = "replace"

# hdc start 调用时刻与监听进程创建时刻比较时允许的时钟偏差(秒)
_CLOCK_SKEW_SECONDS = 2.0
Expand Down Expand Up @@ -47,20 +49,19 @@ def _listener_pid() -> int | None:


def _listener_pid_windows() -> int | None:
"""解析 netstat 输出,取本地监听 8710 的 PID。"""
"""从 netstat 原始字节中解析本地监听 8710 的 PID。"""
try:
result = subprocess.run(
["netstat", "-ano", "-p", "TCP"],
capture_output=True,
text=True,
timeout=10,
**no_window_kwargs(),
)
except (OSError, subprocess.TimeoutExpired):
return None
# 监听套接字的远端地址恒为 0.0.0.0:0,状态列文本在不同语言环境可能不同
pattern = re.compile(r"TCP\s+127\.0\.0\.1:8710\s+0\.0\.0\.0:0\s+\S+\s+(\d+)\s*$")
for line in result.stdout.splitlines():
pattern = re.compile(rb"TCP\s+127\.0\.0\.1:8710\s+0\.0\.0\.0:0\s+\S+\s+(\d+)\s*$")
for line in (result.stdout or b"").splitlines():
match = pattern.search(line)
if match:
return int(match.group(1))
Expand Down Expand Up @@ -282,6 +283,8 @@ def _ensure_server(self) -> None:
[self._hdc, "start"],
capture_output=True,
text=True,
encoding=_HDC_OUTPUT_ENCODING,
errors=_HDC_OUTPUT_ERRORS,
timeout=10,
**no_window_kwargs(),
)
Expand Down Expand Up @@ -346,6 +349,8 @@ def close(self) -> None:
[self._hdc, "kill"],
capture_output=True,
text=True,
encoding=_HDC_OUTPUT_ENCODING,
errors=_HDC_OUTPUT_ERRORS,
timeout=5,
**no_window_kwargs(),
)
Expand Down Expand Up @@ -393,14 +398,16 @@ def get_udid(self) -> str:
cmd,
capture_output=True,
text=True,
encoding=_HDC_OUTPUT_ENCODING,
errors=_HDC_OUTPUT_ERRORS,
timeout=15,
cancel_event=self.cancel_event,
)
except subprocess.TimeoutExpired:
continue
if result.returncode == 0:
# 从输出中提取 64 位十六进制 UDID
match = re.search(r"\b([0-9A-Fa-f]{64})\b", result.stdout)
match = re.search(r"\b([0-9A-Fa-f]{64})\b", result.stdout or "")
if match:
return match.group(1)
raise RuntimeError(
Expand All @@ -414,6 +421,8 @@ def list_targets(self, connected_only: bool = False) -> list[dict[str, object]]:
[self._hdc, "list", "targets", "-v"],
capture_output=True,
text=True,
encoding=_HDC_OUTPUT_ENCODING,
errors=_HDC_OUTPUT_ERRORS,
timeout=15,
cancel_event=self.cancel_event,
)
Expand All @@ -425,7 +434,7 @@ def list_targets(self, connected_only: bool = False) -> list[dict[str, object]]:
raise RuntimeError(f"hdc list targets 失败: {output}")

targets: list[dict[str, object]] = []
for raw_line in result.stdout.splitlines():
for raw_line in (result.stdout or "").splitlines():
parts = raw_line.strip().split()
if not parts or parts[0].startswith("["):
continue
Expand Down Expand Up @@ -460,6 +469,8 @@ def inspect_bundle(self, bundle_name: str) -> dict[str, str] | None:
self._device_command("shell", "bm", "dump", "-n", bundle_name),
capture_output=True,
text=True,
encoding=_HDC_OUTPUT_ENCODING,
errors=_HDC_OUTPUT_ERRORS,
timeout=20,
cancel_event=self.cancel_event,
)
Expand Down Expand Up @@ -500,6 +511,8 @@ def install(self, hap_path: str) -> bool:
cmd,
capture_output=True,
text=True,
encoding=_HDC_OUTPUT_ENCODING,
errors=_HDC_OUTPUT_ERRORS,
timeout=60,
cancel_event=self.cancel_event,
)
Expand Down
84 changes: 75 additions & 9 deletions hapsign/subprocess_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,52 @@
_CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)


class ProcessOutputError(RuntimeError):
"""外部进程的已捕获输出无法按调用方声明的格式提供。"""


def _output_encoding(text: bool, options: dict[str, Any]) -> str:
encoding = options.get("encoding")
if encoding is not None:
return str(encoding)
if text or options.get("errors") is not None:
return "Python default"
return "bytes"


def _process_output_error(
command: list[str],
text: bool,
options: dict[str, Any],
detail: str,
) -> ProcessOutputError:
# 只包含可执行文件名,避免把密码等敏感命令参数带入错误信息。
executable = command[0] if command else "<unknown>"
encoding = _output_encoding(text, options)
return ProcessOutputError(
f"外部命令 {executable!r} 的输出不可用 (encoding={encoding}): {detail}"
)


def _validate_captured_output(
result: subprocess.CompletedProcess,
command: list[str],
*,
capture_output: bool,
text: bool,
options: dict[str, Any],
) -> subprocess.CompletedProcess:
"""捕获开启时保证 stdout/stderr 存在,否则给出明确的边界错误。"""
if capture_output and (result.stdout is None or result.stderr is None):
raise _process_output_error(
command,
text,
options,
"capture_output=True 但 stdout/stderr 为 None",
)
return result


def no_window_kwargs() -> dict[str, int]:
"""Windows 下禁止控制台工具创建一闪而过的命令行窗口。"""
if platform.system() == "Windows":
Expand Down Expand Up @@ -150,8 +196,8 @@ def _terminate_windows_tree(pid: int) -> None:
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(pid)],
capture_output=True,
text=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**no_window_kwargs(),
)
except (OSError, ValueError):
Expand Down Expand Up @@ -201,16 +247,27 @@ def run_process(
"""运行外部命令;有取消信号或超时要求时,终止子进程及整棵进程树。

仅当既无取消信号也无超时时走 subprocess.run 快路径;否则使用 Popen +
Job Object / 进程组,确保取消或超时能终止整棵进程树。
Job Object / 进程组,确保取消或超时能终止整棵进程树。调用方要求捕获输出时,
stdout/stderr 必须保持稳定;解码失败或异常的 None 输出会转换为明确异常。
"""
options = no_window_kwargs()
options.update(kwargs)
if cancel_event is None and timeout is None:
return subprocess.run(
try:
result = subprocess.run(
command,
capture_output=capture_output,
text=text,
**options,
)
except UnicodeError as exc:
raise _process_output_error(command, text, options, str(exc)) from exc
return _validate_captured_output(
result,
command,
capture_output=capture_output,
text=text,
**options,
options=options,
)

raise_if_cancelled(cancel_event)
Expand Down Expand Up @@ -248,11 +305,20 @@ def run_process(
stdout, stderr = process.communicate(timeout=wait_timeout)
except subprocess.TimeoutExpired:
continue
return subprocess.CompletedProcess(
except UnicodeError as exc:
_stop_process(process, job)
raise _process_output_error(command, text, options, str(exc)) from exc
return _validate_captured_output(
subprocess.CompletedProcess(
command,
process.returncode,
stdout,
stderr,
),
command,
process.returncode,
stdout,
stderr,
capture_output=capture_output,
text=text,
options=options,
)
finally:
if job is not None:
Expand Down
36 changes: 26 additions & 10 deletions tests/test_signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
from hapsign.signing import hap_signer, installer, keytool_util


def _assert_hdc_utf8_call(call) -> None:
assert call.kwargs["text"] is True
assert call.kwargs["encoding"] == "utf-8"
assert call.kwargs["errors"] == "replace"


def test_hap_signer_builds_subprocess_command(monkeypatch) -> None:
run = Mock(return_value=SimpleNamespace(returncode=0, stdout="", stderr=""))
monkeypatch.setattr(hap_signer, "run_process", run)
Expand Down Expand Up @@ -60,6 +66,7 @@ def test_installer_extracts_udid(monkeypatch) -> None:
monkeypatch.setattr(installer.Installer, "_ensure_server", lambda self: None)

assert installer.Installer().get_udid() == udid
_assert_hdc_utf8_call(run.call_args)


def test_installer_targets_explicit_serial(monkeypatch) -> None:
Expand All @@ -74,6 +81,7 @@ def test_installer_targets_explicit_serial(monkeypatch) -> None:
"-t",
"device-serial",
]
_assert_hdc_utf8_call(run.call_args)


def test_installer_does_not_treat_explicit_empty_serial_as_implicit() -> None:
Expand Down Expand Up @@ -116,6 +124,7 @@ def test_installer_lists_agent_friendly_targets(monkeypatch) -> None:
"targets",
"-v",
]
_assert_hdc_utf8_call(run.call_args)


def test_installer_rejects_hdc_failure_marker_with_zero_exit(monkeypatch) -> None:
Expand All @@ -136,7 +145,7 @@ def test_installer_inspects_bundle_on_explicit_serial(monkeypatch) -> None:
returncode=0,
stdout=(
'{"bundleName":"com.example.app","appProvisionType":"debug",'
'"versionName":"1.2.3"}'
'"versionName":"测试版 1.2.3"}'
),
stderr="",
)
Expand All @@ -151,7 +160,7 @@ def test_installer_inspects_bundle_on_explicit_serial(monkeypatch) -> None:
assert bundle == {
"bundle_name": "com.example.app",
"provision_type": "debug",
"version_name": "1.2.3",
"version_name": "测试版 1.2.3",
}
assert run.call_args.args[0] == [
installer.config.HDC_PATH,
Expand All @@ -163,6 +172,7 @@ def test_installer_inspects_bundle_on_explicit_serial(monkeypatch) -> None:
"-n",
"com.example.app",
]
_assert_hdc_utf8_call(run.call_args)


def test_installer_raises_on_fail_marker(monkeypatch) -> None:
Expand Down Expand Up @@ -250,6 +260,7 @@ def test_installer_uses_serial_and_replace(monkeypatch) -> None:
"-r",
"app.hap",
]
_assert_hdc_utf8_call(run.call_args)


def test_installer_accepts_success_with_error_in_path(monkeypatch) -> None:
Expand Down Expand Up @@ -282,6 +293,8 @@ def test_installer_closes_server_started_by_current_task(monkeypatch) -> None:

commands = [call.args[0] for call in run.call_args_list]
assert commands == [[hdc._hdc, "start"], [hdc._hdc, "kill"]]
for call in run.call_args_list:
_assert_hdc_utf8_call(call)


def test_installer_preserves_preexisting_server(monkeypatch) -> None:
Expand Down Expand Up @@ -399,29 +412,32 @@ def test_installer_polls_until_listener_appears(monkeypatch) -> None:


def test_listener_pid_parses_netstat_output(monkeypatch) -> None:
# 本地化表头包含非法 UTF-8 字节;监听行本身只依赖 ASCII 字段。
output = (
" TCP 127.0.0.1:8710 0.0.0.0:0 LISTENING 47024\n"
" TCP 127.0.0.1:8710 127.0.0.1:65140 TIME_WAIT 0\n"
" TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234\n"
b"\xbb\xee\xb6\xaf\xd0\xad\xd2\xe9 \xb1\xbe\xb5\xd8\xb5\xd8\xd6\xb7\r\n"
b" TCP 127.0.0.1:8710 0.0.0.0:0 LISTENING 47024\r\n"
b" TCP 127.0.0.1:8710 127.0.0.1:65140 TIME_WAIT 0\r\n"
b" TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234\r\n"
)
monkeypatch.setattr(installer.os, "name", "nt")
run = Mock(return_value=SimpleNamespace(returncode=0, stdout=output, stderr=b""))
monkeypatch.setattr(
installer.subprocess,
"run",
Mock(return_value=SimpleNamespace(returncode=0, stdout=output, stderr="")),
run,
)
assert installer._listener_pid() == 47024
assert "text" not in run.call_args.kwargs
assert "encoding" not in run.call_args.kwargs


def test_listener_pid_returns_none_when_port_free(monkeypatch) -> None:
output = (
" TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234\n"
)
output = b" TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 1234\r\n"
monkeypatch.setattr(installer.os, "name", "nt")
monkeypatch.setattr(
installer.subprocess,
"run",
Mock(return_value=SimpleNamespace(returncode=0, stdout=output, stderr="")),
Mock(return_value=SimpleNamespace(returncode=0, stdout=output, stderr=b"")),
)
assert installer._listener_pid() is None

Expand Down
Loading
Loading