diff --git a/.cargo/config.toml b/.cargo/config.toml index 92052c1..3b25b33 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,6 @@ [target.x86_64-unknown-linux-gnu] linker = "x86_64-linux-gnu-gcc" + +[target.x86_64-pc-windows-gnu] +linker = "x86_64-w64-mingw32-gcc" +ar = "x86_64-w64-mingw32-ar" diff --git a/crates/client/build.rs b/crates/client/build.rs index 4558998..427ef10 100644 --- a/crates/client/build.rs +++ b/crates/client/build.rs @@ -22,14 +22,21 @@ fn main() { "windres" }; + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let out_dir = std::env::var("OUT_DIR").unwrap(); + let mut res = winres::WindowsResource::new(); - res.set_icon("../../assets/icon.ico"); + res.set_icon(&format!("{}/../../assets/icon.ico", manifest_dir)); res.set_toolkit_path("/usr"); res.set_windres_path(windres); res.set_ar_path("x86_64-w64-mingw32-ar"); match res.compile() { - Ok(_) => println!("cargo:warning=Icon embedded successfully via {windres}"), + Ok(_) => { + println!("cargo:warning=Icon embedded successfully via {windres}"); + // Pass resource.o directly as linker arg (avoids ld skipping unreferenced archive members) + println!("cargo:rustc-link-arg={}/resource.o", out_dir); + } Err(e) => { println!("cargo:warning=winres failed: {e}"); println!("cargo:warning=windres path used: {windres}"); diff --git a/remote b/remote new file mode 100755 index 0000000..069c193 Binary files /dev/null and b/remote differ diff --git a/remote.py b/remote.py deleted file mode 100644 index eb577a6..0000000 --- a/remote.py +++ /dev/null @@ -1,427 +0,0 @@ -#!/usr/bin/env python3 -""" -helios-remote CLI - Control devices connected to the Helios Remote Relay Server. - -Device labels are the sole identifiers. No session UUIDs. -Labels must be lowercase, no whitespace, only a-z 0-9 - _ -""" - -import argparse -import base64 -import os -import re -import sys -from pathlib import Path - -# ── Load config.env ────────────────────────────────────────────────────────── -_config_path = Path(__file__).parent / "config.env" -_config: dict = {} -if _config_path.exists(): - for _line in _config_path.read_text().splitlines(): - _line = _line.strip() - if _line and not _line.startswith("#") and "=" in _line: - k, v = _line.split("=", 1) - _config[k.strip()] = v.strip() - -BASE_URL = _config.get("HELIOS_REMOTE_URL", "").rstrip("/") -API_KEY = _config.get("HELIOS_REMOTE_API_KEY", "") - -if not BASE_URL or not API_KEY: - sys.exit(f"[helios-remote] ERROR: config.env missing or incomplete at {_config_path}") - -try: - import requests -except ImportError: - sys.exit("[helios-remote] ERROR: 'requests' not installed. Run: pip install requests") - - -LABEL_RE = re.compile(r'^[a-z0-9][a-z0-9_-]*$') - -def validate_label(label: str) -> str: - """Validate a device label.""" - if not LABEL_RE.match(label): - sys.exit( - f"[helios-remote] Invalid label '{label}'. " - f"Must be lowercase, no whitespace, only a-z 0-9 - _" - ) - return label - - -# ── HTTP helpers ───────────────────────────────────────────────────────────── - -def _headers() -> dict: - return {"X-Api-Key": API_KEY, "Content-Type": "application/json"} - - -def _req(method: str, path: str, timeout: int = 30, **kwargs): - url = f"{BASE_URL}{path}" - try: - resp = requests.request(method, url, headers=_headers(), timeout=timeout, **kwargs) - except requests.exceptions.ConnectionError as exc: - sys.exit(f"[helios-remote] CONNECTION ERROR: Cannot reach {url}\n → {exc}") - except requests.exceptions.Timeout: - sys.exit(f"[helios-remote] TIMEOUT: {url} did not respond within {timeout} s") - - if not resp.ok: - body = resp.text[:1000] if resp.text else "(empty body)" - sys.exit( - f"[helios-remote] HTTP {resp.status_code} {resp.reason}\n" - f" URL : {url}\n" - f" Method : {method.upper()}\n" - f" Body : {body}" - ) - return resp - - -# ── Window resolution ──────────────────────────────────────────────────────── - -def resolve_window(device: str, window_label: str) -> int: - """Resolve a window label to its HWND. Searches by label field.""" - resp = _req("GET", f"/devices/{device}/windows") - windows = resp.json().get("windows", []) - query = window_label.lower() - # Exact label match first - for w in windows: - if w.get("visible") and w.get("label", "") == query: - return int(w["id"]) - # Substring match on label - matches = [w for w in windows if w.get("visible") and query in w.get("label", "")] - if not matches: - # Fallback: substring match on title - matches = [w for w in windows if w.get("visible") and query in w.get("title", "").lower()] - if not matches: - raise SystemExit(f"[helios-remote] No visible window matching '{window_label}'") - if len(matches) > 1: - print(f"[helios-remote] Multiple matches for '{window_label}', using first:") - for w in matches: - print(f" {w.get('label', '?'):<30} {w['title']}") - return int(matches[0]["id"]) - - -# ── Commands ───────────────────────────────────────────────────────────────── - -def cmd_devices(_args): - """List all connected devices.""" - resp = _req("GET", "/devices") - data = resp.json() - devices = data.get("devices", []) - if not devices: - print("No devices connected.") - return - print(f"{'Device':<30}") - print("-" * 30) - for d in devices: - print(d.get("label", "?")) - - -def cmd_screenshot(args): - """Capture a screenshot. Argument is 'screen' for full screen or a window label.""" - device = validate_label(args.device) - target = args.target - out_path = Path("/tmp/helios-remote-screenshot.png") - - if target == "screen": - resp = _req("POST", f"/devices/{device}/screenshot") - else: - # Resolve window label to HWND - wid = resolve_window(device, target) - resp = _req("POST", f"/devices/{device}/windows/{wid}/screenshot") - - data = resp.json() - b64 = ( - data.get("image_base64") - or data.get("screenshot") - or data.get("image") - or data.get("data") - or data.get("png") - ) - if not b64: - sys.exit(f"[helios-remote] ERROR: No image in response. Keys: {list(data.keys())}") - if "," in b64: - b64 = b64.split(",", 1)[1] - out_path.write_bytes(base64.b64decode(b64)) - print(str(out_path)) - - -def cmd_exec(args): - """Run a shell command on the remote device.""" - device = validate_label(args.device) - command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts - body = {"command": command} - if args.timeout: - body["timeout_ms"] = args.timeout * 1000 - resp = _req("POST", f"/devices/{device}/exec", json=body, - timeout=max(35, (args.timeout or 30) + 5)) - data = resp.json() - - stdout = data.get("stdout") or data.get("output") or "" - stderr = data.get("stderr") or "" - exit_code = data.get("exit_code") - - if stdout: - print(stdout, end="" if stdout.endswith("\n") else "\n") - if stderr: - print("[stderr]", stderr, file=sys.stderr) - if exit_code is not None and int(exit_code) != 0: - print(f"[helios-remote] Command exited with code {exit_code}", file=sys.stderr) - sys.exit(int(exit_code)) - - -def cmd_windows(args): - """List windows on the remote device (visible only).""" - device = validate_label(args.device) - resp = _req("GET", f"/devices/{device}/windows") - data = resp.json() - windows = data.get("windows", []) - windows = [w for w in windows if w.get("visible")] - if not windows: - print("No windows returned.") - return - print(f"{'Label':<30} Title") - print("-" * 70) - for w in windows: - label = w.get("label", "?") - title = w.get("title", "") - print(f"{label:<30} {title}") - - -def cmd_minimize_all(args): - """Minimize all windows on the remote device.""" - device = validate_label(args.device) - _req("POST", f"/devices/{device}/windows/minimize-all") - print(f"All windows minimized on {device}.") - - -def cmd_focus(args): - """Bring a window to the foreground (by label).""" - device = validate_label(args.device) - wid = resolve_window(device, args.window) - _req("POST", f"/devices/{device}/windows/{wid}/focus") - print(f"Window '{args.window}' focused on {device}.") - - -def cmd_maximize(args): - """Maximize and focus a window (by label).""" - device = validate_label(args.device) - wid = resolve_window(device, args.window) - _req("POST", f"/devices/{device}/windows/{wid}/maximize") - print(f"Window '{args.window}' maximized on {device}.") - - -def cmd_version(args): - """Show relay, remote.py, and client commit — check if all are in sync.""" - device = validate_label(args.device) - - # 1. Relay (public, no auth) - try: - r = requests.get(f"{BASE_URL}/version", timeout=10) - relay = r.json() if r.ok else {"commit": f"HTTP {r.status_code}"} - except Exception as e: - relay = {"commit": str(e)} - - # 2. remote.py commit - import subprocess - try: - r = subprocess.run( - ["git", "log", "-1", "--format=%h", "--", __file__], - capture_output=True, text=True, - cwd=os.path.dirname(os.path.abspath(__file__)) - ) - script_commit = r.stdout.strip() or "unknown" - except Exception: - script_commit = "unknown" - - # 3. Client - try: - r2 = _req("GET", f"/devices/{device}/version") - client = r2.json() - except SystemExit: - client = {"commit": "unreachable"} - - relay_commit = relay.get("commit", "?") - client_commit = client.get("commit", "?") - all_same = relay_commit == script_commit == client_commit - - print(f" relay {relay_commit}") - print(f" remote.py {script_commit}") - print(f" client {client_commit}") - print(f" {'✅ all in sync' if all_same else '⚠️ OUT OF SYNC'}") - - -def cmd_logs(args): - """Fetch the last N lines of the client log file.""" - device = validate_label(args.device) - resp = _req("GET", f"/devices/{device}/logs", params={"lines": args.lines}) - data = resp.json() - if "error" in data: - sys.exit(f"[helios-remote] {data['error']}") - print(f"# {data.get('log_path', '?')} (last {args.lines} lines)") - print(data.get("content", "")) - - -def cmd_upload(args): - """Upload a local file to the remote device.""" - device = validate_label(args.device) - local_path = Path(args.local_path) - if not local_path.exists(): - sys.exit(f"[helios-remote] ERROR: Local file not found: {local_path}") - content_base64 = base64.b64encode(local_path.read_bytes()).decode() - _req("POST", f"/devices/{device}/upload", json={ - "path": args.remote_path, - "content_base64": content_base64, - }) - print(f"Uploaded {local_path} → {args.remote_path} on {device}.") - - -def cmd_download(args): - """Download a file from the remote device to a local path.""" - device = validate_label(args.device) - from urllib.parse import quote - encoded_path = quote(args.remote_path, safe="") - resp = _req("GET", f"/devices/{device}/download?path={encoded_path}") - data = resp.json() - b64 = data.get("content_base64", "") - if not b64: - sys.exit(f"[helios-remote] ERROR: No content in download response.") - local_path = Path(args.local_path) - local_path.parent.mkdir(parents=True, exist_ok=True) - local_path.write_bytes(base64.b64decode(b64)) - size = data.get("size", len(base64.b64decode(b64))) - print(f"Downloaded {args.remote_path} → {local_path} ({size} bytes).") - - -def cmd_prompt(args): - """Show a MessageBox on the remote PC asking the user to do something.""" - device = validate_label(args.device) - body = {"message": args.message} - if args.title: - body["title"] = args.title - resp = _req("POST", f"/devices/{device}/prompt", json=body) - data = resp.json() - answer = data.get("answer", "") - if answer: - print(answer) - else: - print("Prompt confirmed.") - - -def cmd_run(args): - """Launch a program on the remote device (fire-and-forget).""" - device = validate_label(args.device) - _req("POST", f"/devices/{device}/run", json={"program": args.program, "args": args.args}) - print(f"Started {args.program!r} on {device}.") - - -def cmd_clipboard_get(args): - """Get the clipboard contents from the remote device.""" - device = validate_label(args.device) - resp = _req("GET", f"/devices/{device}/clipboard") - data = resp.json() - print(data.get("text", "")) - - -def cmd_clipboard_set(args): - """Set the clipboard contents on the remote device.""" - device = validate_label(args.device) - _req("POST", f"/devices/{device}/clipboard", json={"text": args.text}) - print(f"Clipboard set ({len(args.text)} chars) on {device}.") - - -# ── CLI wiring ──────────────────────────────────────────────────────────────── - -def build_parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser( - prog="remote.py", - description="Control devices connected to the Helios Remote Relay Server.", - ) - sub = p.add_subparsers(dest="subcmd", required=True) - - sub.add_parser("devices", help="List all connected devices") - - sp = sub.add_parser("screenshot", help="Capture screenshot (screen or window label)") - sp.add_argument("device", help="Device label") - sp.add_argument("target", help="'screen' for full screen, or a window label") - - ep = sub.add_parser("exec", help="Run a shell command on the remote device") - ep.add_argument("device", help="Device label") - ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command", - help="Command (and arguments) to execute") - ep.add_argument("--timeout", type=int, default=None, metavar="SECONDS", - help="Timeout in seconds (default: 30)") - - wp = sub.add_parser("windows", help="List all visible windows on the remote device") - wp.add_argument("device", help="Device label") - - mp = sub.add_parser("minimize-all", help="Minimize all windows") - mp.add_argument("device", help="Device label") - - fp = sub.add_parser("focus", help="Bring a window to the foreground") - fp.add_argument("device", help="Device label") - fp.add_argument("window", help="Window label") - - xp = sub.add_parser("maximize", help="Maximize and focus a window") - xp.add_argument("device", help="Device label") - xp.add_argument("window", help="Window label") - - vp = sub.add_parser("version", help="Compare relay, remote.py, and client commits") - vp.add_argument("device", help="Device label") - - lgp = sub.add_parser("logs", help="Fetch last N lines of client log file") - lgp.add_argument("device", help="Device label") - lgp.add_argument("--lines", type=int, default=100, metavar="N") - - up = sub.add_parser("upload", help="Upload a local file to the remote device") - up.add_argument("device", help="Device label") - up.add_argument("local_path") - up.add_argument("remote_path") - - dp = sub.add_parser("download", help="Download a file from the remote device") - dp.add_argument("device", help="Device label") - dp.add_argument("remote_path") - dp.add_argument("local_path") - - pp = sub.add_parser("prompt", help="Show a MessageBox asking the user to do something") - pp.add_argument("device", help="Device label") - pp.add_argument("message") - pp.add_argument("--title", default=None) - - rp = sub.add_parser("run", help="Launch a program (fire-and-forget)") - rp.add_argument("device", help="Device label") - rp.add_argument("program") - rp.add_argument("args", nargs=argparse.REMAINDER) - - cgp = sub.add_parser("clipboard-get", help="Get clipboard contents") - cgp.add_argument("device", help="Device label") - - csp = sub.add_parser("clipboard-set", help="Set clipboard contents") - csp.add_argument("device", help="Device label") - csp.add_argument("text") - - return p - - -def main(): - parser = build_parser() - args = parser.parse_args() - - { - "devices": cmd_devices, - "screenshot": cmd_screenshot, - "exec": cmd_exec, - "windows": cmd_windows, - "minimize-all": cmd_minimize_all, - "focus": cmd_focus, - "maximize": cmd_maximize, - "version": cmd_version, - "logs": cmd_logs, - "upload": cmd_upload, - "download": cmd_download, - "prompt": cmd_prompt, - "run": cmd_run, - "clipboard-get": cmd_clipboard_get, - "clipboard-set": cmd_clipboard_set, - }[args.subcmd](args) - - -if __name__ == "__main__": - main()