replace JavaScript and C# packaging tools with Rust
This commit is contained in:
Generated
+32
-11
@@ -3323,11 +3323,9 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d2d36d9bb45863f9bc5225bf3bccda125fafb1b225c1f99dbeaeebb258e7dc"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"cpal",
|
||||
"ironrdp-error",
|
||||
"ironrdp-rdpsnd",
|
||||
"opus2",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3625,15 +3623,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libopus_sys"
|
||||
version = "0.3.3"
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b81c32f233fb2507347a93f97b9919493be9db7ec2249ce2c3ed0c89ad8edf60"
|
||||
dependencies = [
|
||||
"cmake",
|
||||
"log",
|
||||
"pkg-config",
|
||||
]
|
||||
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
@@ -3852,6 +3845,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mousiki"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "025feef2fc01604ee1fac4ae254b33480574b16210945d0096d665009ba9c666"
|
||||
dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
"cfg-if",
|
||||
"libm",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
@@ -4514,7 +4519,7 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49521e33fbf825d2abc8d696506c278cb8469d0c6cc05d3785bef5c5f7ee957b"
|
||||
dependencies = [
|
||||
"libopus_sys",
|
||||
"mousiki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5561,6 +5566,22 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "remotedesk-update-manifest"
|
||||
version = "0.2.19"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"clap",
|
||||
"ed25519-dalek 2.2.0",
|
||||
"pkcs8 0.10.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"tempfile",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "remotedesk-webrtc-core"
|
||||
version = "0.2.19"
|
||||
|
||||
@@ -14,6 +14,7 @@ members = [
|
||||
"client/helpers/rdp-session",
|
||||
"client/helpers/rdp-viewer",
|
||||
"protocol",
|
||||
"tools/update-manifest",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -18,7 +18,7 @@ RemoteDesk 采用双通道设计:
|
||||
```text
|
||||
+----------------------+
|
||||
| RemoteDesk Client |
|
||||
| Tauri + Rust helpers |
|
||||
| winit + wgpu + egui |
|
||||
+----------+-----------+
|
||||
|
|
||||
+--------------+--------------+
|
||||
@@ -51,7 +51,7 @@ RemoteDesk 采用双通道设计:
|
||||
|
||||
- 不从零实现 RDP 协议,也不引入 C++/FreeRDP 客户端。
|
||||
- 不承诺 Windows 系统 RDP 服务端内部零拷贝;只能验证并报告 IronRDP 客户端实际得到 GPU surface 还是 CPU bitmap。
|
||||
- 不使用 JavaScript、Tauri IPC 或 CPU buffer 传递 Linux 桌面原始视频帧;WebView2 `<video>` 仅保留为显式兼容模式。
|
||||
- 发布客户端不包含 JavaScript、Tauri、WebView 或 C# 启动器,远程媒体也不经过这些运行时。
|
||||
- 不提供 VDI 桌面池、模板克隆和用户调度。
|
||||
- 不承诺所有 Wayland 合成器均支持无人值守控制。
|
||||
- 不使用缓存型 CDN 转码或缓存远程桌面内容。
|
||||
@@ -63,12 +63,12 @@ RemoteDesk 采用双通道设计:
|
||||
|
||||
| 模块 | 技术 |
|
||||
|---|---|
|
||||
| Windows 桌面客户端 | Tauri 2、React/TypeScript、Rust |
|
||||
| Windows 桌面客户端 | Rust、winit、wgpu、egui |
|
||||
| RDP 通道 | 独立 Rust helper、IronRDP、D3D11 |
|
||||
| Linux Agent | Rust、Tokio、PipeWire/Portal/libei、X11、PTY/PAM |
|
||||
| 媒体 | GStreamer、硬件编码、Opus |
|
||||
| Linux 会话显示 | 默认 Rust/GStreamer/D3D11 原生零拷贝 helper;WebView2 仅兼容模式 |
|
||||
| 命令行显示 | xterm.js + WebRTC DataChannel |
|
||||
| 媒体 | str0m、GStreamer、平台硬件编解码、纯 Rust Opus |
|
||||
| Linux 会话显示 | Rust/wgpu 与平台原生 GPU surface |
|
||||
| 命令行显示 | Rust 原生终端 + WebRTC DataChannel |
|
||||
| 本地数据 | SQLite |
|
||||
| 凭据 | Windows Credential Manager |
|
||||
| 边缘中继 | Rust Rendezvous/Allocator、双 POP TURN、优质骨干网 |
|
||||
|
||||
@@ -40,7 +40,7 @@ windows = { version = "0.62.2", features = [
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
cpal = "0.17.3"
|
||||
opus2 = { version = "0.4.0", features = ["bundled"] }
|
||||
opus2 = { version = "0.4.0", default-features = false, features = ["backend-mousiki"] }
|
||||
windows-sys = { version = "0.61.2", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
|
||||
@@ -2,6 +2,9 @@ use clap::{Parser, Subcommand};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::Path;
|
||||
#[cfg(windows)]
|
||||
use std::process::Command as ProcessCommand;
|
||||
#[cfg(windows)]
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -40,7 +43,7 @@ static ENCODED_SESSIONS: OnceLock<Mutex<HashMap<String, EncodedSessionRoute>>> =
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
@@ -60,6 +63,7 @@ enum Command {
|
||||
#[arg(long)]
|
||||
allow_unauthenticated: bool,
|
||||
},
|
||||
ConfigureHost,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -88,7 +92,14 @@ struct Status {
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
let command = match cli.command {
|
||||
Some(command) => command,
|
||||
None if is_portable_host_executable(std::env::current_exe()?.as_path()) => {
|
||||
Command::ConfigureHost
|
||||
}
|
||||
None => anyhow::bail!("a command is required; use --help to list available commands"),
|
||||
};
|
||||
match command {
|
||||
Command::Status { json } => {
|
||||
let capabilities = capability::snapshot();
|
||||
let status = Status {
|
||||
@@ -140,10 +151,50 @@ fn main() -> anyhow::Result<()> {
|
||||
name,
|
||||
allow_unauthenticated,
|
||||
} => run_pipe_server(name, allow_unauthenticated)?,
|
||||
Command::ConfigureHost => configure_host()?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_portable_host_executable(path: &Path) -> bool {
|
||||
path.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("RemoteDesk-Host"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn configure_host() -> anyhow::Result<()> {
|
||||
let executable = std::env::current_exe()?;
|
||||
let root = executable
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to locate the portable Host directory"))?;
|
||||
let script = root.join("Configure-RemoteDeskHost.ps1");
|
||||
anyhow::ensure!(script.is_file(), "Configure-RemoteDeskHost.ps1 is missing");
|
||||
let status = ProcessCommand::new("powershell.exe")
|
||||
.current_dir(root)
|
||||
.arg("-NoProfile")
|
||||
.arg("-ExecutionPolicy")
|
||||
.arg("Bypass")
|
||||
.arg("-File")
|
||||
.arg(&script)
|
||||
.arg("-Action")
|
||||
.arg("Enable")
|
||||
.arg("-AcceptRemoteDesktopChanges")
|
||||
.status()
|
||||
.map_err(|error| anyhow::anyhow!("unable to start Host configuration: {error}"))?;
|
||||
anyhow::ensure!(
|
||||
status.success(),
|
||||
"Host configuration exited with {}",
|
||||
status.code().unwrap_or(-1)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn configure_host() -> anyhow::Result<()> {
|
||||
anyhow::bail!("Host configuration is only available on Windows")
|
||||
}
|
||||
|
||||
fn run_server(listen: SocketAddr) -> anyhow::Result<()> {
|
||||
let runtime = tokio::runtime::Runtime::new()?;
|
||||
runtime.block_on(async move {
|
||||
@@ -1384,6 +1435,19 @@ mod fallback {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn portable_host_alias_selects_the_rust_launcher() {
|
||||
assert!(is_portable_host_executable(Path::new(
|
||||
r"C:\RemoteDesk\RemoteDesk-Host.exe"
|
||||
)));
|
||||
assert!(is_portable_host_executable(Path::new(
|
||||
r"C:\RemoteDesk\remotedesk-host.EXE"
|
||||
)));
|
||||
assert!(!is_portable_host_executable(Path::new(
|
||||
r"C:\RemoteDesk\remotedesk-windows-agent.exe"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_software_fallback_field_does_not_enable_compatibility() {
|
||||
let default: AgentCommand = serde_json::from_str(r#"{"kind":"open_desktop"}"#).unwrap();
|
||||
|
||||
@@ -16,7 +16,7 @@ winit = "0.30"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
cpal = "0.17.3"
|
||||
opus2 = { version = "0.4.0", features = ["bundled"] }
|
||||
opus2 = { version = "0.4.0", default-features = false, features = ["backend-mousiki"] }
|
||||
windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[lints.rust]
|
||||
|
||||
@@ -127,7 +127,7 @@ surface 类型和 Adapter;硬件能力不足时明确拒绝会话,不静默
|
||||
|
||||
## 阶段 9:更新安装包和 CI
|
||||
|
||||
状态:部分完成。Windows/Linux 打包与 CI 已移除 Go、Node Web 前端和 Tauri 构建入口,原生 Client 与安装包已加入 CI;macOS 包、升级矩阵和完整产物白名单尚缺。
|
||||
状态:部分完成。Windows/Linux 打包与 CI 已移除 Go、Node Web 前端、Tauri、JavaScript 清单工具和 C# 启动器构建入口;升级清单及 Windows 便携入口已迁移到 Rust,原生 Client 与安装包已加入 CI;macOS 包、升级矩阵和完整产物白名单尚缺。
|
||||
|
||||
工作:
|
||||
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
|
||||
Linux Docker runner 使用 `rust:1.89-bookworm`,流水线会安装 GStreamer 开发包、`rpm`、`pkg-config` 和 CA 证书。Runner 需要允许访问 crates.io、NuGet(Windows WiX 下载)以及项目依赖的 HTTPS 服务。
|
||||
|
||||
Windows Exec runner 需要 Rust stable(包含 `rustfmt` 和 `cargo`)、PowerShell、Windows .NET Framework `csc.exe`,并允许脚本下载固定 SHA-256 的 WiX 4.0.6 包。Runner 应使用 x64 Windows 主机;MSI 产物不在普通 CI 中签名。
|
||||
Windows Exec runner 需要 Rust stable(包含 `rustfmt` 和 `cargo`)与 PowerShell,并允许脚本下载固定 SHA-256 的 WiX 4.0.6 包。Runner 应使用 x64 Windows 主机;MSI 产物不在普通 CI 中签名,不需要 Node.js、C# 编译器或 .NET Framework 构建链。
|
||||
|
||||
## 产物
|
||||
|
||||
|
||||
+11
-17
@@ -3,8 +3,7 @@
|
||||
## 1. 环境
|
||||
|
||||
- Rust stable,包含 rustfmt 和 Clippy。
|
||||
- Node.js 22 与 npm 10。
|
||||
- Windows RDP viewer 的安全 Named Pipe 和 Tauri 2 桌面壳已完成源码接线;Linux 远程 H.264 的 RFC 6184 接收、Media Foundation NV12 解码和 D3D11 VideoProcessor 窗口呈现也已接线,但尚未编译、实机验证或通过 ETW/GPUView 证明硬件解码与隐藏复制边界。Tauri 和媒体新依赖按本轮要求尚未解析或编译。
|
||||
- Windows 管理界面使用 Rust `winit`、`wgpu` 和 `egui`;仓库不需要 Node.js、npm、JavaScript、C# 编译器或 WebView 构建链。
|
||||
- Linux PipeWire、Portal、DRI3 与硬件编码器必须在对应 Linux 测试机执行 M0 Spike。
|
||||
- Drone CI 的 Linux/Windows 打包流水线、Runner 前置条件和产物留存约定见 [Drone CI 打包](ci-drone.md)。
|
||||
|
||||
@@ -33,6 +32,7 @@ cargo test --workspace
|
||||
- `remotedesk-rdp-viewer`:独立 IronRDP 原生窗口;Windows 优先使用 D3D11 swap chain CPU 上传呈现,失败时回退 software framebuffer,不经过 React IPC。
|
||||
- `remotedesk-native-video`:输出 D3D11 管线计划,并可在 Windows 真实创建硬件 device/immediate context。
|
||||
- `remotedesk-edge-service`:独立 Edge Presence、有界签名 Session Intent 队列、Allocator/coturn credential 服务和限额透明 TCP byte relay;不随控制端或 Agent 进程启动。
|
||||
- `remotedesk-update-manifest`:读取 Ed25519 PKCS#8 PEM 私钥,为最终 MSI 生成有界、不可覆盖的签名升级清单。
|
||||
|
||||
运行 native-video 规划:
|
||||
|
||||
@@ -70,22 +70,15 @@ cargo run -p remotedesk-native-video -- --play-h264-file C:\samples\baseline.mp4
|
||||
|
||||
成功时必须输出 `d3d11_created=true`、hardware driver type 和实际 feature level;这仍不代表解码器或端到端零拷贝已经接入。IronRDP viewer 的 swap chain 是独立 CPU 上传呈现路径。
|
||||
|
||||
控制服务还提供 `GET /api/v1/capabilities`。Web UI 必须以该接口启用运行时功能;设计稿中存在但未接入的功能保持 `false`。新增实际能力时,应同时更新 [实现状态](implementation-status.md) 和接口测试。
|
||||
控制服务还提供 `GET /api/v1/capabilities`。新增实际能力时,应同时更新 [实现状态](implementation-status.md) 和接口测试。
|
||||
|
||||
## 3. Web 管理界面
|
||||
## 3. 原生管理界面
|
||||
|
||||
```powershell
|
||||
cd client/web
|
||||
npm ci
|
||||
npm run lint
|
||||
npm run build
|
||||
npm run control
|
||||
npm run dev
|
||||
cargo run --locked -p remotedesk-native-gui
|
||||
```
|
||||
|
||||
控制服务监听 `http://127.0.0.1:4174`,Vite 开发服务器监听 `http://127.0.0.1:4173` 并转发 `/api`。默认主机列表为空,配置保存在浏览器本地存储;使用 `?demo=1` 才加载示例主机。
|
||||
|
||||
受限环境可用 `npm run build:check` 完成不写入 `dist` 的完整 Vite 构建检查。
|
||||
该入口直接启动 `winit`/`wgpu`/`egui` 窗口,不启动浏览器、WebView 或 JavaScript 开发服务器。
|
||||
|
||||
## 4. M0 Windows 预览包
|
||||
|
||||
@@ -93,9 +86,9 @@ npm run dev
|
||||
.\packaging\windows\package-preview.ps1
|
||||
```
|
||||
|
||||
输出位于 `artifacts/RemoteDesk-Portable-M0-<version>-windows-<arch>.zip`,并生成外部 `SHA256SUMS.txt`。解压后运行 `bin/remotedesk.exe`;Tauri 壳会选择随机 loopback 端口、验证控制服务的一次性启动 proof,并且只在 RemoteDesk WebView 中加载管理界面,不打开系统浏览器。
|
||||
输出位于 `artifacts/RemoteDesk-Portable-M0-<version>-windows-<arch>.zip`,并生成外部 `SHA256SUMS.txt`。解压后直接运行原生入口 `bin/remotedesk.exe`。
|
||||
|
||||
该压缩包不是 MSI,也不包含 Linux Agent。Windows 同时提供 Tauri 壳、`mstsc`、独立 IronRDP viewer 和 Windows Credential Manager 凭据适配器;viewer 支持固定/自定义初始尺寸、窗口动态分辨率、全屏和主动退出,协议探针会确认服务端 RDP 协商与 TLS 证书,但不会尝试账号登录。
|
||||
该压缩包不是 MSI,也不包含 Linux Agent。Windows 提供 Rust 原生管理界面、`mstsc`、独立 IronRDP viewer 和 Windows Credential Manager 凭据适配器;viewer 支持固定/自定义初始尺寸、窗口动态分辨率、全屏和主动退出,协议探针会确认服务端 RDP 协商与 TLS 证书,但不会尝试账号登录。
|
||||
|
||||
生成按当前用户安装的 MSI:
|
||||
|
||||
@@ -111,8 +104,9 @@ npm run dev
|
||||
.\packaging\build-all.ps1 -Culture zh-cn
|
||||
.\packaging\build-all.ps1 -Culture en-us
|
||||
|
||||
# 只构建 Hysteria2 UDP/QUIC 传输包
|
||||
.\packaging\build-all.ps1 -SkipClient -SkipHost
|
||||
# 只构建客户端或只构建 Host
|
||||
.\packaging\build-all.ps1 -SkipHost
|
||||
.\packaging\build-all.ps1 -SkipClient
|
||||
|
||||
# 重新生成全部产物前清空 artifacts
|
||||
.\packaging\build-all.ps1 -CleanArtifacts -Culture zh-cn
|
||||
|
||||
@@ -187,12 +187,12 @@ Windows Headless 方案只接受可验证的硬件路径:IDD/IddCx SDR 8-bit
|
||||
- 追加协议/RDP 回归:`remotedesk-protocol` 45 项、`remotedesk-rdp-session` 8 项、`remotedesk-rdp-viewer` 34 项全部通过;Tauri app 目标也可完成 0 测试编译运行。
|
||||
- `cargo test --locked --workspace --exclude remotedesk-client-core -j 1` 已完整通过;工作区其余包(Agent core/runtime、app、control-service、credential-store、edge、Linux terminal、native video、protocol、RDP session/viewer)均无失败。完整工作区的未完成项已明确收敛为 client-core 的 4 个 Windows Named Pipe 实测用例。
|
||||
- 最新源码已重新生成 Windows MSI:客户端 `RemoteDesk-M0-0.2.9-windows-x64.msi` SHA-256 `9b94158e001dc94816d62ce92bdc33fe6d5040795810e330b76d7e7a023df6ae`;Host `RemoteDesk-Host-0.2.9-windows-x64.msi` SHA-256 `b4241792fba8900112472ad0c1b23316801f878c8eb0a93e3269ea3ba0b710cb`。两者均为未签名开发包。
|
||||
- 在线升级清单生成器契约测试 `node packaging/windows/test-update-manifest.mjs` 通过,覆盖签名字段、目标平台、MSI 类型和篡改拒绝;生产更新源、真实签名证书/时间戳和原位升级仍未验收。
|
||||
- Rust 在线升级清单生成器 `remotedesk-update-manifest` 的契约测试通过,覆盖签名字段、目标平台、MSI 类型、防覆盖和篡改拒绝;生产更新源、真实签名证书/时间戳和原位升级仍未验收。
|
||||
- `remotedesk-client-core` 排除 4 个 Windows Named Pipe 实测用例后,其余 40 项测试全部通过;协议、显示布局、GPU 策略、凭据、会话和帧编解码均已自动化验证。
|
||||
|
||||
- Windows MSVC 工作区核心包已通过 `cargo check --locked -j 1`,覆盖协议、客户端核心、RDP viewer、控制服务、Linux Agent runtime 和 Edge service;Linux Agent runtime 的 43 个单元/协议/本地 Edge HTTP 集成测试全部通过。Edge 测试使用进程内真实 HTTP 服务,测试环境设置 `NO_PROXY=localhost,127.0.0.1`,不会把本地连接误送代理。
|
||||
- Tauri `remotedesk-app`、Linux terminal helper、native-video helper 和 RDP session helper 也已通过 `cargo check --locked -j 1`;Linux terminal helper 的未使用导入和可变绑定 warning 已清理。
|
||||
- Web 控制端已通过 `npm run build:check`(TypeScript 两套配置、lint 和 Vite production build)。
|
||||
- Rust 原生控制端与 `remotedesk-update-manifest` 已纳入 workspace check/test;JavaScript、C#、Node.js 和 `csc.exe` 构建入口已删除。
|
||||
- Linux x86_64 musl 被控端便携包和 DEB 已实际生成并写入 SHA-256 清单。静态兼容包使用 `--no-default-features`,关闭依赖系统 `libxkbcommon` 的 Wayland EIS 输入;该包仍包含 X11/终端/文件会话,Wayland EIS 运行时返回明确的不可用错误。
|
||||
- 当前重建产物哈希:Windows 控制端 MSI `cd9a993edbd3eeac48626127880bb8089455c9c64cf86500f0a153c8a22c6e76`;Windows Host MSI `f70bb19c9f1997eee98fe02b20267c6807f3171c5c0232a9ac3ecae497`;Linux tar.gz `6a17413eb74f47879002eef93aa8122e22e0d1347bbb499489b1dfe31f52e28d`;Linux DEB `410dd67c5a18ac7634afa192a527bbce3e958f01413ded14e97a330691e92215`。
|
||||
- Windows 原生 RDP 自定义显示器子集现已完成 secure-pipe JSON、控制端请求、Web UI 选择、viewer 参数和 Win32 monitor layout 筛选:索引必须唯一、范围有效且包含主显示器,筛选后重新计算组合窗口边界;viewer 单元测试和 Web build 已通过。仍需真实多显示器 Windows RDP 服务端验收。
|
||||
@@ -218,6 +218,6 @@ Windows Headless 方案只接受可验证的硬件路径:IDD/IddCx SDR 8-bit
|
||||
- IDD/IddCx 虚拟显示器驱动、Desktop Duplication 到 GPU 编码器的 zero-copy capture、BGRA 到 NV12 的 D3D11 GPU conversion。
|
||||
- NVENC、AMF/VCE/VCN、Quick Sync 真实编码器探测与编码 worker,以及 x264/x265/SVT-AV1 真实 fallback。
|
||||
- 音视频生产端统一 session-start QPC/PTS 映射已接入 Windows Agent compatibility 链;viewer 接收端已完成连续音频 PTS 漂移估计、60 ms PCM 占用反馈和 ±0.5% 有界动态重采样。仍需 Windows 实机长时间稳定性、默认设备切换/拔出和跨设备音画端到端验收,不能据此宣称已完成生产级硬件媒体链。
|
||||
- WASAPI 默认设备切换/拔出、无输出设备、callback error、长时间稳定性、内置 libopus 产物体积/许可证和真实 Windows 音画端到端验收。
|
||||
- WASAPI 默认设备切换/拔出、无输出设备、callback error、长时间稳定性、纯 Rust `mousiki` Opus 后端的产物体积/许可证和真实 Windows 音画端到端验收。
|
||||
- 认证 Named Pipe 和 ring 当前按 Windows 当前用户 SID 隔离;生产安装器/控制服务仍需负责生成每次 Agent 生命周期唯一的 bootstrap、仅注入目标 Agent 与 Go bridge、启动后清除父进程环境,并把两进程纳入统一 Job/service 生命周期。viewer 的输入事件使用独立持久连接和有界优先队列,Win32 全局键盘 hook 复用该队列。
|
||||
- Windows 实机下的多路 4K/120 性能、实际丢包/IDR 恢复和 NVENC/AMF/QSV 多路 session 压力测试。
|
||||
|
||||
@@ -31,7 +31,7 @@ openssl genpkey -algorithm Ed25519 -out remotedesk-update-private.pem
|
||||
先完成 MSI 构建和 Authenticode 签名,再生成更新清单。清单中的哈希必须对应最终上传的字节:
|
||||
|
||||
```powershell
|
||||
node .\packaging\windows\create-update-manifest.mjs `
|
||||
cargo run --locked -p remotedesk-update-manifest -- `
|
||||
--installer .\artifacts\RemoteDesk-M0-0.3.0-windows-x64.msi `
|
||||
--installer-url https://updates.example.com/stable/RemoteDesk-M0-0.3.0-windows-x64.msi `
|
||||
--private-key C:\secure\remotedesk-update-private.pem `
|
||||
|
||||
+8
-19
@@ -45,34 +45,23 @@ Linux Terminal 面板会显示该身份条目的 `已保存/未保存/待检查`
|
||||
|
||||
环境要求:
|
||||
|
||||
- Node.js 22
|
||||
- npm 10
|
||||
- Rust stable
|
||||
- Windows 图形与媒体开发组件
|
||||
|
||||
开发模式需要先构建页面,再分别启动控制服务和 Vite。第一个终端执行:
|
||||
直接启动 Rust 原生管理界面:
|
||||
|
||||
```powershell
|
||||
cd client/web
|
||||
npm ci
|
||||
npm run build
|
||||
npm run control
|
||||
cargo run --locked -p remotedesk-native-gui
|
||||
```
|
||||
|
||||
第二个终端执行:
|
||||
生产构建和检查:
|
||||
|
||||
```powershell
|
||||
cd client/web
|
||||
npm run dev
|
||||
cargo fmt --all -- --check
|
||||
cargo test --workspace --locked
|
||||
cargo build --release -p remotedesk-native-gui
|
||||
```
|
||||
|
||||
浏览器打开 `http://127.0.0.1:4173`。Vite 把 `/api` 转发给 `127.0.0.1:4174` 的 Rust 控制服务。生产构建和类型检查:
|
||||
|
||||
```powershell
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
无法写入 `dist` 的受限环境可执行 `npm run build:check`。
|
||||
|
||||
## 3. 切换白色主题
|
||||
|
||||
点击顶栏右侧的太阳或月亮图标切换白色主题和深色主题。首次打开时跟随 Windows 系统主题;手动切换后,RemoteDesk 会在当前浏览器或 WebView 配置中记住选择。
|
||||
|
||||
@@ -11,6 +11,7 @@ $ErrorActionPreference = 'Stop'
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$artifacts = Join-Path $repoRoot 'artifacts'
|
||||
$windowsScript = Join-Path $repoRoot 'packaging/windows/package-installer.ps1'
|
||||
$windowsPreviewScript = Join-Path $repoRoot 'packaging/windows/package-preview.ps1'
|
||||
$hostScript = Join-Path $repoRoot 'packaging/windows-host/package-host.ps1'
|
||||
$portableHostScript = Join-Path $repoRoot 'packaging/windows-host/package-portable-host.ps1'
|
||||
$previousRustFlags = $env:RUSTFLAGS
|
||||
@@ -41,6 +42,7 @@ try {
|
||||
$env:RUSTFLAGS = $staticRustFlags
|
||||
|
||||
if (-not $SkipClient) {
|
||||
Invoke-ChildBuild -Name 'Windows Client portable package' -Script $windowsPreviewScript
|
||||
Invoke-ChildBuild -Name 'Windows Client installer' -Script $windowsScript -Arguments @('-Culture', $Culture)
|
||||
}
|
||||
|
||||
@@ -62,10 +64,12 @@ try {
|
||||
Write-Host '==> Build outputs' -ForegroundColor Cyan
|
||||
$artifactRootFull = ([IO.Path]::GetFullPath($artifacts)).TrimEnd('\') + '\'
|
||||
$outputFiles = @()
|
||||
$outputFiles += @(Get-ChildItem -LiteralPath $artifacts -File -Include '*.msi', '*.exe', '*.zip' -ErrorAction SilentlyContinue)
|
||||
$outputFiles += @(Get-ChildItem -LiteralPath $artifacts -File -ErrorAction SilentlyContinue |
|
||||
Where-Object Extension -In @('.msi', '.exe', '.zip'))
|
||||
$portableHost = Join-Path $artifacts 'RemoteDesk-Host-portable'
|
||||
if (Test-Path -LiteralPath $portableHost) {
|
||||
$outputFiles += @(Get-ChildItem -LiteralPath $portableHost -File -Include '*.exe', 'manifest.json' -ErrorAction SilentlyContinue)
|
||||
$outputFiles += @(Get-ChildItem -LiteralPath $portableHost -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension -eq '.exe' -or $_.Name -eq 'manifest.json' })
|
||||
}
|
||||
|
||||
$outputFiles |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
RemoteDesk Host configures the Microsoft Remote Desktop service already included with supported Windows editions and installs the separate RemoteDesk Windows Agent capability probe. The Agent is a native capture/input path and does not use RDP; its full authenticated session runtime is not yet enabled by this package.
|
||||
|
||||
For a no-install portable package, run `package-portable-host.ps1`. Extract the generated ZIP and double-click `RemoteDesk-Host.exe`. It requests administrator permission and enables Remote Desktop directly; no MSI installation or service registration is required.
|
||||
For a no-install portable package, run `package-portable-host.ps1`. Extract the generated ZIP and double-click `RemoteDesk-Host.exe`. This is the Rust Agent binary packaged under a launcher alias; it invokes the local configuration script, which requests administrator permission and enables Remote Desktop. It does not automatically start an unauthenticated Agent network listener. No MSI installation or service registration is required.
|
||||
|
||||
Installation does not change RDP, firewall, NLA, or service settings. Open **Configure RemoteDesk Host** from the Start menu, review the change summary, and type `ENABLE`. Windows then displays an administrator elevation prompt.
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
@echo off
|
||||
setlocal
|
||||
set "SCRIPT=%~dp0Configure-RemoteDeskHost.ps1"
|
||||
if not exist "%SCRIPT%" (
|
||||
echo Configure-RemoteDeskHost.ps1 is missing.
|
||||
exit /b 1
|
||||
)
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%" -Action Enable -AcceptRemoteDesktopChanges
|
||||
set "EXITCODE=%ERRORLEVEL%"
|
||||
if not "%EXITCODE%"=="0" pause
|
||||
exit /b %EXITCODE%
|
||||
@@ -1,83 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.Principal;
|
||||
using System.Windows.Forms;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main()
|
||||
{
|
||||
string root = AppDomain.CurrentDomain.BaseDirectory;
|
||||
string script = Path.Combine(root, "Configure-RemoteDeskHost.ps1");
|
||||
if (!File.Exists(script))
|
||||
{
|
||||
MessageBox.Show("Configure-RemoteDeskHost.ps1 is missing.", "RemoteDesk Host", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
|
||||
var info = new ProcessStartInfo("powershell.exe")
|
||||
{
|
||||
UseShellExecute = true,
|
||||
Verb = "runas",
|
||||
WorkingDirectory = root,
|
||||
Arguments = "-NoExit -NoProfile -ExecutionPolicy Bypass -File \"" + script + "\" -Action Enable -AcceptRemoteDesktopChanges"
|
||||
};
|
||||
try
|
||||
{
|
||||
string agent = Path.Combine(root, "remotedesk-windows-agent.exe");
|
||||
if (File.Exists(agent))
|
||||
{
|
||||
StopPreviousAgent(agent);
|
||||
Process.Start(new ProcessStartInfo(agent, "serve --listen 0.0.0.0:39501")
|
||||
{
|
||||
WorkingDirectory = root,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
});
|
||||
}
|
||||
using (var process = Process.Start(info))
|
||||
{
|
||||
process.WaitForExit();
|
||||
return process.ExitCode;
|
||||
}
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception ex)
|
||||
{
|
||||
if (ex.NativeErrorCode == 1223) return 1223;
|
||||
MessageBox.Show(ex.Message, "RemoteDesk Host", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(ex.Message, "RemoteDesk Host", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static void StopPreviousAgent(string agentPath)
|
||||
{
|
||||
string expected = Path.GetFullPath(agentPath);
|
||||
foreach (var process in Process.GetProcessesByName("remotedesk-windows-agent"))
|
||||
{
|
||||
try
|
||||
{
|
||||
string runningPath = process.MainModule == null ? string.Empty : process.MainModule.FileName;
|
||||
if (!string.Equals(Path.GetFullPath(runningPath), expected, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
process.Kill();
|
||||
process.WaitForExit(3000);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// The new process will report a useful bind error if an old instance cannot be stopped.
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
</assembly>
|
||||
@@ -23,22 +23,15 @@ $agentBinary = Join-Path $repoRoot 'target\release\remotedesk-windows-agent.exe'
|
||||
& cargo.exe build --release -p remotedesk-windows-agent
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $agentBinary)) { throw 'Unable to build Windows Agent.' }
|
||||
Copy-Item -LiteralPath $agentBinary -Destination $OutputDirectory -Force
|
||||
|
||||
$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
|
||||
if (-not (Test-Path -LiteralPath $csc)) { $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe' }
|
||||
if (-not (Test-Path -LiteralPath $csc)) { throw 'Microsoft C# compiler (csc.exe) is required to create the portable EXE.' }
|
||||
$launcher = Join-Path $OutputDirectory 'RemoteDesk-Host.exe'
|
||||
$manifestPath = Join-Path $PSScriptRoot 'RemoteDesk-Host.manifest'
|
||||
& $csc /nologo /target:winexe /platform:anycpu /optimize+ /out:$launcher `
|
||||
"/win32manifest:$manifestPath" `
|
||||
/reference:System.dll /reference:System.Windows.Forms.dll (Join-Path $PSScriptRoot 'RemoteDesk-Host.cs')
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $launcher)) { throw 'Unable to build RemoteDesk-Host.exe.' }
|
||||
Copy-Item -LiteralPath $agentBinary -Destination $launcher -Force
|
||||
if (-not (Test-Path -LiteralPath $launcher)) { throw 'Unable to create the Rust RemoteDesk-Host.exe launcher.' }
|
||||
|
||||
$metadata = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Unable to read Cargo workspace metadata.' }
|
||||
$package = $metadata.packages | Where-Object name -eq 'remotedesk-protocol' | Select-Object -First 1
|
||||
if ($null -eq $package) { throw 'remotedesk-protocol is missing from workspace metadata.' }
|
||||
$manifest = [ordered]@{ product = 'RemoteDesk Host'; version = [string]$package.version; distribution = 'portable'; culture = $Culture; action = 'enable-remote-desktop'; install_required = $false }
|
||||
$manifest = [ordered]@{ product = 'RemoteDesk Host'; version = [string]$package.version; distribution = 'portable'; culture = $Culture; action = 'enable-remote-desktop'; launcher = 'rust-agent-alias'; install_required = $false }
|
||||
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'manifest.json') -Encoding utf8
|
||||
|
||||
$zipPath = "$OutputDirectory.zip"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
[STAThread]
|
||||
private static int Main()
|
||||
{
|
||||
string root = AppDomain.CurrentDomain.BaseDirectory;
|
||||
string app = Path.Combine(root, "bin", "remotedesk.exe");
|
||||
if (!File.Exists(app))
|
||||
{
|
||||
MessageBox.Show("RemoteDesk files are incomplete. Expected bin\\remotedesk.exe.", "RemoteDesk", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
try
|
||||
{
|
||||
using (var process = Process.Start(new ProcessStartInfo(app) { WorkingDirectory = root, UseShellExecute = true }))
|
||||
{
|
||||
return process == null ? 1 : 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(ex.Message, "RemoteDesk", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'
|
||||
import { readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
|
||||
const usage = `usage: node create-update-manifest.mjs \
|
||||
--installer <RemoteDesk.msi> \
|
||||
--installer-url <https-url> \
|
||||
--private-key <ed25519-pkcs8.pem> \
|
||||
--version <major.minor.patch> \
|
||||
--output <stable.json> \
|
||||
[--public-key-output <public-key.txt>] \
|
||||
[--channel <stable>] [--target <windows-x64>] [--notes-file <notes.txt>]`
|
||||
|
||||
function parseArgs(values) {
|
||||
const options = {}
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const name = values[index]
|
||||
if (name === '--help' || name === '-h') return { help: true }
|
||||
if (!name.startsWith('--')) throw new Error(`unknown argument: ${name}`)
|
||||
const value = values[index + 1]
|
||||
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`)
|
||||
if (Object.hasOwn(options, name)) throw new Error(`${name} was provided more than once`)
|
||||
options[name] = value
|
||||
index += 1
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
function required(options, name) {
|
||||
const value = options[name]
|
||||
if (!value) throw new Error(`${name} is required`)
|
||||
return value
|
||||
}
|
||||
|
||||
function validateShortText(value, maximum, name) {
|
||||
if (!value || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value)) {
|
||||
throw new Error(`${name} is invalid`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function validateUrl(value) {
|
||||
if (value.length > 2048 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
||||
throw new Error('installer URL is invalid')
|
||||
}
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'https:' || !url.hostname) throw new Error('installer URL must use HTTPS')
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new Error('installer URL must not contain credentials, query, or fragment')
|
||||
}
|
||||
if (!url.pathname.toLowerCase().endsWith('.msi')) {
|
||||
throw new Error('installer URL must identify an MSI package')
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function rawEd25519PublicKey(privateKey) {
|
||||
const jwk = createPublicKey(privateKey).export({ format: 'jwk' })
|
||||
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string') {
|
||||
throw new Error('private key is not an Ed25519 key')
|
||||
}
|
||||
const raw = Buffer.from(jwk.x, 'base64url')
|
||||
if (raw.length !== 32) throw new Error('Ed25519 public key must contain 32 bytes')
|
||||
return raw
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
if (options.help) {
|
||||
process.stdout.write(`${usage}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
const installerPath = path.resolve(required(options, '--installer'))
|
||||
const installerUrl = validateUrl(required(options, '--installer-url'))
|
||||
const privateKeyPath = path.resolve(required(options, '--private-key'))
|
||||
const outputPath = path.resolve(required(options, '--output'))
|
||||
const publicKeyOutput = options['--public-key-output']
|
||||
? path.resolve(options['--public-key-output'])
|
||||
: null
|
||||
const version = required(options, '--version')
|
||||
if (!/^\d+\.\d+\.\d+$/u.test(version)) throw new Error('version must be major.minor.patch')
|
||||
const channel = validateShortText(options['--channel'] ?? 'stable', 32, 'channel')
|
||||
const target = validateShortText(options['--target'] ?? 'windows-x64', 32, 'target')
|
||||
|
||||
const installer = await readFile(installerPath)
|
||||
const installerStats = await stat(installerPath)
|
||||
if (!installerStats.isFile() || installerStats.size === 0) throw new Error('installer is empty')
|
||||
if (installerStats.size > 1024 * 1024 * 1024) throw new Error('installer exceeds 1 GiB')
|
||||
const notes = options['--notes-file']
|
||||
? await readFile(path.resolve(options['--notes-file']), 'utf8')
|
||||
: undefined
|
||||
if (notes && (Buffer.byteLength(notes) > 4096 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(notes))) {
|
||||
throw new Error('release notes are invalid')
|
||||
}
|
||||
|
||||
const privateKey = createPrivateKey(await readFile(privateKeyPath, 'utf8'))
|
||||
if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('private key must use Ed25519')
|
||||
const publicKey = rawEd25519PublicKey(privateKey)
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
product: 'remotedesk',
|
||||
channel,
|
||||
version,
|
||||
published_at: new Date().toISOString(),
|
||||
target,
|
||||
installer: {
|
||||
url: installerUrl,
|
||||
sha256: createHash('sha256').update(installer).digest('hex'),
|
||||
size_bytes: installerStats.size,
|
||||
},
|
||||
...(notes ? { notes } : {}),
|
||||
}), 'utf8')
|
||||
const signature = sign(null, payload, privateKey)
|
||||
if (signature.length !== 64) throw new Error('Ed25519 signature must contain 64 bytes')
|
||||
const envelope = {
|
||||
schema: 1,
|
||||
payload: payload.toString('base64'),
|
||||
signature: signature.toString('base64'),
|
||||
}
|
||||
await writeFile(outputPath, `${JSON.stringify(envelope, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
|
||||
const publicKeyBase64 = publicKey.toString('base64')
|
||||
if (publicKeyOutput) {
|
||||
await writeFile(publicKeyOutput, `${publicKeyBase64}\n`, { encoding: 'ascii', flag: 'wx' })
|
||||
}
|
||||
process.stdout.write(`Manifest: ${outputPath}\n`)
|
||||
process.stdout.write(`Update public key: ${publicKeyBase64}\n`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -303,24 +303,6 @@ try {
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $payloadRoot 'manifest.json') -Encoding utf8
|
||||
|
||||
# Build a no-install launcher alongside the MSI. The launcher starts the
|
||||
# same complete payload from bin\remotedesk.exe using the payload root as
|
||||
# its working directory.
|
||||
$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
|
||||
if (-not (Test-Path -LiteralPath $csc)) { $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe' }
|
||||
if (-not (Test-Path -LiteralPath $csc)) { throw 'Microsoft C# compiler (csc.exe) is required to create the portable client EXE.' }
|
||||
$portableName = "RemoteDesk-M0-$version-$Culture-windows-$architecture"
|
||||
$portableRoot = Join-Path $artifactsRoot "$portableName-portable"
|
||||
if (Test-Path -LiteralPath $portableRoot) { Remove-Item -LiteralPath $portableRoot -Recurse -Force }
|
||||
Copy-Item -LiteralPath $payloadRoot -Destination $portableRoot -Recurse -Force
|
||||
$portableExe = Join-Path $portableRoot "$portableName.exe"
|
||||
& $csc /nologo /target:winexe /platform:anycpu /optimize+ /win32icon:$(Join-Path $PSScriptRoot 'icon.ico') /out:$portableExe `
|
||||
/reference:System.dll /reference:System.Windows.Forms.dll (Join-Path $PSScriptRoot 'RemoteDesk-Portable.cs')
|
||||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $portableExe)) { throw 'Unable to build portable client EXE.' }
|
||||
$portableZip = Join-Path $artifactsRoot "$portableName.zip"
|
||||
if (Test-Path -LiteralPath $portableZip) { Remove-Item -LiteralPath $portableZip -Force }
|
||||
Compress-Archive -Path (Join-Path $portableRoot '*') -DestinationPath $portableZip -CompressionLevel Optimal
|
||||
|
||||
if ($signingEnabled) {
|
||||
Get-ChildItem -LiteralPath $binRoot -Filter '*.exe' -File | Sort-Object Name | ForEach-Object {
|
||||
Invoke-RemoteDeskSignTool -SignTool $signTool -Thumbprint $signingCertificate.Thumbprint `
|
||||
@@ -375,8 +357,6 @@ try {
|
||||
Write-Output "Installer: $installerPath"
|
||||
Write-Output "SHA256: $installerHash"
|
||||
Write-Output "Authenticode: $(if ($signingEnabled) { "signed ($($signingCertificate.Thumbprint))" } else { 'unsigned development package' })"
|
||||
Write-Output "Portable EXE: $portableExe"
|
||||
Write-Output "Portable ZIP: $portableZip"
|
||||
} finally {
|
||||
if ($null -eq $previousRollForward) {
|
||||
Remove-Item Env:DOTNET_ROLL_FORWARD -ErrorAction SilentlyContinue
|
||||
|
||||
@@ -125,7 +125,9 @@ try {
|
||||
$hashLines = Get-ChildItem -LiteralPath $stagingRoot -Recurse -File |
|
||||
Sort-Object FullName |
|
||||
ForEach-Object {
|
||||
$relative = [IO.Path]::GetRelativePath($stagingRoot, $_.FullName).Replace('\', '/')
|
||||
$rootUri = [Uri]((Resolve-Path -LiteralPath $stagingRoot).Path.TrimEnd('\') + '\')
|
||||
$fileUri = [Uri]((Resolve-Path -LiteralPath $_.FullName).Path)
|
||||
$relative = [Uri]::UnescapeDataString($rootUri.MakeRelativeUri($fileUri).ToString()).Replace('\', '/')
|
||||
$hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
"$hash $relative"
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { createHash, generateKeyPairSync, verify } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
function runGenerator(script, args, expectedSuccess) {
|
||||
const result = spawnSync(process.execPath, [script, ...args], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
})
|
||||
if ((result.status === 0) !== expectedSuccess) {
|
||||
throw new Error(`generator exit ${result.status}: ${result.stderr || result.stdout}`)
|
||||
}
|
||||
}
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'remotedesk-update-manifest-'))
|
||||
try {
|
||||
const script = path.resolve('packaging/windows/create-update-manifest.mjs')
|
||||
const installer = path.join(root, 'RemoteDesk-M0-9.8.7-windows-x64.msi')
|
||||
const privateKeyPath = path.join(root, 'private.pem')
|
||||
const output = path.join(root, 'stable.json')
|
||||
const publicOutput = path.join(root, 'public.txt')
|
||||
const installerBytes = Buffer.from('deterministic MSI fixture bytes', 'utf8')
|
||||
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
|
||||
await writeFile(installer, installerBytes)
|
||||
await writeFile(privateKeyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }))
|
||||
|
||||
const validArgs = [
|
||||
'--installer', installer,
|
||||
'--installer-url', 'https://updates.example.test/releases/RemoteDesk-M0-9.8.7-windows-x64.msi',
|
||||
'--private-key', privateKeyPath,
|
||||
'--version', '9.8.7',
|
||||
'--channel', 'stable',
|
||||
'--target', 'windows-x64',
|
||||
'--output', output,
|
||||
'--public-key-output', publicOutput,
|
||||
]
|
||||
runGenerator(script, validArgs, true)
|
||||
|
||||
const envelope = JSON.parse(await readFile(output, 'utf8'))
|
||||
if (envelope.schema !== 1 || typeof envelope.payload !== 'string' || typeof envelope.signature !== 'string') {
|
||||
throw new Error('generated envelope schema is invalid')
|
||||
}
|
||||
const payloadBytes = Buffer.from(envelope.payload, 'base64')
|
||||
const signature = Buffer.from(envelope.signature, 'base64')
|
||||
if (signature.length !== 64 || !verify(null, payloadBytes, publicKey, signature)) {
|
||||
throw new Error('generated Ed25519 signature is invalid')
|
||||
}
|
||||
const payload = JSON.parse(payloadBytes.toString('utf8'))
|
||||
const expectedHash = createHash('sha256').update(installerBytes).digest('hex')
|
||||
if (payload.product !== 'remotedesk'
|
||||
|| payload.channel !== 'stable'
|
||||
|| payload.version !== '9.8.7'
|
||||
|| payload.target !== 'windows-x64'
|
||||
|| payload.installer.sha256 !== expectedHash
|
||||
|| payload.installer.size_bytes !== installerBytes.length) {
|
||||
throw new Error('signed payload does not bind the installer and release metadata')
|
||||
}
|
||||
const expectedPublicKey = Buffer.from(publicKey.export({ format: 'jwk' }).x, 'base64url').toString('base64')
|
||||
if ((await readFile(publicOutput, 'ascii')).trim() !== expectedPublicKey) {
|
||||
throw new Error('published raw update public key does not match the signing key')
|
||||
}
|
||||
|
||||
runGenerator(script, validArgs, false)
|
||||
runGenerator(script, [
|
||||
'--installer', installer,
|
||||
'--installer-url', 'http://updates.example.test/RemoteDesk.msi',
|
||||
'--private-key', privateKeyPath,
|
||||
'--version', '9.8.7',
|
||||
'--output', path.join(root, 'invalid.json'),
|
||||
], false)
|
||||
process.stdout.write('update manifest generator contract: OK\n')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "remotedesk-update-manifest"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Generate signed RemoteDesk update manifests"
|
||||
|
||||
[[bin]]
|
||||
name = "remotedesk-update-manifest"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
ed25519-dalek = { version = "2.2", features = ["pem"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.10"
|
||||
time = { version = "0.3", features = ["formatting"] }
|
||||
url = "2.5"
|
||||
|
||||
[dev-dependencies]
|
||||
pkcs8 = { version = "0.10", features = ["pem"] }
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,356 @@
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use clap::Parser;
|
||||
use ed25519_dalek::{Signer as _, SigningKey, pkcs8::DecodePrivateKey as _};
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use url::Url;
|
||||
|
||||
const MAX_INSTALLER_SIZE: u64 = 1024 * 1024 * 1024;
|
||||
const MAX_NOTES_SIZE: usize = 4 * 1024;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "remotedesk-update-manifest",
|
||||
about = "Generate an Ed25519-signed RemoteDesk update manifest"
|
||||
)]
|
||||
struct Args {
|
||||
#[arg(long)]
|
||||
installer: PathBuf,
|
||||
#[arg(long)]
|
||||
installer_url: String,
|
||||
#[arg(long)]
|
||||
private_key: PathBuf,
|
||||
#[arg(long)]
|
||||
version: String,
|
||||
#[arg(long)]
|
||||
output: PathBuf,
|
||||
#[arg(long)]
|
||||
public_key_output: Option<PathBuf>,
|
||||
#[arg(long, default_value = "stable")]
|
||||
channel: String,
|
||||
#[arg(long, default_value = "windows-x64")]
|
||||
target: String,
|
||||
#[arg(long)]
|
||||
notes_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SignedPayload {
|
||||
product: &'static str,
|
||||
channel: String,
|
||||
version: String,
|
||||
published_at: String,
|
||||
target: String,
|
||||
installer: Installer,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Installer {
|
||||
url: String,
|
||||
sha256: String,
|
||||
size_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Envelope {
|
||||
schema: u8,
|
||||
payload: String,
|
||||
signature: String,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args = Args::parse();
|
||||
match generate(&args) {
|
||||
Ok(public_key) => {
|
||||
println!("Manifest: {}", args.output.display());
|
||||
println!("Update public key: {public_key}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate(args: &Args) -> Result<String, String> {
|
||||
validate_version(&args.version)?;
|
||||
validate_short_text(&args.channel, 32, "channel")?;
|
||||
validate_short_text(&args.target, 32, "target")?;
|
||||
let installer_url = validate_installer_url(&args.installer_url)?;
|
||||
ensure_new_output(&args.output)?;
|
||||
if let Some(path) = args.public_key_output.as_deref() {
|
||||
ensure_new_output(path)?;
|
||||
}
|
||||
|
||||
let (size_bytes, sha256) = hash_installer(&args.installer)?;
|
||||
let notes = read_notes(args.notes_file.as_deref())?;
|
||||
let private_key = fs::read_to_string(&args.private_key)
|
||||
.map_err(|error| format!("unable to read the private key: {error}"))?;
|
||||
let signing_key = SigningKey::from_pkcs8_pem(&private_key)
|
||||
.map_err(|_| "private key must be an Ed25519 PKCS#8 PEM key".to_owned())?;
|
||||
let published_at = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.map_err(|error| format!("unable to format the publication time: {error}"))?;
|
||||
let payload = serde_json::to_vec(&SignedPayload {
|
||||
product: "remotedesk",
|
||||
channel: args.channel.clone(),
|
||||
version: args.version.clone(),
|
||||
published_at,
|
||||
target: args.target.clone(),
|
||||
installer: Installer {
|
||||
url: installer_url,
|
||||
sha256,
|
||||
size_bytes,
|
||||
},
|
||||
notes,
|
||||
})
|
||||
.map_err(|error| format!("unable to serialize the signed payload: {error}"))?;
|
||||
let signature = signing_key.sign(&payload);
|
||||
let envelope = Envelope {
|
||||
schema: 1,
|
||||
payload: STANDARD.encode(payload),
|
||||
signature: STANDARD.encode(signature.to_bytes()),
|
||||
};
|
||||
let mut manifest = serde_json::to_vec_pretty(&envelope)
|
||||
.map_err(|error| format!("unable to serialize the update manifest: {error}"))?;
|
||||
manifest.push(b'\n');
|
||||
write_new(&args.output, &manifest, "update manifest")?;
|
||||
|
||||
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
if let Some(path) = args.public_key_output.as_deref()
|
||||
&& let Err(error) = write_new(path, format!("{public_key}\n").as_bytes(), "public key")
|
||||
{
|
||||
let _ = fs::remove_file(&args.output);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(public_key)
|
||||
}
|
||||
|
||||
fn validate_version(value: &str) -> Result<(), String> {
|
||||
let parts = value.split('.').collect::<Vec<_>>();
|
||||
if parts.len() != 3
|
||||
|| parts
|
||||
.iter()
|
||||
.any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
|
||||
{
|
||||
return Err("version must be major.minor.patch".to_owned());
|
||||
}
|
||||
for part in parts {
|
||||
part.parse::<u64>()
|
||||
.map_err(|_| "version component is too large".to_owned())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_short_text(value: &str, maximum: usize, name: &str) -> Result<(), String> {
|
||||
if value.is_empty() || value.len() > maximum || value.chars().any(char::is_control) {
|
||||
return Err(format!("{name} is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_installer_url(value: &str) -> Result<String, String> {
|
||||
if value.len() > 2_048 || value.chars().any(char::is_control) {
|
||||
return Err("installer URL is invalid".to_owned());
|
||||
}
|
||||
let url = Url::parse(value).map_err(|_| "installer URL is invalid".to_owned())?;
|
||||
if url.scheme() != "https" || url.host_str().is_none() {
|
||||
return Err("installer URL must use HTTPS".to_owned());
|
||||
}
|
||||
if !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return Err("installer URL must not contain credentials, query, or fragment".to_owned());
|
||||
}
|
||||
if !url.path().to_ascii_lowercase().ends_with(".msi") {
|
||||
return Err("installer URL must identify an MSI package".to_owned());
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
fn hash_installer(path: &Path) -> Result<(u64, String), String> {
|
||||
let mut file =
|
||||
File::open(path).map_err(|error| format!("unable to open the installer: {error}"))?;
|
||||
let metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("unable to inspect the installer: {error}"))?;
|
||||
if !metadata.is_file() || metadata.len() == 0 {
|
||||
return Err("installer is empty".to_owned());
|
||||
}
|
||||
if metadata.len() > MAX_INSTALLER_SIZE {
|
||||
return Err("installer exceeds 1 GiB".to_owned());
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0_u8; 64 * 1024].into_boxed_slice();
|
||||
loop {
|
||||
let read = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| format!("unable to read the installer: {error}"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
Ok((metadata.len(), format!("{:x}", hasher.finalize())))
|
||||
}
|
||||
|
||||
fn read_notes(path: Option<&Path>) -> Result<Option<String>, String> {
|
||||
let Some(path) = path else {
|
||||
return Ok(None);
|
||||
};
|
||||
let notes = fs::read_to_string(path)
|
||||
.map_err(|error| format!("unable to read the release notes: {error}"))?;
|
||||
if notes.len() > MAX_NOTES_SIZE
|
||||
|| notes
|
||||
.chars()
|
||||
.any(|character| character.is_control() && !matches!(character, '\r' | '\n' | '\t'))
|
||||
{
|
||||
return Err("release notes are invalid".to_owned());
|
||||
}
|
||||
Ok((!notes.is_empty()).then_some(notes))
|
||||
}
|
||||
|
||||
fn ensure_new_output(path: &Path) -> Result<(), String> {
|
||||
if path.exists() {
|
||||
return Err(format!(
|
||||
"refusing to overwrite existing output: {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_new(path: &Path, bytes: &[u8], name: &str) -> Result<(), String> {
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|error| format!("unable to create the {name}: {error}"))?;
|
||||
file.write_all(bytes)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|error| format!("unable to write the {name}: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Verifier as _, pkcs8::EncodePrivateKey as _};
|
||||
use pkcs8::LineEnding;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn fixture(root: &Path) -> (Args, SigningKey) {
|
||||
let installer = root.join("RemoteDesk-M0-9.8.7-windows-x64.msi");
|
||||
let private_key = root.join("private.pem");
|
||||
let signing_key = SigningKey::from_bytes(&[7_u8; 32]);
|
||||
fs::write(&installer, b"deterministic MSI fixture bytes").unwrap();
|
||||
let pem = signing_key.to_pkcs8_pem(LineEnding::LF).unwrap();
|
||||
fs::write(&private_key, pem.as_bytes()).unwrap();
|
||||
(
|
||||
Args {
|
||||
installer,
|
||||
installer_url:
|
||||
"https://updates.example.test/releases/RemoteDesk-M0-9.8.7-windows-x64.msi"
|
||||
.to_owned(),
|
||||
private_key,
|
||||
version: "9.8.7".to_owned(),
|
||||
output: root.join("stable.json"),
|
||||
public_key_output: Some(root.join("public.txt")),
|
||||
channel: "stable".to_owned(),
|
||||
target: "windows-x64".to_owned(),
|
||||
notes_file: None,
|
||||
},
|
||||
signing_key,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_manifest_binds_installer_and_signing_key() {
|
||||
let root = tempdir().unwrap();
|
||||
let (args, signing_key) = fixture(root.path());
|
||||
let public_key = generate(&args).unwrap();
|
||||
assert_eq!(
|
||||
public_key,
|
||||
STANDARD.encode(signing_key.verifying_key().to_bytes())
|
||||
);
|
||||
|
||||
let envelope: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&args.output).unwrap()).unwrap();
|
||||
assert_eq!(envelope["schema"], 1);
|
||||
let payload = STANDARD
|
||||
.decode(envelope["payload"].as_str().unwrap())
|
||||
.unwrap();
|
||||
let signature = STANDARD
|
||||
.decode(envelope["signature"].as_str().unwrap())
|
||||
.unwrap();
|
||||
let signature = ed25519_dalek::Signature::from_slice(&signature).unwrap();
|
||||
signing_key
|
||||
.verifying_key()
|
||||
.verify_strict(&payload, &signature)
|
||||
.unwrap();
|
||||
let payload: serde_json::Value = serde_json::from_slice(&payload).unwrap();
|
||||
assert_eq!(payload["product"], "remotedesk");
|
||||
assert_eq!(payload["version"], "9.8.7");
|
||||
assert_eq!(payload["target"], "windows-x64");
|
||||
assert_eq!(payload["installer"]["size_bytes"], 31);
|
||||
assert_eq!(
|
||||
payload["installer"]["sha256"],
|
||||
"d3e9bac0e9cd7395e5e227314fe13afd7f170e29cdb9046231ad7b64857c4442"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(args.public_key_output.as_ref().unwrap())
|
||||
.unwrap()
|
||||
.trim(),
|
||||
public_key
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_not_overwritten_and_http_is_rejected() {
|
||||
let root = tempdir().unwrap();
|
||||
let (mut args, _) = fixture(root.path());
|
||||
generate(&args).unwrap();
|
||||
assert!(
|
||||
generate(&args)
|
||||
.unwrap_err()
|
||||
.contains("refusing to overwrite")
|
||||
);
|
||||
|
||||
args.output = root.path().join("invalid.json");
|
||||
args.public_key_output = None;
|
||||
args.installer_url = "http://updates.example.test/RemoteDesk.msi".to_owned();
|
||||
assert!(generate(&args).unwrap_err().contains("must use HTTPS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampering_invalidates_the_signature() {
|
||||
let root = tempdir().unwrap();
|
||||
let (args, signing_key) = fixture(root.path());
|
||||
generate(&args).unwrap();
|
||||
let envelope: serde_json::Value =
|
||||
serde_json::from_slice(&fs::read(&args.output).unwrap()).unwrap();
|
||||
let mut payload = STANDARD
|
||||
.decode(envelope["payload"].as_str().unwrap())
|
||||
.unwrap();
|
||||
payload[0] ^= 1;
|
||||
let signature = STANDARD
|
||||
.decode(envelope["signature"].as_str().unwrap())
|
||||
.unwrap();
|
||||
let signature = ed25519_dalek::Signature::from_slice(&signature).unwrap();
|
||||
assert!(
|
||||
signing_key
|
||||
.verifying_key()
|
||||
.verify(&payload, &signature)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -152,6 +152,7 @@ optional = true
|
||||
[dependencies.ironrdp-rdpsnd-native]
|
||||
version = "0.7"
|
||||
optional = true
|
||||
default-features = false
|
||||
|
||||
[dependencies.ironrdp-session]
|
||||
version = "0.11"
|
||||
|
||||
Reference in New Issue
Block a user