f8ead215d8
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>
124 lines
4.0 KiB
Python
124 lines
4.0 KiB
Python
#!/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()
|