清理:移除 RDP 遗留资料并纳管产品文档与运维工具
变更详情:清理 RDP 遗留文档与临时文件,纳管产品文档与运维工具脚本
This commit is contained in:
parent
26fde0369e
commit
7fdcb53dd9
BIN
docs/VDI云桌面完整方案-云管端.docx
Normal file
BIN
docs/VDI云桌面完整方案-云管端.docx
Normal file
Binary file not shown.
BIN
docs/VDI云桌面软件开发方案(1).docx
Normal file
BIN
docs/VDI云桌面软件开发方案(1).docx
Normal file
Binary file not shown.
BIN
docs/VDI云终端产品方案-定版摘要(1).docx
Normal file
BIN
docs/VDI云终端产品方案-定版摘要(1).docx
Normal file
Binary file not shown.
@ -1,33 +0,0 @@
|
||||
# FreeRDP 3 客户端 API 参考(Ubuntu 24.04 / freerdp3-dev)
|
||||
|
||||
> 用途:kiosk-shell 内嵌 FreeRDP 客户端时使用,避免拉起外部进程(开发方案 3.2 节)。
|
||||
> 记录时间:2026-10-23
|
||||
|
||||
## 包名
|
||||
- 运行时:`libfreerdp-client3-3`、`libfreerdp3-3`
|
||||
- 开发:`freerdp3-dev`(Ubuntu 24.04 提供 `freerdp3-dev` 与 `libfreerdp-client3-3-dev`)
|
||||
- pkg-config 名称:`freerdp3`、`freerdp3-client`(安装后以 `pkg-config --list-all | grep freerdp` 实测为准)
|
||||
|
||||
## 关键头文件
|
||||
| 头文件 | 用途 |
|
||||
|---|---|
|
||||
| `freerdp/client.h` | 客户端主入口、freerdp_client_context_new / freerdp_client_context_free |
|
||||
| `freerdp/freerdp.h` | 核心上下文 |
|
||||
| `freerdp/settings.h` | 连接参数设置(freerdp_settings_new / set) |
|
||||
| `freerdp/channels/cliprdr.h` | 剪贴板通道 |
|
||||
| `freerdp/gdi/gfx.h` | GFX 图形管线(AVC420/AVC444/RFX 能力) |
|
||||
| `freerdp/crypto/tls.h` | TLS |
|
||||
|
||||
## 最小连接流程(伪代码)
|
||||
```c
|
||||
freerdp_client_context* ctx = freerdp_client_context_new();
|
||||
RdpClientContext* cctx = ...;
|
||||
freerdp_settings_new(0); // 释放: freerdp_settings_free
|
||||
freerdp_set_pointer / freerdp_set_keyboard ... // 输入回调
|
||||
ctx->Connect = ...; // 事件表
|
||||
freerdp_client_context_new / connect
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
- `/gfx` 命令行语法在 3.32.1 上必须用 `|` 分隔的能力组合(如 `progressive|RFX|AVC420|AVC444`),
|
||||
单独 `AVC420` 会解析失败。API 侧对应 `gfx` 能力位设置。
|
||||
@ -1,67 +0,0 @@
|
||||
# FreeRDP 3 内嵌客户端实现笔记(Ubuntu 24.04 / freerdp3-dev 3.32.1)
|
||||
|
||||
> 对应《VDI 云桌面软件开发方案》3.2 / 3.3 节:以 libfreerdp3 API 内嵌(非拉起外部进程)。
|
||||
> 记录时间:2026-10-23,实测于 RK3528 盒子(Armbian / Ubuntu Noble / aarch64)
|
||||
|
||||
## 1. API 结构(与 FreeRDP 2.x 差异很大)
|
||||
|
||||
| 用途 | FreeRDP 3 写法 |
|
||||
|---|---|
|
||||
| 创建实例 | `freerdp* instance = freerdp_new();` |
|
||||
| 建立上下文 | `freerdp_context_new_ex(instance, settings)` |
|
||||
| 上下文 | `instance->context`(`rdpContext*`,公开结构体) |
|
||||
| 事件回调 | `instance->PostConnect` / `instance->PostDisconnect` / `instance->AuthenticateEx` |
|
||||
| 更新回调 | `instance->context->update->BitmapUpdate` / `->EndPaint` |
|
||||
| GDI | `gdi_init(instance, PIXEL_FORMAT_BGRX32)` |
|
||||
| 取帧 | `context->gdi->primary_buffer` + `context->gdi->stride` |
|
||||
|
||||
设置项键名(与 2.x 不同):
|
||||
`FreeRDP_ServerHostname` / `FreeRDP_Username` / `FreeRDP_Password` / `FreeRDP_Domain` /
|
||||
`FreeRDP_DesktopWidth` / `FreeRDP_DesktopHeight` / `FreeRDP_ColorDepth` / `FreeRDP_Fullscreen` /
|
||||
`FreeRDP_IgnoreCertificate` / `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` …
|
||||
|
||||
## 2. 三个必须踩过才知道的坑
|
||||
|
||||
### 坑一:gdi_init 必须在 PostConnect 里调用
|
||||
在 `freerdp_context_new_ex()` 之后立刻调用 `gdi_init()` 会 **SIGSEGV**
|
||||
(`gdi_init_ex` 段错误)。官方 x11 客户端(`client/X11/xf_client.c` 的 `xf_post_connect`)
|
||||
把 `gdi_init` 放在 PostConnect 回调内,因为 GDI 依赖连接协商后的桌面尺寸与颜色格式。
|
||||
|
||||
### 坑二:不能用 instance->ContextSize 扩展 rdpContext
|
||||
常见做法是把自定义指针塞进扩展的 rdpContext,但 `gdi_init_ex()` 会按 `ContextSize`
|
||||
重新分配 context 缓冲区,扩展字段随即失效 → 段错误。
|
||||
改用静态注册表按 `freerdp*` 记录会话对象;GFX 通道回调则用官方的
|
||||
`RdpgfxClientContext.custom` 字段。
|
||||
|
||||
### 坑三:NLA 认证必须传空字符串而非 nullptr
|
||||
Windows 端启用 NLA(CredSSP)时,认证回调里把密码置为 `nullptr` 会导致
|
||||
`SEC_E_NO_CREDENTIALS [0x8009030e]`。必须传 `strdup("")`。
|
||||
认证回调签名:
|
||||
`BOOL (*pAuthenticateEx)(freerdp*, char** username, char** password, char** domain, rdp_auth_reason)`
|
||||
|
||||
## 3. /gfx 语法(命令行与设置项的对应)
|
||||
FreeRDP 3.32.1 的 `/gfx` **不接受单个管道名**,必须是 `|` 分隔的能力组合:
|
||||
|
||||
```
|
||||
/gfx:progressive|RFX|AVC420|AVC444 ← 可解析
|
||||
/gfx:AVC420 ← parsing failed [-1000]
|
||||
/gfx:AVC444 ← parsing failed [-1000]
|
||||
```
|
||||
|
||||
API 侧对应 `FreeRDP_GfxProgressive` / `FreeRDP_GfxH264` / `FreeRDP_GfxAVC444` 等布尔项。
|
||||
|
||||
## 4. 像素格式
|
||||
`PIXEL_FORMAT_BGRX32` 的内存布局是 **B,G,R,X** 四字节/像素。
|
||||
QImage 没有对应格式,输出前需手动做 BGR→RGB 转换。
|
||||
注意 `PIXEL_FORMAT_*` 是计算宏 `FREERDP_PIXEL_FORMAT(...)`,**不能硬编码数值**。
|
||||
|
||||
## 5. 后续演进(对应 freerdp-rk 分支)
|
||||
当前实现是 CPU 侧 GDI 缓冲 → Qt 绘制。开发方案 3.3 节要求改为:
|
||||
|
||||
```text
|
||||
FreeRDP3 解复用(gfx 通道) → GStreamer h264parse → mppvideodec(VPU硬解, zero-copy DMABuf)
|
||||
→ waylandsink(DRM plane)
|
||||
```
|
||||
|
||||
盒子已具备 `/dev/mpp_service`(VPU)与 `/dev/rga`,但缺 `mppvideodec` GStreamer 插件
|
||||
(Ubuntu 官方源没有,需 Rockchip BSP / Armbian 额外源或自研)。
|
||||
39
docs/idd-install-helper.bat
Normal file
39
docs/idd-install-helper.bat
Normal file
@ -0,0 +1,39 @@
|
||||
@echo off
|
||||
REM ============================================================
|
||||
REM MttVDD 虚拟显示驱动 - 一键安装(管理员运行)
|
||||
REM 用法:右键本文件 -> 以管理员身份运行
|
||||
REM ============================================================
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo [1/4] 关闭可能占用驱动的程序...
|
||||
sc stop SunshineService >nul 2>&1
|
||||
taskkill /f /im sunshine.exe >nul 2>&1
|
||||
|
||||
echo [2/4] 安装驱动签名证书...
|
||||
certutil -addstore -f "Root" "Virtual_Display_Driver.cer"
|
||||
if errorlevel 1 (
|
||||
echo [错误] 证书安装失败,请确认以管理员身份运行
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [3/4] 安装 MttVDD 驱动...
|
||||
pnputil /add-driver "MttVDD.inf" /install
|
||||
if errorlevel 1 (
|
||||
echo [警告] 驱动安装返回错误,尝试另一种方式...
|
||||
rundll32.exe setupapi.dll,InstallHinfSection DefaultInstall 132 .\MttVDD.inf
|
||||
)
|
||||
|
||||
echo [4/4] 显示当前虚拟显示器状态...
|
||||
pnputil /enum-devices /class Display
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo 安装完成。请重启 Windows 后:
|
||||
echo 1) 设置 - 显示 - 找到 MttVDD 虚拟显示器
|
||||
echo 2) 分辨率选 1920x1080,刷新率选 60Hz
|
||||
echo 3) 重启 SunshineService 服务
|
||||
echo 重启方式:开始菜单搜索 services.msc -> SunshineService -> 重新启动
|
||||
echo ============================================================
|
||||
pause
|
||||
82
docs/rdp-vs-moonlight-comparison-20261028.md
Normal file
82
docs/rdp-vs-moonlight-comparison-20261028.md
Normal file
@ -0,0 +1,82 @@
|
||||
# RDP 与 Moonlight 实测对比(2026-10-28)
|
||||
|
||||
## 测试环境
|
||||
|
||||
```text
|
||||
终端:RK3528 PRO DDR4,Armbian 25.05,aarch64,LightDM + Xorg
|
||||
显示器:1920×1440 @ 75Hz
|
||||
云桌面:VM 100(win10-test),Proxmox VE 9.2.21,节点 starhe
|
||||
终端到云桌面:192.168.1.188 -> 192.168.1.70,同一局域网 WiFi
|
||||
```
|
||||
|
||||
服务端显示环境改造(本次关键前提):
|
||||
|
||||
```text
|
||||
改造前:Microsoft Basic Display Adapter,1920×1080 @ 1Hz
|
||||
改造后:Virtual Display Driver 8.35.11.365,1920×1080 @ 60Hz
|
||||
```
|
||||
|
||||
改造前 Sunshine 抓不到稳定帧(1Hz),改造后可正常编码。
|
||||
|
||||
## 对比数据
|
||||
|
||||
| 指标 | RDP(当前 kiosk-shell) | Moonlight 1080p30 | Moonlight 1080p60 |
|
||||
|---|---|---|---|
|
||||
| 协议/编码 | RDP GFX,CPU 软解 | H.264 / libx264 软编 | H.264 / libx264 软编 |
|
||||
| 分辨率 | 1920×1440 | 1920×1080 | 1920×1080 |
|
||||
| 终端 CPU | 约 111%(单核近满载) | 约 26% | 约 26% |
|
||||
| 终端内存 | 约 232 MB | 约 192 MB | 约 165 MB |
|
||||
| 终端温度 | 约 58~65℃ | 约 56℃ | 约 53℃ |
|
||||
| 实际帧率 | 无可靠统计(观感为低帧+卷帘) | 12.83 FPS | 22.79 FPS |
|
||||
| 网络丢包 | 未测 | 0.00% | 0.00% |
|
||||
| 网络抖动丢帧 | 未测 | 0.08% | 15.28% |
|
||||
| 网络延迟 | 未测 | 2 ms | 6 ms |
|
||||
| 主机处理延迟 | 未测 | 14.4 / 27.6 / 平均 18.1 ms | 18.8 / 78.3 / 平均 38.2 ms |
|
||||
| 平均渲染耗时 | 未测 | 3.34 ms | 46.26 ms |
|
||||
| 画面观感 | 卷帘、翻页 | 完整清晰,无撕裂 | 完整清晰,无撕裂 |
|
||||
|
||||
## 结论
|
||||
|
||||
1. **卷帘问题不是 RDP 协议的限制,是当前 RDP 客户端实现路径的问题。**
|
||||
Moonlight 在同一台盒子、同一台云桌面、同一网络下画面完整清晰,无卷帘无撕裂,
|
||||
抓屏确认 1080p 画面文字锐利、任务栏时间正确。
|
||||
|
||||
2. **Moonlight 的瓶颈在服务端,不在终端,也不<E4B99F><E4B88D><EFBFBD>网络。**
|
||||
1080p30 时网络丢包 0.00%、延迟 2 ms、渲染 3.34 ms、终端 CPU 26%,
|
||||
但实际帧率仅 12.83 FPS;1080p60 时主机处理延迟升到平均 38.2 ms、渲染 46.26 ms。
|
||||
服务端当前使用 libx264 软件编码(VM 内无 NVIDIA 直通,`nvidia-smi` 不存在),
|
||||
4 核 CPU 同时承担 1080p 编码与桌面捕获,是帧率上限的真正原因。
|
||||
|
||||
3. **RDP 路径的 CPU 代价约为 Moonlight 的 4 倍**(111% vs 26%),
|
||||
因为 RDP 走 `FreeRDP GDI -> CPU 内存复制 -> QImage/QPixmap -> QLabel -> Xorg`,
|
||||
而 Moonlight 只做一次解码和一次窗口呈现。
|
||||
|
||||
4. **RK3528 VPU 硬解在当前 moonlight-qt 6.2.0 下不可用。**
|
||||
`h264_v4l2m2m` / `hevc_v4l2m2m` / `hevc_v4l2request` 均报
|
||||
`Could not find a valid device`,即使盒子存在 /dev/mpp_service、/dev/rga 和
|
||||
/dev/dri/renderD128。需 Rockchip MPP FFmpeg 补丁或 V4L2 request 驱动。
|
||||
|
||||
## 后续建议
|
||||
|
||||
优先级从高到低:
|
||||
|
||||
1. **服务端 GPU 直通**:将宿主机 GTX 965M(PCI 01:00.0,IOMMU Group 2 独占)
|
||||
直通给 VM 100,使 Sunshine 可用 NVENC 硬编码。预期把 1080p 帧率从 13 FPS
|
||||
提升到 60 FPS,同时释放服务端 CPU。需先确认内核 `intel_iommu=on`、
|
||||
`vfio-pci` 已绑定,以及该笔记本 GPU 的 vBIOS ROM 提取。
|
||||
2. **消除帧率随请求升高而抖动**(60 FPS 时抖动丢帧 15.28%):服务端稳定后复测。
|
||||
3. **RK3528 硬解适配**:解决 V4L2 request/m2m 设备缺失,这是达成 4K 的前提。
|
||||
4. **协议选型建议**:
|
||||
- 若 GPU 直通可行,Moonlight 在 CAD/3D/视频场景优于 RDP,建议按产品方案采用双协议:
|
||||
普通办公用 RDP,GPU/CAD/3D 用 Moonlight;
|
||||
- 同时保留并优化 RDP 路径的 Weston + MPP/VPU + DMABUF/DRM 零拷贝改造,
|
||||
使 RDP 在办公场景达到可接受体验。
|
||||
5. 当前 Moonlight 未接 VPU,1080p 软件解码仅 26% CPU 但 4K 会超出能力,
|
||||
需在 4K 目标确定后优先解决硬解。
|
||||
|
||||
## 附:本次变更
|
||||
|
||||
- VM 100:安装 Virtual Display Driver(MttVDD),提供 1920×1080@60Hz 虚拟显示器。
|
||||
- VM 100:安装 Sunshine v2026.914.233613,Web UI 账号 admin / vdi12345。
|
||||
- 终端:安装 moonlight-qt 6.2.0,已与 Sunshine 完成配对。
|
||||
- 终端:kiosk-shell 自启动已恢复(~/.config/autostart/vdi-kiosk.desktop)。
|
||||
74
docs/sunshine-vm-setup-for-colleague.md
Normal file
74
docs/sunshine-vm-setup-for-colleague.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Sunshine 显卡环境配置指引(转给操作 VM 的同事)
|
||||
|
||||
## 背景
|
||||
|
||||
我们在 VM 100(win10-test,192.168.1.70)里装了 Sunshine 用于 Moonlight 串流验证。当前卡在:VM 没有可用 GPU,Windows 只有「Microsoft 基本显示适配器」,分辨率 1024×768、刷新率 1Hz,Sunshine 无法按 30/60fps 捕获编码。
|
||||
|
||||
不需要动 Proxmox,只需要在 Windows 里完成两件事:装虚拟显示器驱动 + 确认 Sunshine 配置。
|
||||
|
||||
## 第 1 步:安装虚拟显示器驱动(IddSampleDriver)
|
||||
|
||||
让 Windows 出现一个虚拟显卡,可自定义分辨率和刷新率:
|
||||
|
||||
1. 下载虚拟显示驱动包(ParrotGeek 的 IddSampleDriver,或等效的「Virtual Display Driver」)。
|
||||
常用地址:https://github.com/itsmikethetech/Virtual-Display-Driver/releases
|
||||
(如果 GitHub 打不开,用镜像:把链接前面加 https://gh-proxy.com/ )
|
||||
2. 解压后右键 `IddSampleDriver.inf` →「安装」。
|
||||
3. 重启 Windows。
|
||||
4. 装好后设备管理器会出现「IddSampleDriver Device」,显示设置里会多出一个虚拟显示器。
|
||||
5. 把虚拟显示器设置为:
|
||||
- 分辨率:1920×1080(先用 1080p,稳定后再试 2K)
|
||||
- 刷新率:60Hz(关键!当前 1Hz 是主要问题)
|
||||
- 如果看到多显示器,把虚拟屏设为「扩展这些显示器」, Sunshine 默认抓主屏;也可以只保留虚拟屏。
|
||||
|
||||
## 第 2 步:确认 NVIDIA 独显是否可用(如果宿主已直通/有驱动)
|
||||
|
||||
打开 PowerShell 执行:
|
||||
|
||||
```powershell
|
||||
nvidia-smi
|
||||
```
|
||||
|
||||
- 如果能输出显卡信息(GTX 965M):很好,说明 VM 内能用 NVIDIA。
|
||||
- 请安装 GeForce 驱动(如未装),然后告知我们,我们把 Sunshine 切到 NVENC 硬编码。
|
||||
- 如果提示找不到命令或没有 NVIDIA 设备:跳过此步,走纯软件编码 + 虚拟显示器路线(也够验证流畅度)。
|
||||
|
||||
## 第 3 步:检查 Sunshine 服务状态
|
||||
|
||||
Sunshine 已装在 `C:\Program Files\Sunshine\`,服务名 `SunshineService`。
|
||||
|
||||
PowerShell 执行:
|
||||
|
||||
```powershell
|
||||
Get-Service SunshineService
|
||||
Restart-Service SunshineService
|
||||
```
|
||||
|
||||
重启服务后再看日志确认识别到新显示器:
|
||||
|
||||
```powershell
|
||||
Get-Content "C:\Program Files\Sunshine\config\sunshine.log" -Tail 60
|
||||
```
|
||||
|
||||
需要看到的关键行(类似):
|
||||
|
||||
```
|
||||
Desktop resolution [1920x1080]
|
||||
Display refresh rate [60 Hz]
|
||||
```
|
||||
|
||||
## 第 4 步:告知我们结果
|
||||
|
||||
只需回三条信息:
|
||||
|
||||
1. 虚拟显示器是否装好,分辨率/刷新率是多少;
|
||||
2. `nvidia-smi` 是否有输出;
|
||||
3. sunshine.log 最后几十行里的 Desktop resolution / Display refresh rate 数值。
|
||||
|
||||
我们这边会立刻重新发起 Moonlight 串流测试并采集性能数据。
|
||||
|
||||
## 备注
|
||||
|
||||
- Web UI(可选):https://192.168.1.70:47990 账号 admin / 密码 vdi12345
|
||||
- 装驱动需要重启 VM,重启后 SunshineService 会自启,无需其他操作。
|
||||
- Sunshine 的串流端口 47984/47989/47990/48010 已确认开放,防火墙无需改动。
|
||||
20
docs/vdd_settings.xml
Normal file
20
docs/vdd_settings.xml
Normal file
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<display_config>
|
||||
<target_display>
|
||||
<target_x>0</target_x>
|
||||
<target_y>0</target_y>
|
||||
<target_width>1920</target_width>
|
||||
<target_height>1080</target_height>
|
||||
<target_refresh_rate>60</target_refresh_rate>
|
||||
</target_display>
|
||||
<adapter_index>0</adapter_index>
|
||||
<modes>
|
||||
<mode>
|
||||
<width>1920</width>
|
||||
<height>1080</height>
|
||||
<refresh_rate>60</refresh_rate>
|
||||
</mode>
|
||||
</modes>
|
||||
</display_config>
|
||||
</root>
|
||||
287
docs/web-console-mvp-plan-20261028.md
Normal file
287
docs/web-console-mvp-plan-20261028.md
Normal file
@ -0,0 +1,287 @@
|
||||
# VDI Web 管理控制台 MVP 实施方案
|
||||
|
||||
> 版本:v0.1(功能先行,UI 稿待项目经理提供)
|
||||
> 日期:2026-10-28
|
||||
> 依据:`VDI云桌面软件开发方案(1).docx` 第 2、4、5 节及 `VDI云桌面完整方案-云管端.docx` 管理平台模块表。
|
||||
|
||||
## 1. 先明确两个“登录”
|
||||
|
||||
本项目有两个不同的界面,必须分开:
|
||||
|
||||
| 界面 | 使用者 | 运行位置 | 职责 |
|
||||
|---|---|---|---|
|
||||
| 终端 kiosk-shell | 云桌面最终用户 | RK3528 盒子本地全屏 | 首配、设备绑定、用户登录、选择/进入被授权桌面、会话状态 |
|
||||
| Web 管理控制台 console | 超级管理员、运维、只读人员 | PC 浏览器 | 终端、桌面池、用户绑定、GPU、监控、OTA、审计管理 |
|
||||
|
||||
正式文档中的 `console/` 指第二项。它不应运行在盒子上,也不应直接发起 RDP/Moonlight;它通过平台 API 管理资源和下发策略。
|
||||
|
||||
“盒子开机直连 Moonlight”只能作为当前串流技术验证,不是最终产品登录流程。最终终端流程应恢复为:
|
||||
|
||||
```text
|
||||
盒子开机
|
||||
-> kiosk-shell 全屏终端登录/首配页
|
||||
-> vdt-agent 注册并获取终端身份
|
||||
-> 用户登录或终端绑定校验
|
||||
-> 平台返回该用户可用的桌面列表及短期连接信息
|
||||
-> 用户选择桌面
|
||||
-> kiosk-shell 按 protocol=rdp|moonlight 启动会话
|
||||
-> 断开后回桌面列表/登录页
|
||||
```
|
||||
|
||||
## 2. Web 控制台 MVP 目标
|
||||
|
||||
第一版目标不是做漂亮的大屏,而是跑通一个可演示、可替换 UI、可接真实后端的后台系统:
|
||||
|
||||
1. 用户能登录和退出;
|
||||
2. 根据角色控制菜单和按钮;
|
||||
3. 管理员能查看终端、桌面、用户及绑定关系;
|
||||
4. 能对终端下发命令、对桌面执行开关机/重置;
|
||||
5. 能查看基础状态、操作结果和审计记录;
|
||||
6. 后端未完成时使用 Mock API,后端接口上线后不改页面业务逻辑即可切换。
|
||||
|
||||
## 3. 技术栈与工程结构
|
||||
|
||||
按定版文档执行:Vue 3 + Vite + TypeScript 严格模式 + Element Plus。
|
||||
|
||||
建议补充:Pinia(状态)、Vue Router、Axios、Vitest、Playwright、MSW(Mock API)。
|
||||
|
||||
```text
|
||||
console/
|
||||
├─ src/
|
||||
│ ├─ api/ # OpenAPI 生成 SDK 的薄封装
|
||||
│ ├─ assets/
|
||||
│ ├─ components/ # 领域无关组件
|
||||
│ ├─ layouts/ # 登录布局、控制台布局
|
||||
│ ├─ mocks/ # MSW handlers 与演示数据
|
||||
│ ├─ router/ # 路由、鉴权守卫、RBAC meta
|
||||
│ ├─ stores/ # auth、app、notification
|
||||
│ ├─ types/ # 页面 ViewModel;API 类型由生成器管理
|
||||
│ ├─ views/
|
||||
│ │ ├─ login/
|
||||
│ │ ├─ dashboard/
|
||||
│ │ ├─ terminals/
|
||||
│ │ ├─ desktops/
|
||||
│ │ ├─ users/
|
||||
│ │ ├─ gpu/
|
||||
│ │ ├─ monitoring/
|
||||
│ │ ├─ ota/
|
||||
│ │ └─ audit/
|
||||
│ ├─ App.vue
|
||||
│ └─ main.ts
|
||||
├─ e2e/
|
||||
├─ public/
|
||||
├─ .env.development
|
||||
├─ .env.mock
|
||||
├─ package.json
|
||||
└─ vite.config.ts
|
||||
```
|
||||
|
||||
约束:页面不能直接拼 URL 或写 Axios;所有网络调用只经过 `api/`。这样 Mock 与真实后端可无感切换。
|
||||
|
||||
## 4. 页面与优先级
|
||||
|
||||
### M0:登录与应用框架
|
||||
|
||||
- 登录页:用户名、密码、登录按钮、错误状态、加载状态;
|
||||
- 控制台壳:侧边导航、顶部栏、面包屑、当前账号、退出;
|
||||
- 401 自动清理会话并回登录;
|
||||
- 403 权限提示;
|
||||
- 路由级 RBAC:`super_admin`、`operator`、`viewer`;
|
||||
- 开发环境使用 Mock 登录,生产环境只调用真实 API。
|
||||
|
||||
### M1:核心演示闭环
|
||||
|
||||
1. 仪表盘:在线终端、活动会话、运行桌面、告警数量;
|
||||
2. 终端管理:列表、搜索、状态筛选、详情、重启、重置会话;
|
||||
3. 桌面管理:列表、状态筛选、开机、关机、重启、重置;
|
||||
4. 用户与绑定:用户列表、用户详情、终端/桌面绑定与解绑;
|
||||
5. 审计日志:操作者、动作、目标、结果、时间、请求 ID。
|
||||
|
||||
M1 完成后已经可以给项目经理演示完整业务流程。
|
||||
|
||||
### M2:完整文档范围
|
||||
|
||||
- 桌面池:模板、容量、回收策略;
|
||||
- GPU 池:节点/PCI 地址/型号/状态/绑定桌面;
|
||||
- 监控:会话在线数、FPS、RTT、节点水位、告警;
|
||||
- OTA 中心:版本、批次、灰度比例、进度、失败终端;
|
||||
- CSV 用户/绑定批量导入向导;
|
||||
- 配置模板与配置版本下发。
|
||||
|
||||
## 5. 登录与权限方案
|
||||
|
||||
### 登录接口
|
||||
|
||||
```http
|
||||
POST /api/v1/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "******"
|
||||
}
|
||||
```
|
||||
|
||||
建议响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "jwt",
|
||||
"expiresIn": 3600,
|
||||
"user": {
|
||||
"id": "usr_001",
|
||||
"username": "admin",
|
||||
"displayName": "系统管理员",
|
||||
"role": "super_admin"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议第一版 access token 仅存内存;刷新 token 使用 `HttpOnly + Secure + SameSite` Cookie。若后端尚未支持 refresh,MVP 可暂存 `sessionStorage`,但不要长期使用 `localStorage` 保存高权限令牌。
|
||||
|
||||
### 权限矩阵
|
||||
|
||||
| 能力 | super_admin | operator | viewer |
|
||||
|---|---:|---:|---:|
|
||||
| 查看所有页面 | 是 | 是 | 是 |
|
||||
| 终端远程命令 | 是 | 是 | 否 |
|
||||
| 桌面生命周期操作 | 是 | 是 | 否 |
|
||||
| 用户/绑定修改 | 是 | 否 | 否 |
|
||||
| GPU 绑定 | 是 | 否 | 否 |
|
||||
| OTA 发布/灰度 | 是 | 是 | 否 |
|
||||
| 只读监控/审计 | 是 | 是 | 是 |
|
||||
|
||||
前端 RBAC 只是交互约束,真正授权必须由 `api-gateway` 再验证,不能只隐藏按钮。
|
||||
|
||||
## 6. 第一版 API 契约
|
||||
|
||||
文档已明确的接口优先沿用:
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login
|
||||
GET /api/v1/users
|
||||
POST /api/v1/desktop/assign
|
||||
POST /api/v1/pool/{id}/action
|
||||
GET /api/v1/gpu/cards
|
||||
POST /api/v1/gpu/bind
|
||||
GET /api/v1/monitor/query
|
||||
```
|
||||
|
||||
为控制台 MVP 补齐建议接口(需后端确认并写入 OpenAPI):
|
||||
|
||||
```text
|
||||
GET /api/v1/auth/me
|
||||
POST /api/v1/auth/logout
|
||||
GET /api/v1/terminals
|
||||
GET /api/v1/terminals/{id}
|
||||
POST /api/v1/terminals/{id}/commands
|
||||
GET /api/v1/desktops
|
||||
GET /api/v1/desktops/{id}
|
||||
POST /api/v1/desktops/{id}/actions
|
||||
GET /api/v1/bindings
|
||||
DELETE /api/v1/bindings/{id}
|
||||
GET /api/v1/audit-logs
|
||||
```
|
||||
|
||||
所有列表统一查询参数:`page`、`pageSize`、`keyword`、`status`、`sort`;统一响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [],
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"total": 0,
|
||||
"requestId": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
所有命令型接口必须返回 `commandId`,页面展示“已提交”,再轮询或通过 WebSocket/MQTT 网关显示最终结果,不能把 HTTP 200 当作设备已经执行成功。
|
||||
|
||||
## 7. Mock 先行策略
|
||||
|
||||
后端还没完成时,前端不应硬编码页面数据。使用 MSW 模拟真实 HTTP:
|
||||
|
||||
- 固定 3 个测试账号,对应三角色;
|
||||
- 终端包含在线/离线/会话中/升级中;
|
||||
- 桌面包含运行/停止/错误/任务中;
|
||||
- 所有命令先进入 `pending`,1–3 秒后变成 `success` 或 `failed`;
|
||||
- 登录失败、401、403、网络超时、空列表都要有测试场景;
|
||||
- `VITE_API_MODE=mock|real` 切换数据源。
|
||||
|
||||
项目经理 UI 稿到后,Mock 数据仍然可用于逐页视觉验收。
|
||||
|
||||
## 8. 与终端登录流程的接口边界
|
||||
|
||||
管理员 Web 控制台不直接把 VM 密码写进终端,也不让终端调用 Proxmox API。
|
||||
|
||||
建议终端用户登录流程由 `auth-svc` 单独提供:
|
||||
|
||||
```text
|
||||
POST /api/v1/terminal/session/login
|
||||
GET /api/v1/terminal/session/desktops
|
||||
POST /api/v1/terminal/session/connect-ticket
|
||||
```
|
||||
|
||||
`connect-ticket` 返回短期、一次性连接信息:
|
||||
|
||||
```json
|
||||
{
|
||||
"desktopId": "desk_001",
|
||||
"protocol": "moonlight",
|
||||
"endpoint": "192.168.1.70",
|
||||
"app": "Desktop",
|
||||
"display": { "width": 1920, "height": 1080, "fps": 60 },
|
||||
"ticket": "short-lived-token",
|
||||
"expiresAt": "2026-10-28T16:30:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
这部分不是 `console/` 页面,但必须和 Web 控制台的“用户/终端/桌面绑定”共享同一套后端数据。
|
||||
|
||||
## 9. UI 稿到来前的页面设计原则
|
||||
|
||||
第一版只做稳定工作台,不做营销式页面:
|
||||
|
||||
- 左侧导航 + 顶部工具栏 + 主内容区;
|
||||
- 列表优先,详情使用独立页或抽屉;
|
||||
- 状态使用图标/标签,不依赖单一颜色;
|
||||
- 危险操作二次确认;
|
||||
- 所有异步操作显示提交、执行中、成功、失败四态;
|
||||
- 页面级空态、错误态、无权限态必须完整;
|
||||
- 颜色、字号、间距集中为设计 token,避免 UI 稿到后逐页改 CSS;
|
||||
- 表格列、筛选区、表单字段以配置驱动,减少视觉改版成本。
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
M1 应达到:
|
||||
|
||||
1. 三种角色都能登录,菜单/按钮权限正确;
|
||||
2. 刷新页面仍能恢复会话,令牌过期自动回登录;
|
||||
3. 终端、桌面、用户/绑定、审计五条流程可演示;
|
||||
4. 命令有执行中与最终结果,不出现“点击无反馈”;
|
||||
5. API 全走统一 SDK/适配层,无页面直连 Axios;
|
||||
6. Vitest 覆盖 auth store、路由守卫、权限判断;
|
||||
7. Playwright 覆盖登录、终端命令、桌面操作、用户绑定四条主路径;
|
||||
8. `npm run lint`、`npm run typecheck`、`npm run test`、`npm run build` 全通过。
|
||||
|
||||
## 11. 推荐实施顺序
|
||||
|
||||
```text
|
||||
第 1 步:初始化 console 工程、路由、布局、设计 token
|
||||
第 2 步:OpenAPI 初稿 + API SDK 生成 + MSW Mock
|
||||
第 3 步:登录、会话恢复、RBAC、错误处理
|
||||
第 4 步:终端管理 + 桌面管理
|
||||
第 5 步:用户与绑定 + 审计日志
|
||||
第 6 步:仪表盘聚合数据
|
||||
第 7 步:Playwright E2E 与生产构建
|
||||
第 8 步:项目经理 UI 稿到后替换视觉层
|
||||
第 9 步:接真实 Go API,关闭 Mock
|
||||
```
|
||||
|
||||
## 12. 当前决策建议
|
||||
|
||||
- 现在先开发 `console/` 的 M0 + M1,不等 UI 稿;
|
||||
- 同时为 `terminal/kiosk-shell` 恢复“登录/桌面列表后连接”的产品流程,不再把开机直连 Moonlight 当最终形态;
|
||||
- Web 控制台第一版只接 Mock,API 契约先和后端负责人确认;
|
||||
- Moonlight/RDP 统一抽象成终端连接票据的 `protocol` 字段,Web 控制台只管理策略,不直接操控串流客户端;
|
||||
- UI 稿到后只调整布局组件与 design tokens,业务路由、权限、API、测试保留。
|
||||
29
tools/overnight-monitor.sh
Normal file
29
tools/overnight-monitor.sh
Normal file
@ -0,0 +1,29 @@
|
||||
#!/bin/sh
|
||||
set -u
|
||||
LOG_DIR="$HOME/vdi-terminal/logs"
|
||||
LOG_FILE="$LOG_DIR/overnight-$(date +%Y%m%d).log"
|
||||
mkdir -p "$LOG_DIR"
|
||||
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S %z')" "$*" >> "$LOG_FILE"; }
|
||||
log "monitor_start pid=$$ interval=60s"
|
||||
while :; do
|
||||
now=$(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
shell_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/kiosk-shell$' | head -n 1 || true)
|
||||
monitor_pid=$(pgrep -f '^/home/armbian/vdi-terminal/bin/vdi-kiosk-monitor' | head -n 1 || true)
|
||||
if [ -n "$shell_pid" ]; then
|
||||
ps_line=$(ps -o pcpu=,pmem=,rss=,etime= -p "$shell_pid" | tr '\n' ' ')
|
||||
else
|
||||
ps_line="missing"
|
||||
fi
|
||||
temp=$(awk '{printf "%.1f", $1/1000}' /sys/class/thermal/thermal_zone0/temp 2>/dev/null || printf "unknown")
|
||||
mem=$(awk '/MemTotal:|MemAvailable:|SwapFree:/{printf "%s=%sKB ",$1,$2}' /proc/meminfo 2>/dev/null || true)
|
||||
heartbeat="missing"
|
||||
if [ -f /tmp/vdi-kiosk.heartbeat ]; then
|
||||
heartbeat=$(stat -c '%Y' /tmp/vdi-kiosk.heartbeat 2>/dev/null || printf 'unknown')
|
||||
fi
|
||||
rdp=$(ss -tnp 2>/dev/null | grep '192.168.1.70:3389' | tr '\n' ';' || true)
|
||||
starts=$(grep -c '主进程已启动' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
|
||||
exits=$(grep -c '主进程已退出' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
|
||||
crashes=$(grep -ciE 'segmentation fault|bus error|double free|invalid pointer|aborted|terminate called' /tmp/vdi-kiosk-monitor.log 2>/dev/null || printf 0)
|
||||
log "sample now=$now shell_pid=${shell_pid:-missing} monitor_pid=${monitor_pid:-missing} ps=[$ps_line] temp_c=$temp heartbeat_epoch=$heartbeat rdp=[$rdp] starts=$starts exits=$exits crashes=$crashes $mem"
|
||||
sleep 60
|
||||
done
|
||||
89
tools/pve_term_exec.py
Normal file
89
tools/pve_term_exec.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""Run a command on the PVE node via the same VNC term channel the web console uses.
|
||||
|
||||
Usage: python3 pve_term_exec.py "<command>"
|
||||
Reads ticket from /tmp/pve_t.txt (refreshed automatically if expired).
|
||||
Prints the command output captured from the terminal stream.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import ssl
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HOST = "https://192.168.1.51:8006"
|
||||
NODE = "starhe"
|
||||
CTX = ssl.create_default_context()
|
||||
CTX.check_hostname = False
|
||||
CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def http(path, data=None, ticket=None, csrf=None):
|
||||
body = urllib.parse.urlencode(data).encode() if data is not None else None
|
||||
req = urllib.request.Request(HOST + path, body)
|
||||
if ticket:
|
||||
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
|
||||
if csrf:
|
||||
req.add_header("CSRFPreventionToken", csrf)
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=30) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw)["data"] if raw else None
|
||||
|
||||
|
||||
def login():
|
||||
d = http("/api2/json/access/ticket",
|
||||
{"username": "root@pam", "password": "yx66668888"})
|
||||
return d["ticket"], d["CSRFPreventionToken"]
|
||||
|
||||
|
||||
def run_cmd(command, timeout=60):
|
||||
ticket, csrf = login()
|
||||
# 1. open a term proxy (VNC channel to node shell)
|
||||
r = http("/api2/json/nodes/%s/termproxy" % NODE, {}, ticket, csrf)
|
||||
port, ticket_vnc = r["port"], r["ticket"]
|
||||
user, realm_sep, _ = "root", "@", "pam"
|
||||
|
||||
import socket
|
||||
sock = socket.create_connection(("192.168.1.51", port), timeout=10)
|
||||
# PVE handshake: username@realm\n ticket\n binary handshake
|
||||
sock.sendall(b"root@pam\n" + ticket_vnc.encode() + b"\n")
|
||||
gh = sock.recv(16)
|
||||
maj, mino = struct.unpack("BB", gh[0:2])
|
||||
said = sock.recv(16 * 4)
|
||||
# client sends its own handshake
|
||||
ch = struct.pack("BB", maj, mino) + b"PVEVNC Client 00" + b"\x00" * 0
|
||||
sock.sendall(ch)
|
||||
|
||||
# 2. run the command in the shell by typing it and reading output
|
||||
marker = "PVEDONE%s" % int(time.time())
|
||||
payload = command + " ; echo " + marker + "\n"
|
||||
time.sleep(0.5)
|
||||
sock.sendall(payload.encode())
|
||||
|
||||
buf = b""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
sock.settimeout(2)
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
if marker.encode() in buf.replace(b"\x1b", b"").replace(b"[?2004l", b""):
|
||||
break
|
||||
except socket.timeout:
|
||||
if marker.encode() in buf:
|
||||
break
|
||||
continue
|
||||
sock.close()
|
||||
# strip terminal control chars roughly and the echoed command
|
||||
text = buf.decode("utf-8", "replace")
|
||||
text = text.replace(marker, "")
|
||||
lines = [l for l in text.split("\r\n")]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(run_cmd(sys.argv[1]))
|
||||
55
tools/pve_vm.py
Normal file
55
tools/pve_vm.py
Normal file
@ -0,0 +1,55 @@
|
||||
import json
|
||||
import ssl
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HOST = "https://192.168.1.51:8006"
|
||||
CTX = ssl.create_default_context()
|
||||
CTX.check_hostname = False
|
||||
CTX.verify_mode = ssl.CERT_NONE
|
||||
|
||||
|
||||
def api(path, data=None, ticket=None, csrf=None, json_body=None):
|
||||
body = None
|
||||
headers = {}
|
||||
if json_body is not None:
|
||||
body = json.dumps(json_body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
elif data is not None:
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
req = urllib.request.Request(HOST + path, body, headers)
|
||||
if ticket:
|
||||
req.add_header("Cookie", "PVEAuthCookie=" + ticket)
|
||||
if csrf:
|
||||
req.add_header("CSRFPreventionToken", csrf)
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=40) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw)["data"] if raw else None
|
||||
|
||||
|
||||
def agent(ticket, csrf, command, timeout=90):
|
||||
r = api("/api2/json/nodes/starhe/qemu/100/agent/exec",
|
||||
json_body={"command": command}, ticket=ticket, csrf=csrf)
|
||||
pid = r["pid"]
|
||||
for _ in range(timeout // 2):
|
||||
import time
|
||||
time.sleep(2)
|
||||
st = api("/api2/json/nodes/starhe/qemu/100/agent/exec-status?pid=%d" % pid,
|
||||
ticket=ticket)
|
||||
if st.get("exited"):
|
||||
return st.get("out-data") or "", st.get("err-data") or ""
|
||||
return "TIMEOUT", ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
d = api("/api2/json/access/ticket",
|
||||
{"username": "root@pam", "password": "yx66668888"})
|
||||
ticket, csrf = d["ticket"], d["CSRFPreventionToken"]
|
||||
ps = sys.argv[1]
|
||||
out, err = agent(ticket, csrf, [
|
||||
"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
|
||||
"-NoProfile", "-Command", ps])
|
||||
print(out)
|
||||
if err:
|
||||
print("ERR:", err[:300])
|
||||
84
tools/xwd2png.py
Normal file
84
tools/xwd2png.py
Normal file
@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert an XWD dump (from xwd) into a PNG, without external image libraries.
|
||||
|
||||
Usage: xwd -root -silent | python3 xwd2png.py out.png
|
||||
"""
|
||||
import struct
|
||||
import sys
|
||||
import zlib
|
||||
|
||||
|
||||
def read_header(data):
|
||||
fields = struct.unpack(">25I", data[:100])
|
||||
names = [
|
||||
"header_size", "file_version", "pixmap_format", "pixmap_depth",
|
||||
"pixmap_width", "pixmap_height", "xoffset", "byte_order",
|
||||
"bitmap_unit", "bitmap_bit_order", "bitmap_pad", "bits_per_pixel",
|
||||
"bytes_per_line", "visual_class", "red_mask", "green_mask",
|
||||
"blue_mask", "bits_per_rgb", "colormap_entries", "ncolors",
|
||||
"window_width", "window_height", "window_x", "window_y",
|
||||
"window_bdrwidth",
|
||||
]
|
||||
return dict(zip(names, fields))
|
||||
|
||||
|
||||
def write_png(path, width, height, rgb_rows):
|
||||
raw = b"".join(b"\x00" + row for row in rgb_rows)
|
||||
|
||||
def chunk(tag, payload):
|
||||
return (struct.pack(">I", len(payload)) + tag + payload +
|
||||
struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
|
||||
|
||||
header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||
png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) +
|
||||
chunk(b"IDAT", zlib.compress(raw, 6)) + chunk(b"IEND", b""))
|
||||
with open(path, "wb") as handle:
|
||||
handle.write(png)
|
||||
|
||||
|
||||
def main():
|
||||
out_path = sys.argv[1] if len(sys.argv) > 1 else "out.png"
|
||||
data = sys.stdin.buffer.read()
|
||||
head = read_header(data)
|
||||
width = head["pixmap_width"]
|
||||
height = head["pixmap_height"]
|
||||
bpp = head["bits_per_pixel"]
|
||||
stride = head["bytes_per_line"]
|
||||
offset = head["header_size"] + head["ncolors"] * 12
|
||||
pixels = data[offset:]
|
||||
|
||||
if bpp == 32:
|
||||
rows = []
|
||||
for y in range(height):
|
||||
line = pixels[y * stride:y * stride + width * 4]
|
||||
out = bytearray(width * 3)
|
||||
for x in range(width):
|
||||
src = x * 4
|
||||
dst = x * 3
|
||||
out[dst] = line[src + 2]
|
||||
out[dst + 1] = line[src + 1]
|
||||
out[dst + 2] = line[src]
|
||||
rows.append(bytes(out))
|
||||
elif bpp == 24:
|
||||
rows = []
|
||||
for y in range(height):
|
||||
line = pixels[y * stride:y * stride + width * 3]
|
||||
out = bytearray(width * 3)
|
||||
for x in range(width):
|
||||
src = x * 3
|
||||
dst = x * 3
|
||||
out[dst] = line[src + 2]
|
||||
out[dst + 1] = line[src + 1]
|
||||
out[dst + 2] = line[src]
|
||||
rows.append(bytes(out))
|
||||
else:
|
||||
sys.stderr.write("unsupported bits_per_pixel=%s\n" % bpp)
|
||||
return 2
|
||||
|
||||
write_png(out_path, width, height, rows)
|
||||
print("%s %dx%d" % (out_path, width, height))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Reference in New Issue
Block a user