5 Commits

Author SHA1 Message Date
mike d10e547b70 Update README.md
build-windows / build-hello-agent-x64 (push) Successful in 6m5s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 7s
2026-05-09 14:25:27 +02:00
mike 8025f8558a Fix asset inventory update
build-windows / build-hello-agent-x64 (push) Successful in 6m6s
build-windows / sign-hello-agent-x64 (push) Successful in 6s
build-windows / validate-hello-agent-x64 (push) Successful in 7s
2026-05-09 11:32:12 +02:00
mike e815776329 Fix file-transfer
build-windows / build-hello-agent-x64 (push) Successful in 6m7s
build-windows / sign-hello-agent-x64 (push) Successful in 6s
build-windows / validate-hello-agent-x64 (push) Successful in 7s
2026-05-09 10:53:41 +02:00
mike b59be25a16 Implement asset inventory 2026-05-09 00:59:34 +02:00
mike a2c79e56d3 split builder and signer provision scripts for Gitea CI 2026-05-08 22:28:24 +02:00
17 changed files with 1656 additions and 203 deletions
+6 -3
View File
@@ -2,13 +2,13 @@ name: build-windows
on:
push:
branches: [main, master]
branches: [pro-features]
workflow_dispatch:
inputs:
version_suffix:
description: "Version suffix (e.g. 'cst', 'beta1'). Empty = vanilla."
type: string
default: ""
default: "cst"
# Workflow-level env is visible to every job. Runner-specific paths
# (VCPKG_ROOT, LLVM_HOME, …) live on the build-x64 job instead, since the
@@ -125,7 +125,10 @@ jobs:
id: version
shell: pwsh
env:
VERSION_SUFFIX: ${{ inputs.version_suffix }}
# On push events `inputs.*` is empty — the workflow_dispatch default
# ("cst") doesn't apply. Fall back to "cst" in-script so push and
# dispatch produce the same default tag shape.
VERSION_SUFFIX: ${{ inputs.version_suffix || 'cst' }}
run: |
$base = (Select-String -Path Cargo.toml -Pattern '^version = "([^"]+)"').Matches[0].Groups[1].Value
if (-not $base) { throw "could not parse version from Cargo.toml" }
Generated
+2 -1
View File
@@ -3197,13 +3197,14 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hello-agent"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"env_logger 0.10.2",
"hbb_common",
"log",
"rustdesk",
"serde_json 1.0.118",
"tokio",
"winapi",
"windows-service",
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "hello-agent"
version = "0.1.0"
version = "0.1.1"
edition = "2021"
rust-version = "1.75"
description = "Headless RustDesk-protocol-compatible support agent for Windows"
@@ -28,10 +28,16 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync",
log = "0.4"
env_logger = "0.10"
anyhow = "1"
# Used by `inventory.rs` to validate the PowerShell-produced JSON before
# we stamp it into the sysinfo upload. hbb_common already pulls serde_json
# transitively, so this is a free re-export — listed explicitly here so
# the inventory module's `use serde_json` doesn't depend on internal
# implementation details of hbb_common.
serde_json = "1"
[target.'cfg(target_os = "windows")'.dependencies]
windows-service = "0.6"
winapi = { version = "0.3", features = ["winuser", "wtsapi32", "processthreadsapi", "synchapi", "handleapi", "winbase"] }
winapi = { version = "0.3", features = ["winuser", "wtsapi32", "processthreadsapi", "synchapi", "handleapi", "winbase", "wlanapi", "wlantypes"] }
winreg = "0.11"
# Embed the icon and EXE metadata via the Windows resource compiler.
+76 -15
View File
@@ -45,6 +45,10 @@ hello-agent.exe --install
└──> creates Windows service "HelloAgent", binPath ends in --service
hello-agent.exe --service # Session 0, LocalSystem
├── unattended_password::rotate_and_report (background thread)
│ └─ POSTs per-boot password to <api-server>/api/unattended-password
│ with retry until ack — races rendezvous registration
└──> spawns into the active console session as SYSTEM token:
@@ -53,6 +57,12 @@ hello-agent.exe --server # user session, SYSTEM token
├── default ipc listener (rustdesk core)
├── RendezvousMediator ──> rustdesk-server registration + NAT
├── hbbs_http::sync ──> /api/heartbeat + /api/sysinfo
│ └─ stamps `agent_name` / `agent_version` / `inventory`
│ into each /api/sysinfo payload (re-uploads when the
│ inventory collector below transitions empty → ready)
├── inventory::collect_inventory (background thread)
│ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global
│ consumed by hbbs_http::sync above; one-shot, no retry
│ at startup, --server proactively spawns (via WTSQueryUserToken
│ + CreateProcessAsUserW with lpDesktop = winsta0\default —
@@ -78,35 +88,66 @@ Flutter Connection Manager.
```
hello-agent/
├── src/ hello-agent sources (~600 lines)
├── src/ hello-agent sources (~2400 lines)
├── vendor/rustdesk/ vendored RustDesk crate + workspace libs
│ ├── Cargo.toml rustdesk's own workspace + package manifest
│ ├── src/ librustdesk source
│ └── libs/ hbb_common, scrap, enigo, clipboard, …
├── Cargo.toml hello-agent package manifest, path-deps on vendor
├── ci/ provision scripts for the Gitea Windows-build /
│ Linux-signing self-hosted runners
├── .gitea/workflows/ Gitea CI
└── README.md
```
The vendored source has a few local divergences from upstream — all
documented inline at the patch site so they're easy to spot when
re-syncing:
The vendored source has a handful of local divergences from upstream.
Most patch sites carry an inline `hello-agent` breadcrumb (search for
`grep -rn "hello-agent" vendor/rustdesk/` in a tree where the patches
have been applied) but a couple of one-token edits like
`pub mod custom_server` don't, so the list below is the authoritative
inventory — keep it in sync when adding new patches.
1. [`vendor/rustdesk/src/lib.rs`](vendor/rustdesk/src/lib.rs):
`mod custom_server``pub mod custom_server` so hello-agent can call
the deploy-blob decoder.
2. [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
1. **Module visibility**
[`vendor/rustdesk/src/lib.rs`](vendor/rustdesk/src/lib.rs):
* `mod custom_server``pub mod custom_server` so hello-agent's
`config_import` can call the deploy-blob decoder.
* `mod ui_cm_interface``pub mod ui_cm_interface` so the headless
`--cm` process can plug a `MessageBoxW`-based `InvokeUiCM` into
upstream's connection-manager IPC loop and inherit file-transfer,
chat, and clipboard handling rather than re-implementing them.
2. **Build shape** — [`vendor/rustdesk/Cargo.toml`](vendor/rustdesk/Cargo.toml):
`[lib] crate-type` reduced from `["cdylib", "staticlib", "rlib"]` to
`["rlib"]`. We statically link the rlib into hello-agent.exe; the
cdylib link step (used by upstream for Flutter FFI) trips
`LNK1169 multiply-defined symbols` from overlapping
windows-targets/windows_x86_64_msvc versions and we don't need it.
3. Heartbeat intervals lowered 15s → 1s so device-online status in the
admin UI reacts faster:
[`vendor/rustdesk/libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs)
3. **Heartbeat cadence** lowered 15s → 1s so device-online status in
the admin UI reacts faster:
[`libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs)
(`REG_INTERVAL`, UDP rendezvous re-register) and
[`vendor/rustdesk/src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
[`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
(`TIME_HEARTBEAT`, HTTP `/api/heartbeat`).
4. **Sysinfo upload extensions**
[`libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs)
adds three opt-in `RwLock<String>` globals — `AGENT_NAME`,
`AGENT_VERSION`, `INVENTORY` — that hello-agent populates at startup
(rebrand identity) and asynchronously (CMDB inventory).
[`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs)
reads them when each `/api/sysinfo` payload is constructed and
tracks `had_inventory` on the `InfoUploaded` state so the loop
re-uploads when `INVENTORY` transitions empty → populated (the
collector is async and routinely loses the race against the first
sysinfo tick).
5. **Documentation / branding URLs** retargeted from `rustdesk.com` to
`cstudio.ch/hello-agent/`:
[`libs/hbb_common/src/config.rs`](vendor/rustdesk/libs/hbb_common/src/config.rs)
(`LINK_DOCS_HOME`, `LINK_DOCS_X11_REQUIRED`),
[`src/client.rs`](vendor/rustdesk/src/client.rs) (`SCRAP_X11_REF_URL`,
login-screen help link). Plus author / copyright strings in
[`Cargo.toml`](vendor/rustdesk/Cargo.toml) (`LegalCopyright`) and
[`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`).
Cosmetic, but they show through in the Windows EXE metadata and
in-app error dialogs.
## Build
@@ -138,8 +179,13 @@ To pull updates from upstream RustDesk:
1. Sync the upstream rustdesk repo locally and `git submodule update --init` for `libs/hbb_common`.
2. `rsync -a --delete --exclude=.git --exclude=target --exclude=flutter --exclude=appimage … upstream-rustdesk/ vendor/rustdesk/`
3. Re-apply the one-line `pub mod custom_server` patch in
[`vendor/rustdesk/src/lib.rs`](vendor/rustdesk/src/lib.rs).
3. Re-apply the local divergences enumerated in the
[Repo layout](#repo-layout) section above (that list is the source
of truth — most patches carry a `hello-agent` breadcrumb in a
nearby comment, but a couple of one-token edits like
`pub mod custom_server` do not). The reliable recipe is to diff
against the upstream rev you rsync'd from before you do the rsync,
stash the patch hunks, then re-apply them on top of the new tree.
4. `cargo build --release --bin hello-agent` — fix any breakage from
upstream API drift in our [src/](src/) modules.
@@ -243,7 +289,22 @@ it duplicates info that's already in the main log).
The only residual contention is the optional direct-server port
(TCP 21118) and LAN-discovery port (UDP 21119); both default to off,
so a vanilla install of each side can run simultaneously.
- ✅ CMDB asset inventory — BIOS serial, manufacturer, model, AD domain,
OS edition + release, CPU, RAM, disks, BitLocker recovery key,
network interfaces, public IP, current + nearby Wi-Fi networks.
Collected once at startup ([`src/inventory.rs`](src/inventory.rs),
[`src/wifi_native.rs`](src/wifi_native.rs)) and merged into the
`/api/sysinfo` payload under the `inventory` key, where the
rustdesk-server admin UI's per-device detail page reads it.
- ✅ Unattended-access password — rotated once per service start
([`src/unattended_password.rs`](src/unattended_password.rs)) and
reported to rustdesk-server's `/api/unattended-password` so the admin
UI can show the current per-boot password for headless /
no-user-logged-in support sessions.
- ✅ Authenticode code signing in CI — separate Linux signing job runs
`osslsigncode` against the cStudio CA, with pre-/post-sign SHA-256
audit hashes that catch a tampered transit between build and sign
runners ([`.gitea/workflows/build-windows.yml`](.gitea/workflows/build-windows.yml)).
- ⏳ Linux / macOS (out of scope for v0)
- ⏳ Code signing (CI warns, doesn't sign)
- ⏳ Multiple simultaneous interactive users (only one can receive the
approval popup at a time — the one in the `WTSActive` session)
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# Provisions an Ubuntu 22.04 LTS or Debian 13 (Trixie) host as a Gitea Actions
# runner for RustDesk desktop (.deb) builds. Idempotent: safe to re-run.
#
# Versions are pinned to .gitea/workflows/build-linux.yml. Bump them there and
# here together.
#
# Build host vs. user host: the resulting .deb links against the host's glibc.
# Build on the OLDEST distro your users have, otherwise the .deb won't install
# on older systems.
# - Ubuntu 22.04 build -> runs on Ubuntu 22.04+, Debian 12+, derivatives
# - Debian 13 build -> runs on Debian 13+, Ubuntu 24.04+ only
#
# Usage:
# sudo ./provision.sh \
# --gitea-url https://gitea.example.com \
# --runner-token <token>
#
# All toolchains land in /opt and are readable by the gitea-runner user.
# Service is installed as a systemd unit running as that user.
set -euo pipefail
# ---- pinned versions (mirror .gitea/workflows/build-linux.yml env block) ----
RUST_VERSION="1.75.0"
FLUTTER_VERSION="3.24.5" # used for `flutter build linux`
FLUTTER_BRIDGE_VERSION="3.22.3" # used for `flutter pub get` + flutter_rust_bridge_codegen
LLVM_VERSION="15.0.6"
VCPKG_COMMIT="120deac3062162151622ca4860575a33844ba10b"
RUNNER_VERSION="0.2.11"
# ---- defaults ----
RUNNER_NAME="$(hostname)-rustdesk"
RUNNER_LABELS="" # auto-derived from /etc/os-release if empty
SERVICE_USER="gitea-runner"
GITEA_URL=""
RUNNER_TOKEN=""
# ---- arg parse ----
while [[ $# -gt 0 ]]; do
case "$1" in
--gitea-url) GITEA_URL="$2"; shift 2 ;;
--runner-token) RUNNER_TOKEN="$2"; shift 2 ;;
--runner-name) RUNNER_NAME="$2"; shift 2 ;;
--runner-labels) RUNNER_LABELS="$2"; shift 2 ;;
--service-user) SERVICE_USER="$2"; shift 2 ;;
-h|--help)
sed -n '2,18p' "$0"
exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
[[ "$EUID" -eq 0 ]] || { echo "Run as root (use sudo)." >&2; exit 1; }
[[ -n "$GITEA_URL" && -n "$RUNNER_TOKEN" ]] \
|| { echo "Missing --gitea-url or --runner-token" >&2; exit 2; }
. /etc/os-release
case "${ID}-${VERSION_ID:-}" in
ubuntu-22.04) DISTRO_LABEL="ubuntu-22.04" ;;
debian-13|debian-trixie) DISTRO_LABEL="debian-13" ;;
*)
echo "WARNING: tested only on Ubuntu 22.04 and Debian 13. You're on $PRETTY_NAME."
echo "Package names may differ; build outputs may not run on user systems."
DISTRO_LABEL="${ID}-${VERSION_ID:-unknown}"
sleep 3 ;;
esac
# If --runner-labels wasn't passed, derive a sensible default that includes the
# detected distro so workflows can target a specific build host when needed.
if [[ -z "$RUNNER_LABELS" ]]; then
RUNNER_LABELS="${DISTRO_LABEL},self-hosted,X64,Linux"
fi
log() { printf '\n==> %s\n' "$*"; }
# ---- 1. apt packages ----
log "Installing apt packages"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends \
build-essential clang gcc g++ cmake ninja-build pkg-config nasm yasm \
autoconf automake libtool libtool-bin \
libclang-dev llvm-dev \
libgtk-3-dev libayatana-appindicator3-dev \
libasound2-dev libpulse-dev libpam0g-dev libssl-dev \
libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev \
libva-dev libxdo-dev libxfixes-dev \
libxcb-randr0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
git curl wget zip unzip tar xz-utils ca-certificates \
python3 python3-pip \
rpm tree dpkg-dev sudo
# Node.js (act_runner spawns node for JS actions like actions/checkout)
if ! command -v node >/dev/null; then
log "Installing Node.js LTS"
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y --no-install-recommends nodejs
fi
# ---- 2. LLVM (binary tarball; libclang-15-dev was dropped from Debian 13) ----
LLVM_DIR="/opt/llvm-${LLVM_VERSION}"
if [[ ! -x "$LLVM_DIR/bin/clang" ]]; then
log "Installing LLVM $LLVM_VERSION (binary tarball)"
arch="$(uname -m)"
case "$arch" in
x86_64) llvm_arch="x86_64-linux-gnu-ubuntu-18.04" ;;
aarch64) llvm_arch="aarch64-linux-gnu" ;;
*) echo "Unsupported arch for LLVM tarball: $arch" >&2; exit 1 ;;
esac
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/llvm.tar.xz" \
"https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/clang+llvm-${LLVM_VERSION}-${llvm_arch}.tar.xz"
mkdir -p "$LLVM_DIR"
tar --strip-components=1 -xJf "$tmp/llvm.tar.xz" -C "$LLVM_DIR"
rm -rf "$tmp"
fi
# ---- 3. dedicated runner user ----
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating user $SERVICE_USER"
useradd --system --create-home --shell /bin/bash "$SERVICE_USER"
fi
RUNNER_HOME="$(getent passwd "$SERVICE_USER" | cut -d: -f6)"
# ---- 4. Rust (machine-wide) ----
export RUSTUP_HOME=/opt/rustup
export CARGO_HOME=/opt/cargo
mkdir -p "$RUSTUP_HOME" "$CARGO_HOME"
if [[ ! -x "$CARGO_HOME/bin/rustup" ]]; then
log "Installing rustup at $RUSTUP_HOME / $CARGO_HOME"
curl -fsSL https://sh.rustup.rs | RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" \
sh -s -- -y --default-toolchain none --profile minimal --no-modify-path
fi
"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt
"$CARGO_HOME/bin/rustup" target add --toolchain "$RUST_VERSION" x86_64-unknown-linux-gnu
"$CARGO_HOME/bin/rustup" default "$RUST_VERSION"
# ---- 5. Flutter (two SDKs: 3.24.5 for build, 3.22.3 for bridge gen) ----
# Why two: the bridge codegen (flutter_rust_bridge_codegen 1.80.1 + freezed)
# produces broken Dart output when run under newer Flutter SDKs on Linux.
# Upstream's bridge.yml uses 3.22.3 specifically; we mirror that. The .deb
# build itself uses 3.24.5.
install_flutter() {
local ver="$1" dir="$2"
if [[ ! -x "$dir/bin/flutter" ]]; then
log "Installing Flutter $ver -> $dir"
local tmp; tmp="$(mktemp -d)"
local parent; parent="$(dirname "$dir")"
curl -fsSL -o "$tmp/flutter.tar.xz" \
"https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${ver}-stable.tar.xz"
# Tarball extracts into a top-level `flutter/` dir; rename to target.
tar -xJf "$tmp/flutter.tar.xz" -C "$tmp"
mkdir -p "$parent"
mv "$tmp/flutter" "$dir"
rm -rf "$tmp"
fi
"$dir/bin/flutter" config --no-analytics >/dev/null
"$dir/bin/flutter" precache --linux >/dev/null
}
install_flutter "$FLUTTER_VERSION" /opt/flutter
install_flutter "$FLUTTER_BRIDGE_VERSION" /opt/flutter-bridge
FLUTTER_DIR=/opt/flutter
# ---- 6. vcpkg ----
VCPKG_DIR=/opt/vcpkg
if [[ ! -d "$VCPKG_DIR/.git" ]]; then
log "Cloning vcpkg"
git clone https://github.com/microsoft/vcpkg.git "$VCPKG_DIR"
fi
git -C "$VCPKG_DIR" fetch --tags origin
git -C "$VCPKG_DIR" -c advice.detachedHead=false checkout "$VCPKG_COMMIT"
[[ -x "$VCPKG_DIR/vcpkg" ]] || "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics
# vcpkg binary cache (file-backed -- same scheme as build-windows.yml)
mkdir -p /var/cache/vcpkg
chown -R "$SERVICE_USER:$SERVICE_USER" /var/cache/vcpkg
# ---- 7. Permissions ----
log "Setting up permissions for $SERVICE_USER"
chown -R "$SERVICE_USER:$SERVICE_USER" "$CARGO_HOME"
# rustup state needs to be writable too -- toolchain installs touch it.
chown -R "$SERVICE_USER:$SERVICE_USER" "$RUSTUP_HOME"
# Flutter SDK: r/x is enough for builds, but `flutter pub get` writes to its
# own cache subdir so we make it writable as well.
chown -R "$SERVICE_USER:$SERVICE_USER" "$FLUTTER_DIR"
chown -R "$SERVICE_USER:$SERVICE_USER" /opt/flutter-bridge
# vcpkg: builds write under installed/, buildtrees/, etc.
chown -R "$SERVICE_USER:$SERVICE_USER" "$VCPKG_DIR"
# LLVM: read+execute is enough; we never write here at build time.
chown -R "$SERVICE_USER:$SERVICE_USER" "$LLVM_DIR"
# /opt/cargo-tools: workflow installs cargo-expand and flutter_rust_bridge_codegen
# here via `cargo install --root`. Pre-create with the right owner so the first
# job doesn't try to mkdir under root-owned /opt.
mkdir -p /opt/cargo-tools
chown -R "$SERVICE_USER:$SERVICE_USER" /opt/cargo-tools
# git "dubious ownership": same fix as Windows. Trust system-wide.
git config --system --add safe.directory '*' || true
# ---- 8. act_runner ----
RUNNER_DIR=/var/lib/gitea-runner
mkdir -p "$RUNNER_DIR"
chown -R "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR"
if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then
log "Downloading act_runner $RUNNER_VERSION"
curl -fsSL -o "$RUNNER_DIR/act_runner" \
"https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-linux-amd64"
chmod +x "$RUNNER_DIR/act_runner"
chown "$SERVICE_USER:$SERVICE_USER" "$RUNNER_DIR/act_runner"
fi
if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
log "Registering runner with $GITEA_URL"
sudo -u "$SERVICE_USER" -H bash -c "
cd '$RUNNER_DIR' && \
./act_runner register --no-interactive \
--instance '$GITEA_URL' \
--token '$RUNNER_TOKEN' \
--name '$RUNNER_NAME' \
--labels '$RUNNER_LABELS'
"
fi
# ---- 9. systemd service ----
log "Installing systemd unit"
cat > /etc/systemd/system/gitea-act-runner.service <<EOF
[Unit]
Description=Gitea Actions runner (RustDesk)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
WorkingDirectory=${RUNNER_DIR}
ExecStart=${RUNNER_DIR}/act_runner daemon
Restart=on-failure
RestartSec=5
# Toolchain locations -- needed because services don't inherit a login shell's PATH.
Environment=RUSTUP_HOME=${RUSTUP_HOME}
Environment=CARGO_HOME=${CARGO_HOME}
Environment=VCPKG_ROOT=${VCPKG_DIR}
Environment=LIBCLANG_PATH=${LLVM_DIR}/lib
Environment=PATH=${CARGO_HOME}/bin:${FLUTTER_DIR}/bin:${LLVM_DIR}/bin:${VCPKG_DIR}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# Resource limits for builds
LimitNOFILE=65535
TasksMax=infinity
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable gitea-act-runner.service
systemctl restart gitea-act-runner.service
log "Done."
echo " Verify with: systemctl status gitea-act-runner"
echo " Tail logs with: journalctl -u gitea-act-runner -f"
echo " Runner should appear (online) at $GITEA_URL > Site Admin > Actions > Runners"
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env bash
# Provisions a macOS host (Apple Silicon, macOS 14+) as a Gitea Actions runner
# for RustDesk desktop (.dmg) builds. Idempotent: safe to re-run.
#
# Versions are pinned to .gitea/workflows/build-macos.yml. Bump them there and
# here together.
#
# Usage:
# sudo ./provision.sh \
# --gitea-url https://gitea.example.com \
# --runner-token <token>
#
# Toolchains land in /opt/* (chowned to the runner user). Service is installed
# as a LaunchDaemon running as that user.
set -euo pipefail
# ---- pinned versions (mirror .gitea/workflows/build-macos.yml env block) ----
RUST_VERSION="1.81.0" # MAC_RUST_VERSION upstream (cidre crate needs >=1.81)
FLUTTER_VERSION="3.24.5" # used for `flutter build macos`
FLUTTER_BRIDGE_VERSION="3.22.3" # used for `flutter pub get` + flutter_rust_bridge_codegen
VCPKG_COMMIT="120deac3062162151622ca4860575a33844ba10b"
NASM_VERSION="2.16.03" # 3.x has incompatible CLI; aom/dav1d need 2.x
RUNNER_VERSION="0.2.11"
# ---- defaults ----
RUNNER_NAME="$(hostname -s)-rustdesk"
RUNNER_LABELS=""
SERVICE_USER="gitea-runner"
GITEA_URL=""
RUNNER_TOKEN=""
# ---- arg parse ----
while [[ $# -gt 0 ]]; do
case "$1" in
--gitea-url) GITEA_URL="$2"; shift 2 ;;
--runner-token) RUNNER_TOKEN="$2"; shift 2 ;;
--runner-name) RUNNER_NAME="$2"; shift 2 ;;
--runner-labels) RUNNER_LABELS="$2"; shift 2 ;;
--service-user) SERVICE_USER="$2"; shift 2 ;;
-h|--help)
sed -n '2,15p' "$0"
exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
[[ "$EUID" -eq 0 ]] || { echo "Run as root (use sudo)." >&2; exit 1; }
[[ -n "$GITEA_URL" && -n "$RUNNER_TOKEN" ]] \
|| { echo "Missing --gitea-url or --runner-token" >&2; exit 2; }
# ---- arch + macOS version detection ----
ARCH="$(uname -m)"
case "$ARCH" in
arm64) HOMEBREW_PREFIX="/opt/homebrew"; ARCH_LABEL="ARM64" ;;
x86_64) HOMEBREW_PREFIX="/usr/local"; ARCH_LABEL="X64" ;;
*) echo "Unsupported arch: $ARCH" >&2; exit 1 ;;
esac
OS_MAJOR="$(sw_vers -productVersion | cut -d. -f1)"
[[ "$OS_MAJOR" -ge 14 ]] || {
echo "WARNING: tested only on macOS 14+. You're on $(sw_vers -productVersion)."
sleep 3
}
DISTRO_LABEL="macos-${OS_MAJOR}"
if [[ -z "$RUNNER_LABELS" ]]; then
RUNNER_LABELS="${DISTRO_LABEL},self-hosted,${ARCH_LABEL},macOS"
fi
log() { printf '\n==> %s\n' "$*"; }
# ---- 1. Xcode Command Line Tools ----
log "Verifying Xcode Command Line Tools"
if ! /usr/bin/xcode-select -p >/dev/null 2>&1; then
echo "Xcode Command Line Tools not installed. Run:" >&2
echo " xcode-select --install" >&2
echo "Then re-run this script." >&2
exit 1
fi
echo " $(xcode-select -p)"
# ---- 2. Homebrew (machine-wide) ----
# Homebrew refuses to install under root (its installer aborts with
# "Don't run this as root!"). It must be installed manually by a regular
# user before this script runs.
log "Verifying Homebrew"
if [[ ! -x "$HOMEBREW_PREFIX/bin/brew" ]]; then
echo "Homebrew not installed at $HOMEBREW_PREFIX." >&2
echo "Install it as your regular user (NOT root), then re-run this script:" >&2
echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" >&2
exit 1
fi
export PATH="$HOMEBREW_PREFIX/bin:$PATH"
echo " $(brew --version | head -1)"
# brew install must also run as a non-root user. Determine which user invoked
# sudo so we can drop privileges for brew commands below.
BREW_USER="${SUDO_USER:-}"
if [[ -z "$BREW_USER" || "$BREW_USER" == "root" ]]; then
echo "Could not determine the non-root user that ran sudo (SUDO_USER unset)." >&2
echo "Re-run with: sudo ./provision.sh ..." >&2
exit 1
fi
brew_as_user() { sudo -u "$BREW_USER" -H "$HOMEBREW_PREFIX/bin/brew" "$@"; }
# ---- 3. brew packages ----
log "Installing brew packages"
brew_pkgs=(node cocoapods llvm create-dmg pkg-config cmake ninja yasm autoconf automake libtool wget)
for p in "${brew_pkgs[@]}"; do
if brew_as_user list --versions "$p" >/dev/null 2>&1; then
echo " $p (already installed)"
else
brew_as_user install "$p"
fi
done
# ---- 4. NASM 2.16.x (NOT brew's nasm 3.x; aom/dav1d need 2.x) ----
if ! /usr/local/bin/nasm --version 2>/dev/null | grep -q "version $NASM_VERSION"; then
log "Installing NASM $NASM_VERSION"
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nasm.zip" \
"https://www.nasm.us/pub/nasm/releasebuilds/${NASM_VERSION}/macosx/nasm-${NASM_VERSION}-macosx.zip"
unzip -q "$tmp/nasm.zip" -d "$tmp"
install -m 0755 "$tmp/nasm-${NASM_VERSION}/nasm" /usr/local/bin/nasm
rm -rf "$tmp"
fi
/usr/local/bin/nasm --version | head -1
# ---- 5. dedicated runner user ----
if ! /usr/bin/id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "Creating user $SERVICE_USER"
# Find an unused UID >= 600
uid=600
while dscl . -list /Users UniqueID | awk '{print $2}' | grep -qx "$uid"; do
uid=$((uid + 1))
done
dscl . -create "/Users/$SERVICE_USER"
dscl . -create "/Users/$SERVICE_USER" UserShell /bin/bash
dscl . -create "/Users/$SERVICE_USER" RealName "Gitea Runner"
dscl . -create "/Users/$SERVICE_USER" UniqueID "$uid"
dscl . -create "/Users/$SERVICE_USER" PrimaryGroupID 20
dscl . -create "/Users/$SERVICE_USER" NFSHomeDirectory "/Users/$SERVICE_USER"
mkdir -p "/Users/$SERVICE_USER"
chown "$SERVICE_USER:staff" "/Users/$SERVICE_USER"
fi
RUNNER_HOME="/Users/$SERVICE_USER"
# ---- 6. Rust (machine-wide) ----
export RUSTUP_HOME=/opt/rustup
export CARGO_HOME=/opt/cargo
mkdir -p "$RUSTUP_HOME" "$CARGO_HOME"
if [[ ! -x "$CARGO_HOME/bin/rustup" ]]; then
log "Installing rustup at $RUSTUP_HOME / $CARGO_HOME"
curl -fsSL https://sh.rustup.rs | RUSTUP_HOME="$RUSTUP_HOME" CARGO_HOME="$CARGO_HOME" \
sh -s -- -y --default-toolchain none --profile minimal --no-modify-path
fi
"$CARGO_HOME/bin/rustup" toolchain install "$RUST_VERSION" --profile minimal --component rustfmt
"$CARGO_HOME/bin/rustup" target add --toolchain "$RUST_VERSION" aarch64-apple-darwin x86_64-apple-darwin
"$CARGO_HOME/bin/rustup" default "$RUST_VERSION"
# ---- 7. Flutter (two SDKs: 3.24.5 for build, 3.22.3 for bridge gen) ----
# Same rationale as Linux: bridge codegen 1.80.1 + freezed produces broken Dart
# under newer Flutter. Run codegen under 3.22.3, build under 3.24.5.
install_flutter() {
local ver="$1" dir="$2"
if [[ ! -x "$dir/bin/flutter" ]]; then
log "Installing Flutter $ver -> $dir"
local tmp; tmp="$(mktemp -d)"
local parent; parent="$(dirname "$dir")"
# Flutter URL pattern differs between archs: Apple Silicon has an
# `_arm64_` infix, Intel has no arch infix at all.
local flutter_url_base="https://storage.googleapis.com/flutter_infra_release/releases/stable/macos/flutter_macos"
local flutter_url
case "$ARCH" in
arm64) flutter_url="${flutter_url_base}_arm64_${ver}-stable.zip" ;;
x86_64) flutter_url="${flutter_url_base}_${ver}-stable.zip" ;;
esac
curl -fsSL -o "$tmp/flutter.zip" "$flutter_url"
mkdir -p "$parent"
unzip -q "$tmp/flutter.zip" -d "$tmp"
mv "$tmp/flutter" "$dir"
rm -rf "$tmp"
fi
"$dir/bin/flutter" config --no-analytics >/dev/null
"$dir/bin/flutter" precache --macos >/dev/null
}
install_flutter "$FLUTTER_VERSION" /opt/flutter
install_flutter "$FLUTTER_BRIDGE_VERSION" /opt/flutter-bridge
# ---- 8. vcpkg ----
VCPKG_DIR=/opt/vcpkg
if [[ ! -d "$VCPKG_DIR/.git" ]]; then
log "Cloning vcpkg"
git clone https://github.com/microsoft/vcpkg.git "$VCPKG_DIR"
fi
git -C "$VCPKG_DIR" fetch --tags origin
git -C "$VCPKG_DIR" -c advice.detachedHead=false checkout "$VCPKG_COMMIT"
[[ -x "$VCPKG_DIR/vcpkg" ]] || "$VCPKG_DIR/bootstrap-vcpkg.sh" -disableMetrics
mkdir -p /var/cache/vcpkg
chown -R "$SERVICE_USER:staff" /var/cache/vcpkg
# ---- 9. Permissions ----
log "Setting up permissions for $SERVICE_USER"
chown -R "$SERVICE_USER:staff" "$CARGO_HOME" "$RUSTUP_HOME" \
/opt/flutter /opt/flutter-bridge "$VCPKG_DIR"
mkdir -p /opt/cargo-tools
chown -R "$SERVICE_USER:staff" /opt/cargo-tools
git config --system --add safe.directory '*' || true
# ---- 10. act_runner ----
RUNNER_DIR="/usr/local/var/gitea-runner"
mkdir -p "$RUNNER_DIR"
chown -R "$SERVICE_USER:staff" "$RUNNER_DIR"
if [[ ! -x "$RUNNER_DIR/act_runner" ]]; then
log "Downloading act_runner $RUNNER_VERSION"
case "$ARCH" in
arm64) rarch="arm64" ;;
x86_64) rarch="amd64" ;;
esac
curl -fsSL -o "$RUNNER_DIR/act_runner" \
"https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-darwin-${rarch}"
chmod +x "$RUNNER_DIR/act_runner"
chown "$SERVICE_USER:staff" "$RUNNER_DIR/act_runner"
fi
if [[ ! -f "$RUNNER_DIR/.runner" ]]; then
log "Registering runner with $GITEA_URL"
sudo -u "$SERVICE_USER" -H bash -c "
cd '$RUNNER_DIR' && \
./act_runner register --no-interactive \
--instance '$GITEA_URL' \
--token '$RUNNER_TOKEN' \
--name '$RUNNER_NAME' \
--labels '$RUNNER_LABELS'
"
fi
# ---- 11. launchd service ----
log "Installing LaunchDaemon"
PLIST=/Library/LaunchDaemons/com.rustdesk.gitea-runner.plist
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.rustdesk.gitea-runner</string>
<key>UserName</key>
<string>${SERVICE_USER}</string>
<key>WorkingDirectory</key>
<string>${RUNNER_DIR}</string>
<key>ProgramArguments</key>
<array>
<string>${RUNNER_DIR}/act_runner</string>
<string>daemon</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>RUSTUP_HOME</key> <string>${RUSTUP_HOME}</string>
<key>CARGO_HOME</key> <string>${CARGO_HOME}</string>
<key>VCPKG_ROOT</key> <string>${VCPKG_DIR}</string>
<key>HOMEBREW_PREFIX</key> <string>${HOMEBREW_PREFIX}</string>
<key>PATH</key>
<string>${CARGO_HOME}/bin:/opt/flutter/bin:/opt/cargo-tools/bin:${HOMEBREW_PREFIX}/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key><string>${RUNNER_DIR}/stdout.log</string>
<key>StandardErrorPath</key><string>${RUNNER_DIR}/stderr.log</string>
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key> <integer>65535</integer>
</dict>
</dict>
</plist>
EOF
chmod 0644 "$PLIST"
launchctl bootout system "$PLIST" 2>/dev/null || true
launchctl bootstrap system "$PLIST"
launchctl enable "system/com.rustdesk.gitea-runner"
log "Done."
echo " Verify with: sudo launchctl print system/com.rustdesk.gitea-runner | head"
echo " Tail logs with: tail -F $RUNNER_DIR/stderr.log"
echo " Runner should appear (online) at $GITEA_URL > Site Admin > Actions > Runners"
+324
View File
@@ -0,0 +1,324 @@
# Provisions a Windows host (Windows 10/11 or Server 2019+) as a Gitea Actions
# runner for RustDesk desktop builds. Idempotent: safe to re-run.
#
# Versions are pinned to .gitea/workflows/build-windows.yml. Bump them there and
# here together.
#
# Usage (Administrator PowerShell):
# Set-ExecutionPolicy -Scope Process Bypass -Force
# .\provision.ps1 -GiteaUrl https://gitea.example.com -RunnerToken <token>
#
# By default the runner service is created under a dedicated local user
# (`gitea-runner`) -- LocalSystem has been observed to break flutter pub get,
# symlink creation, and git's "dubious ownership" check on this codebase. To
# opt out, pass `-ServiceAccount LocalSystem` (not recommended).
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)] [string] $GiteaUrl,
[Parameter(Mandatory = $true)] [string] $RunnerToken,
[string] $RunnerName = "$env:COMPUTERNAME-rustdesk",
[string] $RunnerLabels = "windows-10,self-hosted,X64",
[string] $RunnerVersion = "0.2.11",
[string] $ServiceAccount = "gitea-runner",
[SecureString] $ServiceAccountPassword
)
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
# Must run elevated -- nearly every step writes Machine env, HKLM, or service config.
$me = [Security.Principal.WindowsIdentity]::GetCurrent()
if (-not (New-Object Security.Principal.WindowsPrincipal $me).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this script in an elevated (Administrator) PowerShell session.'
}
# --- pinned versions (mirror .gitea/workflows/build-windows.yml env block) ---
$RUST_VERSION = '1.75.0'
$RUST_NIGHTLY = 'nightly-2023-10-13'
$LLVM_VERSION = '15.0.6'
$FLUTTER_VERSION = '3.24.5'
$VCPKG_COMMIT = '120deac3062162151622ca4860575a33844ba10b'
$ToolsRoot = 'C:\tools'
New-Item -ItemType Directory -Force -Path $ToolsRoot | Out-Null
# Exact-segment-match version of PATH augmentation. Substring matching would
# falsely find C:\bin when C:\binaries is on PATH.
function Add-MachinePath([string]$Dir) {
$cur = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$segments = $cur -split ';' | Where-Object { $_ }
if ($segments -notcontains $Dir) {
[Environment]::SetEnvironmentVariable('Path', "$cur;$Dir", 'Machine')
}
if (($env:Path -split ';') -notcontains $Dir) { $env:Path = "$env:Path;$Dir" }
}
# --- 1. Chocolatey (used for git, python, nuget, 7zip, node, dotnet, ...) ---
if (-not (Get-Command choco -ErrorAction SilentlyContinue)) {
Write-Host '==> Installing Chocolatey'
Set-ExecutionPolicy Bypass -Scope Process -Force
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
}
Write-Host '==> Installing base packages'
# nodejs-lts: act_runner spawns Node to execute JavaScript actions.
# powershell-core: workflows use `shell: pwsh` (PS 7), not the OS's PS 5.1.
# dotnet-sdk: WiX 4 SDK-style projects (.wixproj) need it for the MSI build.
choco install -y --no-progress `
git python311 nuget.commandline 7zip cmake ninja `
nodejs-lts powershell-core dotnet-sdk
Add-MachinePath 'C:\Program Files\Git\cmd'
Add-MachinePath 'C:\Program Files\Git\bin' # bash.exe + posix tools (sed, find, ...)
Add-MachinePath 'C:\Python311'
Add-MachinePath 'C:\Python311\Scripts'
Add-MachinePath 'C:\Program Files\nodejs'
Add-MachinePath 'C:\Program Files\PowerShell\7'
Add-MachinePath 'C:\Program Files\dotnet'
# --- 2. Visual Studio 2022 Build Tools (MSVC v143 + Win10 SDK) ---
# Use [Environment]::GetEnvironmentVariable to avoid the PowerShell parser quirk
# that mis-tokenises `$env:ProgramFiles(x86)` as `$env:ProgramFiles` + `(x86)`.
$pfx86 = [Environment]::GetEnvironmentVariable('ProgramFiles(x86)')
$vsInstaller = Join-Path $pfx86 'Microsoft Visual Studio\Installer\vswhere.exe'
$vsPresent = (Test-Path $vsInstaller) -and `
((& $vsInstaller -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath) -ne $null)
if (-not $vsPresent) {
Write-Host '==> Installing VS 2022 Build Tools (this takes a while)'
$vsBootstrapper = "$env:TEMP\vs_buildtools.exe"
Invoke-WebRequest -Uri 'https://aka.ms/vs/17/release/vs_buildtools.exe' -OutFile $vsBootstrapper
$vsArgs = @(
'--quiet','--wait','--norestart','--nocache',
'--add','Microsoft.VisualStudio.Workload.VCTools',
'--add','Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
'--add','Microsoft.VisualStudio.Component.VC.ATL',
'--add','Microsoft.VisualStudio.Component.Windows10SDK.20348',
'--add','Microsoft.VisualStudio.Component.VC.CMake.Project',
'--includeRecommended'
)
$p = Start-Process -FilePath $vsBootstrapper -ArgumentList $vsArgs -Wait -PassThru
if ($p.ExitCode -notin 0,3010) { throw "VS Build Tools installer exit $($p.ExitCode)" }
}
# --- 3. Rust (stable + nightly with i686 target) ---
# Install machine-wide so any user (including the dedicated runner account)
# shares one toolchain registry. Without this, rustup state lives in the
# installing user's profile and the service user has no default toolchain.
$rustupHome = 'C:\rustup'
$cargoHome = 'C:\cargo'
[Environment]::SetEnvironmentVariable('RUSTUP_HOME', $rustupHome, 'Machine')
[Environment]::SetEnvironmentVariable('CARGO_HOME', $cargoHome, 'Machine')
$env:RUSTUP_HOME = $rustupHome
$env:CARGO_HOME = $cargoHome
Add-MachinePath "$cargoHome\bin"
if (-not (Test-Path "$cargoHome\bin\rustup.exe")) {
Write-Host '==> Installing rustup (machine-wide at C:\rustup, C:\cargo)'
Invoke-WebRequest -Uri 'https://win.rustup.rs/x86_64' -OutFile "$env:TEMP\rustup-init.exe"
& "$env:TEMP\rustup-init.exe" -y --default-toolchain none --profile minimal
}
rustup toolchain install $RUST_VERSION --profile minimal --component rustfmt
rustup target add --toolchain $RUST_VERSION x86_64-pc-windows-msvc
rustup toolchain install $RUST_NIGHTLY --profile minimal --component rustfmt
rustup target add --toolchain $RUST_NIGHTLY i686-pc-windows-msvc
rustup default $RUST_VERSION
# --- 4. LLVM/Clang (matches KyleMayes/install-llvm-action layout) ---
$llvmDir = "$ToolsRoot\llvm-$LLVM_VERSION"
if (-not (Test-Path "$llvmDir\bin\clang.exe")) {
Write-Host "==> Installing LLVM $LLVM_VERSION"
$llvmExe = "$env:TEMP\LLVM-$LLVM_VERSION-win64.exe"
Invoke-WebRequest -Uri "https://github.com/llvm/llvm-project/releases/download/llvmorg-$LLVM_VERSION/LLVM-$LLVM_VERSION-win64.exe" -OutFile $llvmExe
& $llvmExe /S "/D=$llvmDir" | Out-Null
}
[Environment]::SetEnvironmentVariable('LIBCLANG_PATH', "$llvmDir\bin", 'Machine')
Add-MachinePath "$llvmDir\bin"
# --- 5. Flutter (stable channel, with windows precache) ---
$flutterDir = "$ToolsRoot\flutter"
if (-not (Test-Path "$flutterDir\bin\flutter.bat")) {
Write-Host "==> Installing Flutter $FLUTTER_VERSION"
$flutterZip = "$env:TEMP\flutter.zip"
Invoke-WebRequest -Uri "https://storage.googleapis.com/flutter_infra_release/releases/stable/windows/flutter_windows_$FLUTTER_VERSION-stable.zip" -OutFile $flutterZip
Expand-Archive -Force -Path $flutterZip -DestinationPath $ToolsRoot
}
Add-MachinePath "$flutterDir\bin"
& "$flutterDir\bin\flutter.bat" config --no-analytics | Out-Null
& "$flutterDir\bin\flutter.bat" precache --windows | Out-Null
# --- 6. vcpkg pinned to commit ---
$vcpkgDir = 'C:\vcpkg'
if (-not (Test-Path "$vcpkgDir\.git")) {
Write-Host '==> Cloning vcpkg'
git clone https://github.com/microsoft/vcpkg.git $vcpkgDir
}
Push-Location $vcpkgDir
git fetch --tags origin
git -c advice.detachedHead=false checkout $VCPKG_COMMIT
if (-not (Test-Path "$vcpkgDir\vcpkg.exe")) { & "$vcpkgDir\bootstrap-vcpkg.bat" -disableMetrics }
Pop-Location
[Environment]::SetEnvironmentVariable('VCPKG_ROOT', $vcpkgDir, 'Machine')
Add-MachinePath $vcpkgDir
# --- 7. CI prerequisites that aren't tools, but environmental switches ---
# git's "dubious ownership" check (>= 2.35.2) refuses to operate on a repo whose
# .git directory is owned by a different user than the one running git. The
# Flutter SDK at C:\tools\flutter is provisioned by this script as Administrator
# but the runner service runs as a non-admin user. Trust everything system-wide.
git config --system --add safe.directory '*' 2>$null
# Flutter on Windows needs SeCreateSymbolicLinkPrivilege to build plugins.
# Enable Developer Mode (registry) AND grant the privilege via Local Security
# Policy to the built-in "Users" group (SID S-1-5-32-545). Either alone has been
# observed to not take effect until logon-token refresh; doing both is
# belt-and-suspenders. The privilege only reaches a long-running service after
# a reboot or a fresh service-token issuance.
$devKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'
if (-not (Test-Path $devKey)) { New-Item -Path $devKey -Force | Out-Null }
New-ItemProperty -Path $devKey -Name 'AllowDevelopmentWithoutDevLicense' `
-PropertyType DWORD -Value 1 -Force | Out-Null
$secCfg = "$env:TEMP\sec-symlink.cfg"
secedit /export /cfg $secCfg | Out-Null
$secContent = Get-Content $secCfg -Raw
if ($secContent -match 'SeCreateSymbolicLinkPrivilege\s*=\s*([^\r\n]*)') {
$cur = $matches[1]
if ($cur -notmatch '\*S-1-5-32-545') {
$secContent = $secContent -replace `
'(SeCreateSymbolicLinkPrivilege\s*=\s*)([^\r\n]*)', `
'$1$2,*S-1-5-32-545'
}
} else {
$secContent = $secContent -replace `
'(\[Privilege Rights\][\r\n]+)', `
"`$1SeCreateSymbolicLinkPrivilege = *S-1-5-32-545`r`n"
}
$secContent | Set-Content $secCfg
secedit /configure /db "$env:TEMP\sec-symlink.sdb" /cfg $secCfg /areas USER_RIGHTS /quiet
Remove-Item $secCfg, "$env:TEMP\sec-symlink.sdb" -ErrorAction SilentlyContinue
# --- 8. Dedicated runner user ---
# Running as LocalSystem causes a cascade of issues:
# - $USERPROFILE = C:\Windows\System32\config\systemprofile, which Flutter,
# dart pub, and other POSIX-leaning tools mis-handle.
# - cargo install lands binaries in that systemprofile path -> not on PATH.
# - flutter/windows occasionally vanishes during long cargo builds.
# A normal local user fixes all of these.
if ($ServiceAccount -ne 'LocalSystem') {
if (-not (Get-LocalUser -Name $ServiceAccount -ErrorAction SilentlyContinue)) {
if (-not $ServiceAccountPassword) {
# Generate a 32-byte random password using the OS RNG. Encoded as
# base64 (alphanumeric + +/) and trimmed of padding -- meets local
# password complexity without needing System.Web (which is missing
# on Server Core).
$bytes = New-Object byte[] 24
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
$plain = ([Convert]::ToBase64String($bytes)).TrimEnd('=') + 'A1!'
$ServiceAccountPassword = ConvertTo-SecureString $plain -AsPlainText -Force
Remove-Variable plain, bytes
}
Write-Host "==> Creating local user '$ServiceAccount'"
New-LocalUser -Name $ServiceAccount -Password $ServiceAccountPassword `
-PasswordNeverExpires -AccountNeverExpires `
-Description 'Gitea Actions runner service account' | Out-Null
Add-LocalGroupMember -Group 'Users' -Member $ServiceAccount
}
# Grant "Log on as a service" via secedit (no PS native cmdlet for this).
$sid = (Get-LocalUser $ServiceAccount).SID.Value
$svcCfg = "$env:TEMP\sec-svc.cfg"
secedit /export /cfg $svcCfg | Out-Null
$svcContent = Get-Content $svcCfg -Raw
if ($svcContent -match "SeServiceLogonRight\s*=\s*([^\r\n]*)") {
if ($matches[1] -notmatch [regex]::Escape($sid)) {
$svcContent = $svcContent -replace `
'(SeServiceLogonRight\s*=\s*)([^\r\n]*)', `
"`$1`$2,*$sid"
}
} else {
$svcContent = $svcContent -replace `
'(\[Privilege Rights\][\r\n]+)', `
"`$1SeServiceLogonRight = *$sid`r`n"
}
$svcContent | Set-Content $svcCfg
secedit /configure /db "$env:TEMP\sec-svc.sdb" /cfg $svcCfg /areas USER_RIGHTS /quiet
Remove-Item $svcCfg, "$env:TEMP\sec-svc.sdb" -ErrorAction SilentlyContinue
# Ensure the user can read/write everything it needs for builds.
foreach ($p in @('C:\actions-runner','C:\cargo','C:\cargo-tools','C:\vcpkg','C:\vcpkg-cache')) {
New-Item -ItemType Directory -Force -Path $p | Out-Null
icacls $p /grant "${ServiceAccount}:(OI)(CI)F" /T 2>$null | Out-Null
}
foreach ($p in @('C:\rustup','C:\tools')) {
if (Test-Path $p) { icacls $p /grant "${ServiceAccount}:(OI)(CI)RX" /T 2>$null | Out-Null }
}
}
# --- 9. Gitea act_runner ---
$runnerDir = 'C:\actions-runner'
New-Item -ItemType Directory -Force -Path $runnerDir | Out-Null
$runnerExe = "$runnerDir\act_runner.exe"
if (-not (Test-Path $runnerExe)) {
Write-Host "==> Downloading act_runner $RunnerVersion"
Invoke-WebRequest -Uri "https://gitea.com/gitea/act_runner/releases/download/v$RunnerVersion/act_runner-$RunnerVersion-windows-amd64.exe" -OutFile $runnerExe
}
Push-Location $runnerDir
if (-not (Test-Path "$runnerDir\.runner")) {
Write-Host '==> Registering runner'
& $runnerExe register --no-interactive `
--instance $GiteaUrl `
--token $RunnerToken `
--name $RunnerName `
--labels $RunnerLabels
}
# Reconfigure the service every run so re-running with a different
# -ServiceAccount actually takes effect.
$svc = Get-Service -Name 'gitea-act-runner' -ErrorAction SilentlyContinue
if ($svc) {
if ($svc.Status -eq 'Running') { Stop-Service gitea-act-runner }
} else {
Write-Host '==> Installing runner as Windows service'
choco install -y --no-progress nssm
nssm install gitea-act-runner $runnerExe daemon | Out-Null
}
nssm set gitea-act-runner AppDirectory $runnerDir | Out-Null
nssm set gitea-act-runner Start SERVICE_AUTO_START | Out-Null
nssm set gitea-act-runner AppStdout "$runnerDir\runner.log" | Out-Null
nssm set gitea-act-runner AppStderr "$runnerDir\runner.log" | Out-Null
if ($ServiceAccount -eq 'LocalSystem') {
nssm set gitea-act-runner ObjectName 'LocalSystem' | Out-Null
} else {
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ServiceAccountPassword)
try {
$plain = [System.Runtime.InteropServices.Marshal]::PtrToStringUni($bstr)
nssm set gitea-act-runner ObjectName ".\$ServiceAccount" $plain | Out-Null
} finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
Remove-Variable plain -ErrorAction SilentlyContinue
}
}
# Start may fail before reboot if the new SeServiceLogonRight hasn't reached
# SCM yet -- that's expected; the service will start cleanly after reboot.
try {
Start-Service gitea-act-runner
} catch {
Write-Warning "Could not start gitea-act-runner now ($($_.Exception.Message)). It will start on reboot."
}
Pop-Location
Write-Host ''
Write-Host '==> Done.'
Write-Host ' A reboot is REQUIRED before the first build run, so:'
Write-Host ' - the runner service inherits the new SeCreateSymbolicLinkPrivilege token'
Write-Host ' - all PATH/env changes propagate to the SCM-launched service'
Write-Host ' After reboot, verify the runner shows up in Gitea > Site Admin > Actions > Runners.'
if ($ServiceAccount -eq 'LocalSystem') {
Write-Warning 'Service is running as LocalSystem. RustDesk builds have been observed to fail in this configuration (Flutter pub get, symlinks, dubious ownership). Re-run with -ServiceAccount gitea-runner to switch.'
}
+8 -4
View File
@@ -22,10 +22,14 @@ pub enum Action {
ConfigOnly,
/// No flags. Foreground dev mode.
None,
/// `--cm`. Connection-manager popup mode. Spawned as a USER-token child
/// by the SYSTEM-token `--server` worker (via librustdesk's
/// `run_as_user`) when a peer needs interactive approval. Binds the
/// `_cm` IPC pipe, shows MessageBoxW, replies, exits.
/// `--cm`. Connection-manager process. Spawned as a USER-token child of
/// the SYSTEM-token `--server` worker (either pre-emptively by hello-agent's
/// own `spawn_cm_into_user_desktop`, or as a fallback by librustdesk's
/// `run_as_user` when its first `ipc::connect("_cm")` fails). Binds the
/// `_cm` named pipe, runs upstream's `IpcTaskRunner` for each incoming
/// `--server` connection, and lives for as long as the user session does
/// — every `Data::FS(...)` frame the server sends is executed here, in
/// the user's security context.
Cm,
}
+109 -172
View File
@@ -1,4 +1,5 @@
// Approval popup, run in a dedicated `--cm` child process.
// Approval popup + connection-manager process body, run in a dedicated
// `--cm` child process.
//
// Architecture (matches stock rustdesk):
//
@@ -8,29 +9,34 @@
// --server (user session, SYSTEM token) --- screen capture, rendezvous, …
// │ on incoming peer requiring approval, librustdesk's start_ipc
// │ tries `ipc::connect("_cm")`, fails (no listener), then falls
// │ back to `run_as_user(["--cm"])`:
// │ back to `run_as_user(["--cm"])`. Either way, a `--cm` process
// │ must be holding the `_cm` named pipe in the user's session:
// ▼
// --cm (user session, USER token) --- this module
// │ binds `_cm`, accepts one connection from the parent's start_ipc,
// │ reads frames until it sees Data::Login{authorized:false, …},
// │ shows MessageBoxW (works cleanly because USER token + interactive
// │ desktop), replies Data::Authorize / Data::Close, drains the
// │ stream until the server closes it, exits.
// │ binds `_cm`, accepts connections from `--server`, hands each one
// │ to upstream's `IpcTaskRunner` (via `start_ipc`). Our only role
// │ on top of that is to plug in an `InvokeUiCM` impl that renders
// │ the approval popup with `MessageBoxW` and forwards the user's
// │ decision back via `authorize(id)` / `close(id)`.
//
// The previous design (run cm_popup as a thread inside the SYSTEM-token
// --server worker) hit Windows' UI-isolation rules — `MessageBoxW` from a
// SYSTEM-token process technically returns successfully but draws on a
// desktop the logged-in user can't see, so the popup was invisible.
// Spawning as a USER child sidesteps the whole class of issues.
// Why this delegates to upstream's `start_ipc` instead of running its own
// frame loop: on Windows the `--server` process forwards *every* filesystem
// operation (ReadDir, ReadFile, WriteBlock, …) over the `_cm` pipe and
// expects the CM to execute them in the user's security context. Reading
// only the Login frame and discarding the rest — what an earlier version of
// this module did — meant the supporter could open a file-transfer session
// and get the request approved, but the directory listing never arrived
// because the `Data::FS(ReadDir)` frame was being silently dropped. The
// upstream `IpcTaskRunner` implements all of that machinery (handle_fs +
// the file_timer for streaming read jobs); we just provide the popup.
use anyhow::Result;
use librustdesk::ipc;
use librustdesk::ui_cm_interface::{self, Client, ConnectionManager, InvokeUiCM};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[cfg(target_os = "windows")]
use std::os::windows::ffi::OsStrExt;
const POSTFIX: &str = "_cm";
/// Diagnostic trace: writes to stderr AND a debug log file.
/// Bypasses `log` so we still see output even when env_logger / flexi_logger
/// init went wrong. Drop these calls once the popup mechanism is stable.
@@ -56,174 +62,105 @@ fn trace(msg: &str) {
}
}
/// Run the popup loop forever on a freshly-created Tokio runtime.
/// Safe to call from a `std::thread::spawn` body.
/// Connection-manager process entry point: bind `_cm`, accept connections
/// from the `--server` worker forever, run upstream's IpcTaskRunner on each.
///
/// `start_ipc` is `#[tokio::main(flavor = "current_thread")]` — it builds
/// its own runtime internally — so this is callable from sync context.
pub fn run_blocking() {
trace("run_blocking entered");
let cm = ConnectionManager {
ui_handler: HeadlessCm::default(),
};
// Returns only on listener error (e.g. another --cm already holds the
// pipe) or process shutdown. Either way there's nothing to do after.
ui_cm_interface::start_ipc(cm);
trace("start_ipc returned");
}
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
trace(&format!("build runtime: {e}"));
/// `InvokeUiCM` adapter for hello-agent. Stateless except for a small map
/// of `(connection id) -> (peer_id, name)` we keep so we can render a
/// "session ended" notification at remove time (the `Client` is dropped
/// from upstream's `CLIENTS` registry before our `remove_connection` hook
/// is called, so we can't fish the peer info out of there).
#[derive(Clone, Default)]
struct HeadlessCm {
/// Tracks peers we approved. Connections that the user denied are not
/// inserted here, so they don't trigger a "session ended" banner the
/// user has no context for.
approved: Arc<Mutex<HashMap<i32, (String, String)>>>,
}
impl InvokeUiCM for HeadlessCm {
/// Called by upstream's IPC loop the moment a peer's Login frame is
/// received and the client has been registered in the global CLIENTS
/// map. We must NOT block here — the same task that called us is
/// also the one that pumps the `_cm` pipe, so blocking on a user
/// click would prevent the IPC loop from ever delivering the
/// `Data::Authorize` we send back.
fn add_connection(&self, client: &Client) {
trace(&format!(
"add_connection: id={} peer_id={} name={} authorized={}",
client.id, client.peer_id, client.name, client.authorized
));
if client.authorized {
// Already authorized (e.g. password-based auth). No popup,
// but track so remove_connection can show "session ended".
self.approved
.lock()
.unwrap()
.insert(client.id, (client.peer_id.clone(), client.name.clone()));
return;
}
};
trace("runtime built; entering serve()");
if let Err(e) = rt.block_on(serve()) {
trace(&format!("serve exited: {e:#}"));
} else {
trace("serve returned cleanly");
// Render the approval MessageBox on a fresh OS thread so the IPC
// task that called us stays responsive. On Yes we register the
// peer in `approved` and call `authorize(id)` which sends
// `Data::Authorize` back to `--server`; on No we call `close(id)`
// which sends `Data::Close` and the server tears the session down.
let id = client.id;
let peer_id = client.peer_id.clone();
let name = client.name.clone();
let approved_map = self.approved.clone();
std::thread::spawn(move || {
let approved = show_messagebox(&peer_id, &name);
trace(&format!("add_connection: MessageBox approved={approved}"));
if approved {
approved_map
.lock()
.unwrap()
.insert(id, (peer_id, name));
ui_cm_interface::authorize(id);
} else {
ui_cm_interface::close(id);
}
});
}
}
/// Bind `_cm`, accept connections from `--server`'s `start_ipc` for as
/// long as the user session lasts. Each connection corresponds to one
/// peer requesting approval; we handle them concurrently.
async fn serve() -> Result<()> {
trace(&format!("calling new_listener({POSTFIX})"));
let mut incoming = match ipc::new_listener(POSTFIX).await {
Ok(i) => {
trace("new_listener succeeded");
i
}
Err(e) => {
trace(&format!("new_listener failed: {e}"));
return Err(anyhow::anyhow!("new_listener({POSTFIX}): {e}"));
}
};
trace("entering accept loop");
while let Some(result) = incoming.next().await {
match result {
Ok(stream) => {
trace("accepted incoming connection");
let conn = ipc::Connection::new(stream);
tokio::spawn(async move {
if let Err(e) = handle_one(conn).await {
trace(&format!("handle_one error: {e:#}"));
}
});
}
Err(e) => {
trace(&format!("accept error: {e}"));
}
}
}
trace("accept loop exited");
Ok(())
}
async fn handle_one(mut conn: ipc::Connection) -> Result<()> {
// Frame ordering on the `_cm` pipe is NOT "Login first, then chatter".
// For an installed/portable controlled side, the server first emits
// `Data::DataPortableService(CmShowElevation(...))` so the Flutter CM
// can render its elevation banner. The `Data::Login` we care about
// arrives a moment later. We loop through frames, ignore everything
// until we see Login{authorized:false}, decide once, and from then on
// just drain the stream so the server's `tx_to_cm.send()` calls don't
// back up.
//
// We use `conn.next()` (no timeout). A long active session can sit
// quiet for tens of minutes — `tx_to_cm` only fires on Login, FS
// transfers, and connection-close — so a short read timeout would
// false-positive into "session ended" UX during normal use.
trace("handle_one: entering frame loop");
let mut decided = false;
// Set when the user clicks Yes on the approval popup. Carries the
// peer's id / name for the matching "session ended" notification we
// fire after the server tears the connection down.
let mut approved_peer: Option<(String, String)> = None;
loop {
match conn.next().await {
Ok(Some(ipc::Data::Login {
peer_id,
name,
authorized: false,
..
})) if !decided => {
trace(&format!(
"handle_one: Login peer_id={peer_id} name={name} authorized=false"
));
decided = true;
let approved = ask_user_blocking(&peer_id, &name).await;
trace(&format!(
"handle_one: MessageBox returned approved={approved}"
));
if approved {
let _ = conn.send(&ipc::Data::Authorize).await;
trace("handle_one: sent Authorize");
approved_peer = Some((peer_id, name));
} else {
let _ = conn.send(&ipc::Data::Close).await;
trace("handle_one: sent Close — exiting handler");
return Ok(());
}
}
Ok(Some(ipc::Data::Close)) | Ok(Some(ipc::Data::Disconnected)) => {
// Server signals the supporter has left (or the
// connection failed). Fall through to the post-loop
// notification path.
trace("handle_one: server sent Close/Disconnected");
break;
}
Ok(Some(other)) => {
// Pre-login chatter (CmShowElevation), or post-Authorize
// chatter (chat, file transfer events, voice call). We
// don't act on any of it — the Flutter CM would, we just
// need to consume frames so the server's send buffer
// drains.
trace(&format!("handle_one: ignoring frame: {other:?}"));
continue;
}
Ok(None) => {
trace("handle_one: stream closed by peer");
break;
}
Err(e) => {
trace(&format!("handle_one: stream error: {e}"));
break;
}
fn remove_connection(&self, id: i32, _close: bool) {
trace(&format!("remove_connection: id={id}"));
let entry = self.approved.lock().unwrap().remove(&id);
if let Some((peer_id, name)) = entry {
std::thread::spawn(move || show_session_ended(&peer_id, &name));
}
}
// Tell the user the supporter is gone. Only fires when we approved
// the connection — denied/cancelled connections already returned
// above, and pre-approval Close from the server (e.g., auth failure
// before the popup even fired) shouldn't show a "session ended"
// banner the user has no context for.
if let Some((peer_id, name)) = approved_peer {
notify_session_ended(&peer_id, &name).await;
// The remaining InvokeUiCM hooks fire for chat / theme / voice-call /
// file-transfer-log / privacy-mode-elevation events. Hello-agent
// doesn't surface any of them — file transfers complete silently in
// the background (the supporter's UI shows progress on their end),
// chat is unsupported, voice call is unsupported. Stubs only.
fn new_message(&self, _id: i32, _text: String) {}
fn change_theme(&self, _dark: String) {}
fn change_language(&self) {}
fn show_elevation(&self, _show: bool) {}
fn update_voice_call_state(&self, _client: &Client) {}
fn file_transfer_log(&self, action: &str, log: &str) {
// Useful breadcrumb for debugging file-transfer failures, gated
// behind trace() to keep production stderr quiet.
trace(&format!("file_transfer_log: action={action} log={log}"));
}
trace("handle_one: returning");
Ok(())
}
/// Show a native MessageBox in the calling (user) session. Runs the dialog
/// on tokio's blocking thread pool so we don't park the reactor while it
/// waits for the user to click.
async fn ask_user_blocking(peer_id: &str, name: &str) -> bool {
let peer_id = peer_id.to_string();
let name = name.to_string();
tokio::task::spawn_blocking(move || show_messagebox(&peer_id, &name))
.await
.unwrap_or(false)
}
/// Inform the user that the remote support session has ended. Best-effort:
/// errors out of the OS dialog APIs are logged (via `trace`) and otherwise
/// ignored — failing to show the post-session banner shouldn't block the
/// handler from cleaning up.
async fn notify_session_ended(peer_id: &str, name: &str) {
let peer_id = peer_id.to_string();
let name = name.to_string();
let _ = tokio::task::spawn_blocking(move || show_session_ended(&peer_id, &name)).await;
}
#[cfg(target_os = "windows")]
+212
View File
@@ -0,0 +1,212 @@
//! System inventory collection for hello-agent (CMDB).
//!
//! Collects hardware and OS metadata at startup — BIOS serial number,
//! manufacturer / model, AD domain, OS edition + release, CPU details,
//! RAM, disks, and the BitLocker recovery key for the system drive — and
//! returns it as a compact JSON object. The caller stamps the result into
//! `hbb_common::config::INVENTORY` so the next /api/sysinfo upload carries
//! it under the `inventory` key. The rustdesk-server admin UI's per-device
//! detail page reads it back from `device_sysinfo.payload`.
//!
//! Implementation: a single PowerShell child gathers everything via
//! Get-CimInstance and emits compact JSON. One subprocess for the whole
//! inventory is cheaper than per-field WMI queries and avoids pulling a
//! `wmi`/COM crate into the dep tree. Inventory is collected once at
//! startup. Collection routinely outruns the first sysinfo tick (TIME_CONN
//! = 3 s) — `Invoke-RestMethod 'api.ipify.org' -TimeoutSec 5` alone can
//! burn that budget on hosts with blocked egress — so the sysinfo loop in
//! `hbbs_http::sync` watches for INVENTORY transitioning empty → populated
//! and forces a re-upload at that point. Subsequent ticks are suppressed
//! by the loop's `had_inventory` / `uploaded` bookkeeping.
//!
//! Non-Windows builds return an empty JSON object — hello-agent v0 only
//! ships on Windows, but keeping the cross-platform surface compiling
//! makes future Linux work cheap.
#[cfg(target_os = "windows")]
const PS_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$bios = Get-CimInstance -ClassName Win32_BIOS
$cs = Get-CimInstance -ClassName Win32_ComputerSystem
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$cpus = @(Get-CimInstance -ClassName Win32_Processor)
$first_cpu = $cpus | Select-Object -First 1
$total_phys_cores = ($cpus | Measure-Object -Property NumberOfCores -Sum).Sum
$total_log_cores = ($cpus | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum
$disks = @(Get-CimInstance -ClassName Win32_DiskDrive | ForEach-Object {
[pscustomobject]@{
name = $_.DeviceID
model = $_.Model
size_gb = if ($_.Size) { [math]::Round([double]$_.Size / 1GB, 1) } else { 0 }
media = $_.MediaType
}
})
# DisplayVersion is the marketing release ID (e.g. "23H2") — not surfaced
# by Win32_OperatingSystem.Version, which only carries the build number.
$displayVersion = ''
try {
$displayVersion = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion
} catch {}
# BitLocker recovery key for the system drive. Get-BitLockerVolume needs
# the BitLocker PowerShell module (present on Pro/Enterprise SKUs) and
# admin rights; the agent runs as LocalSystem in production. On Home SKUs
# or Linux this just stays empty.
$bl_key = ''
try {
$sysDrive = $env:SystemDrive
$bv = Get-BitLockerVolume -MountPoint $sysDrive -ErrorAction Stop
$kp = $bv.KeyProtector | Where-Object { $_.KeyProtectorType -eq 'RecoveryPassword' } | Select-Object -First 1
if ($kp -and $kp.RecoveryPassword) { $bl_key = $kp.RecoveryPassword }
} catch {}
# Network interfaces: every adapter (Up + Disabled + Disconnected) with
# its MAC and bound IPv4/IPv6 addresses. Filtering happens server-side
# so the operator can still see "this NIC is disabled" rather than
# silently dropping it. LinkSpeed comes back as a string like "1 Gbps"
# or "100 Mbps" — normalize to integer Mbps, 0 when unknown / down.
$nics = @(Get-NetAdapter | ForEach-Object {
$nic = $_
$ipv4 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv4 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
$ipv6 = @(Get-NetIPAddress -InterfaceIndex $nic.ifIndex -AddressFamily IPv6 -ErrorAction SilentlyContinue | ForEach-Object { $_.IPAddress })
$speed_mbps = 0
if ($nic.LinkSpeed) {
if ($nic.LinkSpeed -match '^([\d.]+)\s*Gbps') { $speed_mbps = [int]([double]$Matches[1] * 1000) }
elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Mbps') { $speed_mbps = [int]([double]$Matches[1]) }
elseif ($nic.LinkSpeed -match '^([\d.]+)\s*Kbps') { $speed_mbps = 0 }
}
[pscustomobject]@{
name = $nic.Name
description = $nic.InterfaceDescription
mac = $nic.MacAddress
status = "$($nic.Status)"
ipv4 = $ipv4
ipv6 = $ipv6
speed_mbps = $speed_mbps
is_wifi = ($nic.PhysicalMediaType -eq 'Native 802.11')
}
})
# Wi-Fi inventory is collected separately, in Rust, against the Win32
# Native Wi-Fi API (`wlanapi.dll`). netsh's text output is partially
# localized — `Authentication` becomes `Authentifizierung` /
# `Autenticación` / `Autentificare` on de/es/ro Windows — and our
# regexes silently dropped fields on non-English hosts. The native API
# returns SSIDs as bytes and auth/cipher as numeric enums, so the
# resulting data is locale-stable. See `src/wifi_native.rs`. The fields
# `wifi_current` / `wifi_nearby` are merged into this object after
# PowerShell exits.
# Public egress IP: best-effort lookup against an external echo service.
# Used when the operator wants to correlate the device with a NAT'd
# location. 5 s timeout so a blocked corporate firewall doesn't stall
# inventory collection. ipify is HTTPS, IPv4-only by default, no auth.
$public_ip = ''
try {
$public_ip = (Invoke-RestMethod -Uri 'https://api.ipify.org' -TimeoutSec 5 -ErrorAction Stop).ToString().Trim()
} catch {}
$os_release = "$($os.Version)"
if ($displayVersion) { $os_release = "$($os.Version) $displayVersion" }
$result = [pscustomobject]@{
serial_number = $bios.SerialNumber
manufacturer = $cs.Manufacturer
model = $cs.Model
domain = $cs.Domain
os_distro = $os.Caption
os_release = $os_release
cpu_model = $first_cpu.Name
cpu_speed_ghz = if ($first_cpu.MaxClockSpeed) { [math]::Round([double]$first_cpu.MaxClockSpeed / 1000, 2) } else { 0 }
cpu_cores_physical = $total_phys_cores
cpu_cores_logical = $total_log_cores
ram_gb = if ($cs.TotalPhysicalMemory) { [math]::Round([double]$cs.TotalPhysicalMemory / 1GB, 1) } else { 0 }
disks = $disks
bitlocker_recovery_key = $bl_key
network_interfaces = $nics
public_ip = $public_ip
}
$result | ConvertTo-Json -Compress -Depth 6
"#;
/// Collect the inventory and return it as a JSON string. Empty string on
/// any failure — the caller treats that as "skip this upload's
/// `inventory` field" rather than uploading garbage.
#[cfg(target_os = "windows")]
pub fn collect_inventory() -> String {
use std::os::windows::process::CommandExt;
use std::process::Command;
// CREATE_NO_WINDOW prevents a brief PowerShell console flash if the
// agent is ever run interactively (dev mode). In service mode there's
// no console anyway, but the flag is harmless.
const CREATE_NO_WINDOW: u32 = 0x08000000;
let output = match Command::new("powershell.exe")
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
PS_SCRIPT,
])
.creation_flags(CREATE_NO_WINDOW)
.output()
{
Ok(o) => o,
Err(e) => {
log::warn!("inventory: powershell failed to spawn: {e}");
return String::new();
}
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!(
"inventory: powershell exited non-zero ({:?}): {}",
output.status.code(),
stderr.trim()
);
return String::new();
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() {
log::warn!("inventory: powershell produced empty stdout");
return String::new();
}
// Parse the PowerShell-produced object so we can merge in the
// native Wi-Fi data below. Any parse failure aborts the whole
// collection — sync.rs would otherwise have to reject malformed
// payloads mid-upload and we'd carry the bad data until the next
// collect.
let mut value: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(e) => {
log::warn!(
"inventory: powershell output is not valid JSON: {e}; first 200 chars: {:.200}",
trimmed
);
return String::new();
}
};
// Native Wi-Fi via wlanapi.dll. Emits SSIDs as bytes and auth/cipher
// as numeric enums, so the result is locale-stable across the
// en/de/es/ro Windows builds in our fleet — replaces the
// previously-localized netsh parser.
let (wifi_current, wifi_nearby) = crate::wifi_native::collect();
if let Some(c) = wifi_current {
value["wifi_current"] = c;
}
value["wifi_nearby"] = serde_json::Value::Array(wifi_nearby);
let serialized = value.to_string();
log::info!("inventory: collected ({} bytes)", serialized.len());
serialized
}
#[cfg(not(target_os = "windows"))]
pub fn collect_inventory() -> String {
String::new()
}
+19
View File
@@ -23,6 +23,7 @@
mod cli;
mod config_import;
mod inventory;
#[cfg(target_os = "windows")]
mod cm_popup;
@@ -30,6 +31,8 @@ mod cm_popup;
mod service;
#[cfg(target_os = "windows")]
mod unattended_password;
#[cfg(target_os = "windows")]
mod wifi_native;
use cli::{Action, ParsedArgs};
@@ -206,6 +209,22 @@ fn run_server() {
),
}
// Kick off CMDB inventory collection on a background thread before
// start_server boots. PowerShell's first-run cost (a few hundred ms
// to a few seconds) shouldn't delay the rendezvous heartbeat — the
// sysinfo upload loop only fires every TIME_CONN seconds, so the
// inventory will be ready in time for the very first /api/sysinfo
// POST whether collection finishes in 50ms or 5s. We deliberately
// don't retry on failure: a transient PowerShell hiccup leaves the
// INVENTORY global empty, and sync.rs simply omits the `inventory`
// key from the upload. Next agent restart re-tries.
std::thread::spawn(|| {
let inv = inventory::collect_inventory();
if !inv.is_empty() {
*hbb_common::config::INVENTORY.write().unwrap() = inv;
}
});
// `start_server` is `#[tokio::main]` and runs forever. (is_server=true,
// no_server=false). It boots the default IPC server, input service,
// rendezvous mediator, and heartbeat sync.
+278
View File
@@ -0,0 +1,278 @@
//! Locale-independent Wi-Fi inventory via the Win32 Native Wi-Fi API
//! (`wlanapi.dll`).
//!
//! Replaces the previous `netsh wlan show …` text-parsing approach.
//! `netsh` partially localizes its output — labels like
//! `Authentication` / `Authentifizierung` / `Autenticación` / `Autentificare`
//! shift with the user's display language, and our regexes silently
//! dropped fields on non-English Windows. The fleet runs four
//! languages (en, de, es, ro), so we move to the structured API:
//!
//! * SSIDs come back as raw `UCHAR[32]` bytes — no localization
//! possible.
//! * Authentication comes back as a `DOT11_AUTH_ALGORITHM` enum
//! (a small integer); we map to our own English labels.
//! * Cipher / signal quality are likewise plain enums / integers.
//!
//! Returned shape (consumed by `inventory::collect_inventory` and
//! merged into the sysinfo upload):
//!
//! ```text
//! wifi_current: { ssid, bssid, signal_pct, rssi_dbm, rx_kbps, tx_kbps,
//! auth, cipher } | None
//! wifi_nearby: [ { ssid, signal_pct, auth, cipher }, … up to 30 ]
//! ```
//!
//! All allocations from the API (handle, interface list, network list,
//! query-interface buffer) are freed in this module — no caller cleanup.
//! Every failure path returns best-effort partial data; we never panic.
//!
//! We deliberately do **not** trigger a fresh scan via `WlanScan`. The
//! OS scans periodically on its own, and the cached BSS list is usually
//! < 60 s old. Triggering a scan would either delay startup by ~5 s
//! waiting for `wlan_notification_acm_scan_complete`, or return stale
//! data anyway if we don't wait. The next inventory cycle (≤ 120 s)
//! picks up any change.
#![cfg(target_os = "windows")]
use serde_json::{json, Value};
use std::collections::HashMap;
use std::ptr;
use std::slice;
// IMPORTANT: use winapi's `HANDLE` / `PVOID` (which under the hood wrap
// `winapi::ctypes::c_void`) rather than `std::ffi::c_void`. Rust treats
// the two void types as distinct even though they're the same in C —
// mixing them produces opaque "expected `winapi::ctypes::c_void`, found
// `std::ffi::c_void`" errors. macOS hosts can't catch this because
// winapi is gated to Windows targets, so Cargo never typechecks this
// file off Windows.
use winapi::shared::minwindef::DWORD;
use winapi::shared::ntdef::{HANDLE, PVOID};
use winapi::shared::winerror::ERROR_SUCCESS;
use winapi::shared::wlantypes::DOT11_SSID;
use winapi::um::wlanapi::{
wlan_intf_opcode_current_connection, wlan_interface_state_connected, WlanCloseHandle,
WlanEnumInterfaces, WlanFreeMemory, WlanGetAvailableNetworkList, WlanOpenHandle,
WlanQueryInterface, WLAN_AVAILABLE_NETWORK_LIST, WLAN_CONNECTION_ATTRIBUTES,
WLAN_INTERFACE_INFO_LIST,
};
/// Vista+ client version. Win10/Win11 happily negotiate down via
/// `negotiated_version`. We never need v1 (XP-era).
const CLIENT_VERSION_VISTA: DWORD = 2;
/// Public entry point. Returns `(current_connection, nearby_list)`.
/// On hosts without a Wi-Fi adapter, without the WLAN AutoConfig
/// service, or on any FFI failure → `(None, vec![])`.
pub fn collect() -> (Option<Value>, Vec<Value>) {
// SAFETY: every raw-pointer dereference and array read below is
// bounds-checked against the `dwNumberOfItems` field of the parent
// list, and the API contract is that those fields match the
// allocated array length. All allocations are paired with
// `WlanFreeMemory` before this function returns.
unsafe { collect_inner() }
}
unsafe fn collect_inner() -> (Option<Value>, Vec<Value>) {
let mut handle: HANDLE = ptr::null_mut();
let mut neg_ver: DWORD = 0;
if WlanOpenHandle(
CLIENT_VERSION_VISTA,
ptr::null_mut(),
&mut neg_ver,
&mut handle,
) != ERROR_SUCCESS as DWORD
{
return (None, Vec::new());
}
let mut current: Option<Value> = None;
// Dedupe nearby by SSID, keeping the strongest-quality entry per
// network. WlanGetAvailableNetworkList already collapses BSSIDs to
// one row per (SSID, BSS-type), but if multiple Wi-Fi NICs are
// present we'd see the same SSID twice; this keeps the louder copy.
let mut nearby_by_ssid: HashMap<String, Value> = HashMap::new();
let mut iface_list: *mut WLAN_INTERFACE_INFO_LIST = ptr::null_mut();
if WlanEnumInterfaces(handle, ptr::null_mut(), &mut iface_list) == ERROR_SUCCESS as DWORD
&& !iface_list.is_null()
{
let count = (*iface_list).dwNumberOfItems as usize;
if count > 0 {
let ifaces = slice::from_raw_parts((*iface_list).InterfaceInfo.as_ptr(), count);
for iface in ifaces {
read_iface(handle, iface, &mut current, &mut nearby_by_ssid);
}
}
WlanFreeMemory(iface_list as *mut _);
}
WlanCloseHandle(handle, ptr::null_mut());
let mut nearby: Vec<Value> = nearby_by_ssid.into_values().collect();
nearby.sort_by(|a, b| {
let bs = b.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
let as_ = a.get("signal_pct").and_then(|v| v.as_u64()).unwrap_or(0);
bs.cmp(&as_)
});
nearby.truncate(30);
(current, nearby)
}
unsafe fn read_iface(
handle: HANDLE,
iface: &winapi::um::wlanapi::WLAN_INTERFACE_INFO,
current: &mut Option<Value>,
nearby_by_ssid: &mut HashMap<String, Value>,
) {
let guid = iface.InterfaceGuid;
// Current connection. Querying when the interface isn't connected
// returns ERROR_NOT_FOUND — no harm, but skipping the call is
// tidier and avoids a noisy ETW event on locked-down endpoints.
if iface.isState == wlan_interface_state_connected && current.is_none() {
let mut data_size: DWORD = 0;
let mut data: PVOID = ptr::null_mut();
let mut value_type: u32 = 0;
if WlanQueryInterface(
handle,
&guid,
wlan_intf_opcode_current_connection,
ptr::null_mut(),
&mut data_size,
&mut data,
&mut value_type,
) == ERROR_SUCCESS as DWORD
&& !data.is_null()
&& data_size as usize >= std::mem::size_of::<WLAN_CONNECTION_ATTRIBUTES>()
{
let attrs = &*(data as *const WLAN_CONNECTION_ATTRIBUTES);
let assoc = &attrs.wlanAssociationAttributes;
let sec = &attrs.wlanSecurityAttributes;
let ssid = read_ssid(&assoc.dot11Ssid);
let bssid = format_mac(&assoc.dot11Bssid);
*current = Some(json!({
"ssid": ssid,
"bssid": bssid,
"signal_pct": assoc.wlanSignalQuality,
"rssi_dbm": rssi_from_quality(assoc.wlanSignalQuality),
"rx_kbps": assoc.ulRxRate,
"tx_kbps": assoc.ulTxRate,
"auth": auth_label(sec.dot11AuthAlgorithm as u32),
"cipher": cipher_label(sec.dot11CipherAlgorithm as u32),
}));
WlanFreeMemory(data);
}
}
// Available networks (cached scan).
let mut net_list: *mut WLAN_AVAILABLE_NETWORK_LIST = ptr::null_mut();
if WlanGetAvailableNetworkList(handle, &guid, 0, ptr::null_mut(), &mut net_list)
== ERROR_SUCCESS as DWORD
&& !net_list.is_null()
{
let n = (*net_list).dwNumberOfItems as usize;
if n > 0 {
let nets = slice::from_raw_parts((*net_list).Network.as_ptr(), n);
for net in nets {
let ssid = read_ssid(&net.dot11Ssid);
if ssid.is_empty() {
// Hidden networks broadcast empty SSIDs in beacons —
// skip rather than emit `{ ssid: "" }` rows that
// collapse together in the dedupe map.
continue;
}
let entry_signal = net.wlanSignalQuality;
let prev_signal = nearby_by_ssid
.get(&ssid)
.and_then(|v| v.get("signal_pct"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
if (entry_signal as u64) >= prev_signal {
nearby_by_ssid.insert(
ssid.clone(),
json!({
"ssid": ssid,
"signal_pct": entry_signal,
"auth": auth_label(net.dot11DefaultAuthAlgorithm as u32),
"cipher": cipher_label(net.dot11DefaultCipherAlgorithm as u32),
}),
);
}
}
}
WlanFreeMemory(net_list as *mut _);
}
}
/// Read a `DOT11_SSID` (length-prefixed UCHAR[32]) into a Rust String.
/// SSIDs are nominally any byte sequence ≤ 32 octets; UTF-8 is by far
/// the most common (also the IEEE recommendation), but Latin-1 and
/// random bytes occur. We use lossy UTF-8 decoding so weird encodings
/// don't yield panics or empty strings — the operator can still
/// recognize most networks visually.
fn read_ssid(s: &DOT11_SSID) -> String {
let len = (s.uSSIDLength as usize).min(s.ucSSID.len());
String::from_utf8_lossy(&s.ucSSID[..len]).into_owned()
}
fn format_mac(bytes: &[u8; 6]) -> String {
format!(
"{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5]
)
}
/// MS docs: WLAN_SIGNAL_QUALITY 0 ↔ 100 dBm, 100 ↔ 50 dBm, linear
/// interpolation in between. `quality / 2 - 100`.
fn rssi_from_quality(q: u32) -> i32 {
-100 + (q.min(100) as i32 / 2)
}
/// Human label for a `DOT11_AUTH_ALGORITHM` value. Matched on raw
/// integers because winapi 0.3 was tagged before WPA3 / OWE went into
/// the SDK — relying on the named constants would force a dep upgrade
/// just to recognize WPA3 networks. Numeric values are part of the
/// stable Wi-Fi protocol spec, not the SDK.
fn auth_label(a: u32) -> &'static str {
match a {
1 => "Open",
2 => "Shared",
3 => "WPA-Enterprise",
4 => "WPA-Personal",
5 => "WPA-None",
6 => "WPA2-Enterprise",
7 => "WPA2-Personal",
8 => "WPA3-Enterprise (192-bit)",
9 => "WPA3-Personal (SAE)",
10 => "OWE",
11 => "WPA3-Enterprise",
_ => "Unknown",
}
}
/// Human label for a `DOT11_CIPHER_ALGORITHM` value. Same numeric-match
/// rationale as `auth_label`. Values from the IEEE 802.11 / Microsoft
/// header (wlantypes.h DOT11_CIPHER_ALGORITHM enum).
fn cipher_label(c: u32) -> &'static str {
match c {
0x00 => "None",
0x01 => "WEP-40",
0x02 => "TKIP",
0x04 => "AES (CCMP)",
0x05 => "WEP-104",
0x06 => "BIP-CMAC-128",
0x08 => "GCMP",
0x09 => "GCMP-256",
0x0A => "CCMP-256",
0x0B => "BIP-GMAC-128",
0x0C => "BIP-GMAC-256",
0x0D => "BIP-CMAC-256",
0x100 => "WPA-Use-Group",
0x101 => "WEP",
_ => "Unknown",
}
}
+11
View File
@@ -123,6 +123,17 @@ lazy_static::lazy_static! {
/// "empty = omit" convention as `AGENT_NAME`. For hello-agent this
/// is `env!("CARGO_PKG_VERSION")`.
pub static ref AGENT_VERSION: RwLock<String> = RwLock::new(String::new());
/// Pre-serialized JSON object describing the host's hardware /
/// firmware / OS edition inventory (BIOS serial, manufacturer, model,
/// AD domain, OS edition + release, CPU details, RAM, disks,
/// BitLocker recovery key). Same "empty = omit" convention as
/// `AGENT_NAME`: when non-empty, `hbbs_http::sync` parses it and
/// merges it into the sysinfo upload under the `inventory` key, where
/// the rustdesk-server admin UI's per-device detail page reads it.
/// Stored pre-serialized so the producer (hello-agent's `inventory`
/// module) owns the schema and the consumer (sync) doesn't need to
/// know the field set.
pub static ref INVENTORY: RwLock<String> = RwLock::new(String::new());
static ref KEY_PAIR: Mutex<Option<KeyPair>> = Default::default();
static ref USER_DEFAULT_CONFIG: RwLock<(UserDefaultConfig, Instant)> = RwLock::new((UserDefaultConfig::load(), Instant::now()));
pub static ref NEW_STORED_PEER_CONFIG: Mutex<HashSet<String>> = Default::default();
+40 -4
View File
@@ -57,6 +57,13 @@ struct InfoUploaded {
last_uploaded: Option<Instant>,
id: String,
username: Option<String>,
// hello-agent local patch: tracks whether the most recent successful
// sysinfo upload carried the `inventory` key. The CMDB collector
// (hello-agent's `inventory.rs`) runs on a background thread and can
// finish *after* the first sysinfo tick has already fired; without
// this flag the loop would never re-upload because `uploaded == true`
// and `username` is unchanged. Re-apply on vendor resync.
had_inventory: bool,
}
impl Default for InfoUploaded {
@@ -67,18 +74,20 @@ impl Default for InfoUploaded {
last_uploaded: None,
id: "".to_owned(),
username: None,
had_inventory: false,
}
}
}
impl InfoUploaded {
fn uploaded(url: String, id: String, username: String) -> Self {
fn uploaded(url: String, id: String, username: String, had_inventory: bool) -> Self {
Self {
uploaded: true,
url,
last_uploaded: None,
id,
username: Some(username),
had_inventory,
}
}
}
@@ -126,9 +135,18 @@ async fn start_hbbs_sync_async() {
// we may not be able to get the username before login after the next restart.
let mut v = crate::get_sysinfo();
let sys_username = v["username"].as_str().unwrap_or_default().to_string();
// hello-agent local patch: the inventory collector is async
// (PowerShell + WMI + ipify, see `inventory::collect_inventory`)
// and routinely finishes after the first sysinfo tick. When
// INVENTORY transitions from empty → populated we need to
// re-upload, even though `uploaded == true` and the username
// hasn't changed. Re-apply on vendor resync.
let inventory_now_available = !config::INVENTORY.read().unwrap().is_empty();
// Though the username comparison is only necessary on Windows,
// we still keep the comparison on other platforms for consistency.
let need_upload = (!info_uploaded.uploaded || info_uploaded.username.as_ref() != Some(&sys_username)) &&
let need_upload = (!info_uploaded.uploaded
|| info_uploaded.username.as_ref() != Some(&sys_username)
|| (inventory_now_available && !info_uploaded.had_inventory)) &&
info_uploaded.last_uploaded.map(|x| x.elapsed() >= UPLOAD_SYSINFO_TIMEOUT).unwrap_or(true);
if need_upload {
v["version"] = json!(crate::VERSION);
@@ -147,6 +165,24 @@ async fn start_hbbs_sync_async() {
if !agent_version.is_empty() {
v["agent_version"] = json!(agent_version);
}
// Optional CMDB inventory. Producer (hello-agent's
// `inventory` module) populates this with a pre-
// serialized JSON object covering BIOS / hardware /
// OS-edition fields. We re-parse on each upload (the
// string is small — single-digit kB at most) rather
// than caching, so a refreshed inventory is picked up
// without bookkeeping. Parse failure is silently
// dropped: the sysinfo upload still goes out without
// the `inventory` key, identical to a vanilla rustdesk
// install.
let inventory = config::INVENTORY.read().unwrap().clone();
let mut had_inventory = false;
if !inventory.is_empty() {
if let Ok(inv_v) = serde_json::from_str::<Value>(&inventory) {
v["inventory"] = inv_v;
had_inventory = true;
}
}
let ab_name = Config::get_option(keys::OPTION_PRESET_ADDRESS_BOOK_NAME);
if !ab_name.is_empty() {
v[keys::OPTION_PRESET_ADDRESS_BOOK_NAME] = json!(ab_name);
@@ -216,7 +252,7 @@ async fn start_hbbs_sync_async() {
}
};
if samever {
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username);
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
log::info!("sysinfo not changed, skip upload");
continue;
}
@@ -225,7 +261,7 @@ async fn start_hbbs_sync_async() {
match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await {
Ok(x) => {
if x == "SYSINFO_UPDATED" {
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username);
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
log::info!("sysinfo updated");
if !hash.is_empty() {
config::Status::set("sysinfo_hash", hash);
+7 -1
View File
@@ -62,7 +62,13 @@ mod whiteboard;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod updater;
mod ui_cm_interface;
// `ui_cm_interface` exposes the full Connection-Manager IPC loop used by the
// Flutter UI (`start_ipc` + the `InvokeUiCM` trait + `authorize`/`close`/...).
// Made pub so hello-agent's headless `--cm` process can plug in its own
// MessageBoxW-based `InvokeUiCM` impl and inherit upstream's file-transfer,
// chat, and clipboard handling instead of having to re-implement `handle_fs`
// and the read-job timer.
pub mod ui_cm_interface;
mod ui_interface;
mod ui_session_interface;
+1 -1
View File
@@ -1,3 +1,3 @@
pub const VERSION: &str = "1.4.6";
#[allow(dead_code)]
pub const BUILD_DATE: &str = "2026-05-08 14:54";
pub const BUILD_DATE: &str = "2026-05-09 10:43";