Initial commit: hello-agent — headless RustDesk-protocol-compatible Windows agent
build-windows / build-hello-agent-x64 (push) Successful in 5m41s
build-windows / build-hello-agent-x64 (push) Successful in 5m41s
A single-binary, Flutter-free remote-support agent that speaks the stock
RustDesk wire protocol. Designed for one-line MDM deployment against a
self-hosted rustdesk-server: a supporter using the unmodified rustdesk.exe
client connects, the controlled-side user gets a native Win32 approval
prompt, click Yes / No.
CLI surface
hello-agent.exe --install # register + start service
hello-agent.exe --uninstall # stop, delete, clean up
hello-agent.exe --config <BLOB> # admin-UI deploy string
hello-agent.exe --install --config <BLOB> # MDM one-liner
--config accepts both forms emitted by the rustdesk-server admin UI: the
reversed-base64 deploy string and the host=,key=,api=,relay= filename
form. Decoded via the upstream custom_server module, persisted via
hbb_common::config::Config::set_option.
Architecture
--service runs as a Session 0 LocalSystem service. It polls
WTSGetActiveConsoleSessionId and (re)spawns hello-agent.exe --server
into the active console session via librustdesk::platform::run_as_user,
handling the Session 0 → user-session token impersonation.
--server is the worker. It boots three concurrent components:
1. cm_popup: an IPC listener on the rustdesk `_cm` named pipe
2. librustdesk::start_server(true, false): the upstream protocol
stack — rendezvous mediator, NAT punch, IPC server, screen
capture, login validation, hbbs_http heartbeat / sysinfo sync
3. (implicit) ApproveMode::Click is pinned in config, so every
incoming connection routes through cm_popup
The popup mechanism reuses an existing upstream contract without any
patches to the protocol code: when a peer connects with no password,
Connection::start in the upstream code calls try_start_cm_ipc, which
ipc::connect-s the `_cm` pipe before falling back to spawning a Flutter
CM child. Since cm_popup is up first, step 1 succeeds; we read the
Data::Login{authorized:false} frame, show MessageBoxTimeoutW (Yes/No,
60s, top-most, system-modal), and reply Data::Authorize or Data::Close.
Source tree
src/main.rs CLI dispatcher + run_server() composition
src/cli.rs hand-rolled argv parser + unit tests
src/service.rs windows-service install/uninstall/dispatcher
src/config_import.rs --config blob decoding + persistence
src/cm_popup.rs _cm IPC listener + Win32 approval dialog
Vendoring
The upstream RustDesk crate is vendored under vendor/rustdesk/ — full
workspace including libs/{hbb_common, scrap, enigo, clipboard,
virtual_display, remote_printer}. This makes the build self-contained
(no submodules, no sibling-repo checkout in CI) and gives us freedom to
fork in a different direction later. Excluded from the vendor: .git,
target/, flutter/, appimage/, flatpak/, fastlane/, docs/, examples/,
ci/, build.py, Dockerfile, upstream README/CLAUDE/AGENTS/GEMINI.
One local divergence vs. upstream: vendor/rustdesk/src/lib.rs flips
`mod custom_server` → `pub mod custom_server` so config_import.rs can
call get_custom_server_from_string without going through the
ui_interface shim. Documented in README.md → "Re-syncing the vendored
copy".
CI
.gitea/workflows/build-windows.yml builds on a self-hosted Windows
runner with Rust 1.75, LLVM 15.0.6 (libclang for bindgen via libvpx-sys),
and a vcpkg cache. The vendored vcpkg.json drives x64-windows-static
deps. The workflow stages the resulting hello-agent.exe into
SignOutput\, reports authenticode signing status (warns on unsigned),
and uploads as artifact. ~15 min full build, faster on incremental.
Out of scope for this commit: Linux/macOS builds, code signing, MSI
packaging, coexistence with stock rustdesk on the same box (currently
shares the RustDesk APP_NAME and config dir).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pack icon.png into a multi-size icon.ico used by the Windows resource compiler.
|
||||
|
||||
ICO format note:
|
||||
Vista+ allows PNG-encoded frames inside ICO files, BUT the Microsoft
|
||||
resource compiler (rc.exe) only reliably accepts PNG payloads for the
|
||||
256x256 frame. PNG payloads for 16/32/48/64/128 are silently dropped
|
||||
(or worse — rc.exe writes a resource section Explorer can't decode and
|
||||
the EXE shows the generic icon). So we encode small sizes as DIB
|
||||
(BITMAPINFOHEADER + BGRA pixels + AND-mask) and keep PNG only for 256.
|
||||
|
||||
Requires Pillow. We use it to decode the source PNG and resample.
|
||||
|
||||
python3 -m venv .venv && .venv/bin/pip install Pillow
|
||||
.venv/bin/python3 resources/build_ico.py
|
||||
"""
|
||||
import io
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
sys.exit("Pillow required: pip install Pillow")
|
||||
|
||||
DIB_SIZES = [16, 32, 48, 64, 128] # BITMAPINFOHEADER + BGRA
|
||||
PNG_SIZES = [256] # PNG payload (preserves alpha cleanly at large size)
|
||||
HERE = Path(__file__).parent
|
||||
SRC = HERE / "icon.png"
|
||||
|
||||
|
||||
def encode_dib(img: Image.Image, size: int) -> bytes:
|
||||
"""Encode an RGBA image as a DIB-format ICO frame.
|
||||
|
||||
Layout: BITMAPINFOHEADER (40 bytes), then BGRA pixels bottom-up, then
|
||||
a 1-bit AND-mask (also bottom-up, row-padded to 4 bytes). The header
|
||||
declares double the actual height so Windows knows the AND-mask is
|
||||
appended — this is the (counter-intuitive but mandatory) ICO
|
||||
convention; the file is otherwise an ordinary 32bpp BMP minus the
|
||||
14-byte BITMAPFILEHEADER.
|
||||
"""
|
||||
img = img.convert("RGBA").resize((size, size), Image.LANCZOS)
|
||||
pixels = img.load()
|
||||
|
||||
# 32bpp + alpha — AND mask can be all zero (fully opaque-from-mask;
|
||||
# the per-pixel alpha is what Windows actually composites). Still
|
||||
# required structurally.
|
||||
bgra_rows = []
|
||||
for y in range(size - 1, -1, -1): # bottom-up
|
||||
row = bytearray()
|
||||
for x in range(size):
|
||||
r, g, b, a = pixels[x, y]
|
||||
row += bytes((b, g, r, a))
|
||||
bgra_rows.append(bytes(row))
|
||||
bgra = b"".join(bgra_rows)
|
||||
|
||||
mask_row_bytes = ((size + 31) // 32) * 4 # 4-byte aligned
|
||||
mask = b"\x00" * (mask_row_bytes * size)
|
||||
|
||||
header = struct.pack(
|
||||
"<IiiHHIIiiII",
|
||||
40, # biSize
|
||||
size, # biWidth
|
||||
size * 2, # biHeight (double — XOR + AND mask)
|
||||
1, # biPlanes
|
||||
32, # biBitCount
|
||||
0, # biCompression = BI_RGB
|
||||
len(bgra) + len(mask), # biSizeImage
|
||||
0, 0, 0, 0, # ppm, colors used, colors important
|
||||
)
|
||||
return header + bgra + mask
|
||||
|
||||
|
||||
def encode_png(img: Image.Image, size: int) -> bytes:
|
||||
img = img.convert("RGBA").resize((size, size), Image.LANCZOS)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not SRC.exists():
|
||||
sys.exit(f"missing {SRC}")
|
||||
src = Image.open(SRC)
|
||||
|
||||
frames = []
|
||||
for sz in DIB_SIZES:
|
||||
frames.append((sz, "dib", encode_dib(src, sz)))
|
||||
for sz in PNG_SIZES:
|
||||
frames.append((sz, "png", encode_png(src, sz)))
|
||||
|
||||
out = bytearray()
|
||||
# ICONDIR: reserved(2) + type=1(2) + count(2)
|
||||
out += struct.pack("<HHH", 0, 1, len(frames))
|
||||
|
||||
header_size = 6 + 16 * len(frames)
|
||||
offset = header_size
|
||||
for sz, _kind, payload in frames:
|
||||
w = 0 if sz == 256 else sz
|
||||
h = 0 if sz == 256 else sz
|
||||
out += struct.pack(
|
||||
"<BBBBHHII",
|
||||
w, h,
|
||||
0, # colorCount
|
||||
0, # reserved
|
||||
1, # planes
|
||||
32, # bitCount
|
||||
len(payload),
|
||||
offset,
|
||||
)
|
||||
offset += len(payload)
|
||||
|
||||
for _, _, payload in frames:
|
||||
out += payload
|
||||
|
||||
(HERE / "icon.ico").write_bytes(out)
|
||||
summary = ", ".join(f"{sz}{'·png' if k=='png' else ''}" for sz, k, _ in frames)
|
||||
print(f"wrote {HERE / 'icon.ico'} ({len(out)} bytes; frames: {summary})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Reference in New Issue
Block a user