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
8 changes: 7 additions & 1 deletion workbench/network/campus-net/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ if (-not (Test-Path -LiteralPath config.json)) {
- `username`、`password`:校园网账号和密码。程序直接读取本地配置,不会再次询问密码或读取密码环境变量。
- `carrier`:运营商显示名。程序会从本次会话的服务列表中找到“中国电信”对应的动态值再提交。

程序只使用 `interface_index` 指定的 IPv4 接口,不按接口名称或类型猜测校园网,也不会在失败时改用其他网络接口。校园网可以位于 WLAN、以太网或其他 IPv4 接口;若所选接口未连接、无可用路由或返回其他 captive 门户,CLI 与 GUI 会停止并提示重新选择实际承载校园网的接口,不会提交账号密码。所选接口已经可以访问互联网时,程序会跳过登录,并提醒校园网位于其他接口时需要改选接口。
程序始终先使用 `interface_index` 指定的 IPv4 接口,不按接口名称或类型猜测校园网,也不会自动改用其他网络接口。校园网可以位于 WLAN、以太网或其他 IPv4 接口;若交互式 CLI 无法确认所选接口的网络状态,会列出当前 Windows IPv4 接口,由用户显式选择一个接口后重新探测。该选择仅用于本次运行,不修改 `config.json`;重定向输入输出、后台任务和 GUI 不会等待终端输入,仍会安全停止。GUI 可在界面中重新选择接口再连接。任何改选都发生在提交账号密码之前。所选接口已经可以访问互联网时,程序会跳过登录,并提醒校园网位于其他接口时需要改选接口。

以下协议参数不再属于用户配置,由代码统一维护:User-Agent、连通性探测指纹、门户入口路径、验证码模式和次数、登录后确认间隔与超时。这样升级门户适配时只改实现,不要求用户同步一组内部常量。

Expand Down Expand Up @@ -136,6 +136,12 @@ uv sync --group dev
& .\.venv\Scripts\python.exe -m PyInstaller --clean --noconfirm gui.spec
```

控制台版构建后可运行不读取配置、不访问网络的入口冒烟检查:

```powershell
& .\dist\Auto-Connect-CampusNet.exe --help
```

子项目在 `pyproject.toml` 中声明自己的 uv workspace 边界,因此 `uv sync` 不会继续向上扫描仓库根仅用于 Ruff 的 `pyproject.toml`。同步后直接使用该环境的 Python,也不会让后续每条测试或构建命令重新触发项目发现,从而避免与项目构建无关的“缺少 `[project]`”警告。

`build.spec` 继续生成控制台版 `dist\Auto-Connect-CampusNet.exe`,`gui.spec` 生成无控制台窗口版 `dist\Auto-Connect-CampusNet-GUI.exe`。两个 spec 都不会把 `config.json` 打进 EXE,发布物也不应携带用户配置或备份。
Expand Down
4 changes: 3 additions & 1 deletion workbench/network/campus-net/campus_net/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
)
from .interactive import CaptchaPromptError, prompt_captcha
from .legacy import run_legacy
from .runner import run_captive_http
from .runner import InterfaceSelector, run_captive_http
from .sso import SsoProtocolError

StatusReporter = Callable[[str], None]
Expand All @@ -40,6 +40,7 @@ async def execute_config(
probe_only: bool = False,
captcha_provider: CaptchaProvider = prompt_captcha,
status_callback: StatusReporter = print,
interface_selector: InterfaceSelector | None = None,
) -> int:
adapter = get_adapter(cfg)
if adapter == ADAPTER_CAPTIVE_SSO_HTTP:
Expand All @@ -49,6 +50,7 @@ async def execute_config(
probe_only=probe_only,
captcha_provider=captcha_provider,
status_callback=status_callback,
interface_selector=interface_selector,
)
if adapter == ADAPTER_LEGACY_EPORTAL:
if probe_only:
Expand Down
3 changes: 1 addition & 2 deletions workbench/network/campus-net/campus_net/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,11 @@
FailedEvent,
FinishedEvent,
LogEvent,
NetworkInterface,
OperationController,
build_config_from_form,
form_values_from_config,
list_windows_ipv4_interfaces,
)
from .interfaces import NetworkInterface, list_windows_ipv4_interfaces
from .window_state import (
ScreenRect,
WindowState,
Expand Down
114 changes: 0 additions & 114 deletions workbench/network/campus-net/campus_net/gui_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@

import asyncio
import copy
import json
import os
import subprocess
import sys
import threading
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from pathlib import Path
from queue import Empty, Queue
from typing import Any

Expand Down Expand Up @@ -59,23 +54,6 @@ class FailedEvent:
GuiEvent = LogEvent | CaptchaRequested | TerminalEvent


@dataclass(frozen=True, slots=True)
class NetworkInterface:
index: int
alias: str
state: str
metric: int

@property
def display_name(self) -> str:
state = {
"connected": "已连接",
"disconnected": "未连接",
"authenticating": "正在认证",
}.get(self.state.casefold(), self.state or "未知状态")
return f"{self.index} · {self.alias} · {state}"


@dataclass(slots=True)
class _PendingCaptcha:
loop: asyncio.AbstractEventLoop
Expand Down Expand Up @@ -352,98 +330,6 @@ def form_values_from_config(config: EditableConfig) -> tuple[int, dict[str, str]
raise TypeError("GUI 只接受正式 version=1 或 version=2 配置")


def list_windows_ipv4_interfaces() -> list[NetworkInterface]:
if sys.platform != "win32":
raise OSError("网卡列表当前仅支持 Windows")
system_root = os.environ.get("SystemRoot")
if not system_root:
raise OSError("Windows 环境缺少 SystemRoot")
powershell_path = (
Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
)
if not powershell_path.is_file():
raise OSError(f"找不到系统 PowerShell:{powershell_path}")
command = (
"[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); "
"Get-NetIPInterface -AddressFamily IPv4 | "
"Select-Object InterfaceIndex,InterfaceAlias,"
"@{Name='ConnectionState';Expression={$_.ConnectionState.ToString()}},"
"InterfaceMetric | "
"ConvertTo-Json -Compress"
)
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
result = subprocess.run(
[str(powershell_path), "-NoProfile", "-NonInteractive", "-Command", command],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=10,
creationflags=creation_flags,
)
return parse_windows_ipv4_interfaces(result.stdout)


def parse_windows_ipv4_interfaces(payload: str) -> list[NetworkInterface]:
try:
decoded = json.loads(payload)
except json.JSONDecodeError as error:
raise ValueError("无法解析 Windows IPv4 接口列表") from error
if isinstance(decoded, dict):
items = [decoded]
elif isinstance(decoded, list):
items = decoded
else:
raise ValueError("Windows IPv4 接口列表格式无效")

interfaces: list[NetworkInterface] = []
for item in items:
if not isinstance(item, dict):
continue
index = item.get("InterfaceIndex")
alias = item.get("InterfaceAlias")
state = _normalize_connection_state(item.get("ConnectionState"))
metric = item.get("InterfaceMetric")
if (
isinstance(index, bool)
or not isinstance(index, int)
or not isinstance(alias, str)
or not alias.strip()
):
continue
interfaces.append(
NetworkInterface(
index=index,
alias=alias.strip(),
state=state,
metric=metric if isinstance(metric, int) and not isinstance(metric, bool) else 0,
)
)
if not interfaces:
raise ValueError("没有找到可用的 Windows IPv4 接口")
return sorted(
interfaces,
key=lambda item: (
item.state.casefold() != "connected",
item.metric,
item.index,
),
)


def _normalize_connection_state(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, int) and not isinstance(value, bool):
return {
0: "Disconnected",
1: "Connected",
2: "Disconnected",
3: "Authenticating",
}.get(value, f"状态 {value}")
return ""


def _set_future_result(future: asyncio.Future[str], value: str) -> None:
if not future.done():
future.set_result(value)
Expand Down
117 changes: 117 additions & 0 deletions workbench/network/campus-net/campus_net/interfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
from __future__ import annotations

import json
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True, slots=True)
class NetworkInterface:
index: int
alias: str
state: str
metric: int

@property
def display_name(self) -> str:
state = {
"connected": "已连接",
"disconnected": "未连接",
"authenticating": "正在认证",
}.get(self.state.casefold(), self.state or "未知状态")
return f"{self.index} · {self.alias} · {state}"


def list_windows_ipv4_interfaces() -> list[NetworkInterface]:
if sys.platform != "win32":
raise OSError("网卡列表当前仅支持 Windows")
system_root = os.environ.get("SystemRoot")
if not system_root:
raise OSError("Windows 环境缺少 SystemRoot")
powershell_path = (
Path(system_root) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
)
if not powershell_path.is_file():
raise OSError(f"找不到系统 PowerShell:{powershell_path}")
command = (
"[Console]::OutputEncoding = [Text.UTF8Encoding]::new(); "
"Get-NetIPInterface -AddressFamily IPv4 | "
"Select-Object InterfaceIndex,InterfaceAlias,"
"@{Name='ConnectionState';Expression={$_.ConnectionState.ToString()}},"
"InterfaceMetric | "
"ConvertTo-Json -Compress"
)
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
result = subprocess.run(
[str(powershell_path), "-NoProfile", "-NonInteractive", "-Command", command],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=10,
creationflags=creation_flags,
)
return parse_windows_ipv4_interfaces(result.stdout)


def parse_windows_ipv4_interfaces(payload: str) -> list[NetworkInterface]:
try:
decoded = json.loads(payload)
except json.JSONDecodeError as error:
raise ValueError("无法解析 Windows IPv4 接口列表") from error
if isinstance(decoded, dict):
items = [decoded]
elif isinstance(decoded, list):
items = decoded
else:
raise ValueError("Windows IPv4 接口列表格式无效")

interfaces: list[NetworkInterface] = []
for item in items:
if not isinstance(item, dict):
continue
index = item.get("InterfaceIndex")
alias = item.get("InterfaceAlias")
state = _normalize_connection_state(item.get("ConnectionState"))
metric = item.get("InterfaceMetric")
if (
isinstance(index, bool)
or not isinstance(index, int)
or not isinstance(alias, str)
or not alias.strip()
):
continue
interfaces.append(
NetworkInterface(
index=index,
alias=alias.strip(),
state=state,
metric=metric if isinstance(metric, int) and not isinstance(metric, bool) else 0,
)
)
if not interfaces:
raise ValueError("没有找到可用的 Windows IPv4 接口")
return sorted(
interfaces,
key=lambda item: (
item.state.casefold() != "connected",
item.metric,
item.index,
),
)


def _normalize_connection_state(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, int) and not isinstance(value, bool):
return {
0: "Disconnected",
1: "Connected",
2: "Disconnected",
3: "Authenticating",
}.get(value, f"状态 {value}")
return ""
Loading