diff --git a/.github/workflows/build-windows-agent.yml b/.github/workflows/build-windows-agent.yml index 825c3f9..1549bfe 100644 --- a/.github/workflows/build-windows-agent.yml +++ b/.github/workflows/build-windows-agent.yml @@ -1,8 +1,10 @@ -name: Build Windows Agent EXE & Publish GitHub Release +name: Build Windows Host Installer EXE & Publish GitHub Release on: workflow_dispatch: push: + branches: + - main tags: - 'v*' paths: @@ -14,7 +16,7 @@ permissions: contents: write jobs: - build-win64-agent: + build-win64-host: runs-on: windows-latest steps: - name: Checkout repository @@ -30,15 +32,16 @@ jobs: python -m pip install --upgrade pip pip install pyinstaller psutil websockets aiortc mss Pillow - - name: Compile standalone GothwadRemoteAgent-win64.exe + - name: Compile GUI Installer EXE (--noconsole --uac-admin) run: | - pyinstaller --onefile --collect-all av --collect-all aiortc --name GothwadRemoteAgent-win64 agent/gothwad_agent.py + pyinstaller --onefile --noconsole --uac-admin --paths agent --collect-all av --collect-all aiortc --name GothwadRemoteHostSetup agent/gothwad_agent.py - - name: Bundle Setup Zip (Agent EXE + Hyper-V & Installer Scripts) + - name: Prepare Release Assets (GUI Setup EXE + Zip Bundle) shell: pwsh run: | + Copy-Item dist/GothwadRemoteHostSetup.exe dist/GothwadRemoteAgent-win64.exe -Force New-Item -ItemType Directory -Force -Path release-bundle | Out-Null - Copy-Item dist/GothwadRemoteAgent-win64.exe release-bundle/ + Copy-Item dist/GothwadRemoteHostSetup.exe release-bundle/ Copy-Item scripts/host-setup-hyperv.ps1 release-bundle/ Copy-Item scripts/install-gothwad-agent.ps1 release-bundle/ Compress-Archive -Path release-bundle/* -DestinationPath dist/GothwadRemoteSetup-win64.zip -Force @@ -46,20 +49,32 @@ jobs: - name: Upload Build Artifacts uses: actions/upload-artifact@v4 with: - name: gothwad-remote-agent-win64 + name: gothwad-remote-host-win64 path: | + dist/GothwadRemoteHostSetup.exe dist/GothwadRemoteAgent-win64.exe dist/GothwadRemoteSetup-win64.zip - - name: Publish to GitHub Releases (on version tag v*) - if: startsWith(github.ref, 'refs/tags/v') + - name: Publish or Update Latest GitHub Release + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: pwsh run: | - $tag = "${{ github.ref_name }}" - gh release create $tag ` - dist/GothwadRemoteAgent-win64.exe ` - dist/GothwadRemoteSetup-win64.zip ` - --title "Gothwad Remote Desktop Agent $tag" ` - --generate-notes + $tag = if ("${{ github.ref }}".StartsWith("refs/tags/v")) { "${{ github.ref_name }}" } else { "v1.0.0" } + $exists = gh release view $tag 2>$null + if ($LASTEXITCODE -eq 0) { + gh release upload $tag ` + dist/GothwadRemoteHostSetup.exe ` + dist/GothwadRemoteAgent-win64.exe ` + dist/GothwadRemoteSetup-win64.zip ` + --clobber + } else { + gh release create $tag ` + dist/GothwadRemoteHostSetup.exe ` + dist/GothwadRemoteAgent-win64.exe ` + dist/GothwadRemoteSetup-win64.zip ` + --title "Gothwad Remote Desktop Host ($tag)" ` + --notes "Standalone Windows 11 GUI Installer & Background Host Service (GothwadRemoteHostSetup.exe). Download and double-click on Windows to enable remote access." ` + --latest + } diff --git a/README.md b/README.md index c01076c..a296842 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,12 @@ Modeled on Google Chrome Remote Desktop (`remotedesktop.google.com`), `GothwadTe - Clean three-section layout: **Remote Access**, **Remote Support**, and **Set up via PowerShell** (desktop left navigation rail + mobile bottom navigation bar). - Exact ecosystem theme palette: **`#212121`** in Dark mode and **`#ffffff`** in Light mode with instant theme switching. - **Universal Gothwad Account Menu** (`https://accounts.gothwadtech.com/widget/account-menu.js`) with multi-account (`u/0..5`) switching and `+n` avatar badge. -- **Mobile-Ready Full-Screen Viewer:** +- **Chrome Remote Desktop Installation Flow + Native GUI `.exe`:** + - Click **Turn on** inside `remotedesktop.gothwadtech.com` -> downloads **`GothwadRemoteHostSetup.exe`** -> choose Computer Name & 6-digit PIN -> launches `gothwad-remote://setup` directly into the Windows app. + - Or simply double-click **`GothwadRemoteHostSetup.exe`** on Windows (like an Android APK) to configure your Gothwad Account and 6-digit PIN inside the `#212121` native graphical installer with zero PowerShell commands. +- **Mobile-Ready Full-Screen Viewer + Administrator Tools:** - Collapsible floating top toolbar handle so 100% of your phone screen stays unobstructed. - - Dedicated mobile **Keyboard** button (triggers Android/iOS virtual keyboard) and **Direct Touch / Trackpad** cursor toggle. + - Dedicated mobile **Keyboard** button, **Direct Touch / Trackpad** cursor toggle, and a slide-over **Tools** drawer with **File Transfer (Upload/Download)**, **Administrator PowerShell**, and **Live Task Manager**. - **Main Desktop First, Background VM Optional:** - Primary click connects straight to your Windows 11 Main Desktop (with a visible Chrome Remote Desktop-style `"Your desktop is shared [Stop Sharing]"` banner on the host). - Optional secondary **Hyper-V Background VM** (`Max 4 GB RAM`, `60% CPU`, Wi-Fi `Default Switch` NAT) accessible from the device overflow menu or viewer toggle. diff --git a/agent/admin_tools.py b/agent/admin_tools.py new file mode 100644 index 0000000..1fbcab2 --- /dev/null +++ b/agent/admin_tools.py @@ -0,0 +1,198 @@ +""" +Administrator Remote Management Tools (Files, PowerShell, Processes & System Health) +==================================================================================== +Provides authenticated P2P handlers for browsing files, uploading/downloading files, +running Administrator PowerShell commands, and managing running Windows processes. +""" + +from __future__ import annotations + +import base64 +import os +import platform +import string +import subprocess +from pathlib import Path +from typing import Any, Dict, List + +try: + import psutil +except ImportError: # pragma: no cover + psutil = None # type: ignore[assignment] + +MAX_TRANSFER_BYTES = 8 * 1024 * 1024 # 8 MB per single P2P file transfer + + +def list_roots() -> List[Dict[str, Any]]: + if platform.system() == "Windows": + drives: List[Dict[str, Any]] = [] + for letter in string.ascii_uppercase: + root = f"{letter}:\\" + if os.path.exists(root): + try: + usage = psutil.disk_usage(root) + drives.append( + { + "name": f"{letter}: Drive", + "path": root, + "isDir": True, + "size": usage.total, + "free": usage.free, + } + ) + except Exception: + drives.append({"name": f"{letter}: Drive", "path": root, "isDir": True, "size": 0}) + home = str(Path.home()) + for folder in ("Desktop", "Downloads", "Documents"): + p = os.path.join(home, folder) + if os.path.isdir(p): + drives.insert(0, {"name": folder, "path": p, "isDir": True, "size": 0}) + return drives + return [{"name": "Home", "path": str(Path.home()), "isDir": True, "size": 0}] + + +def handle_fs_list(req_path: str) -> Dict[str, Any]: + if not req_path or req_path == "__roots__": + return {"type": "ADMIN_RES", "op": "FS_LIST", "path": "", "entries": list_roots()} + target = Path(req_path).expanduser().resolve() + if not target.exists() or not target.is_dir(): + return {"type": "ADMIN_RES", "op": "FS_LIST", "error": f"Folder not found: {req_path}"} + + entries: List[Dict[str, Any]] = [] + try: + for item in sorted(target.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))[:250]: + try: + st = item.stat() + entries.append( + { + "name": item.name, + "path": str(item), + "isDir": item.is_dir(), + "size": 0 if item.is_dir() else st.st_size, + "mtime": int(st.st_mtime), + } + ) + except Exception: + continue + except PermissionError: + return {"type": "ADMIN_RES", "op": "FS_LIST", "error": f"Access denied: {target}"} + + parent = str(target.parent) if target.parent != target else "__roots__" + return { + "type": "ADMIN_RES", + "op": "FS_LIST", + "path": str(target), + "parent": parent, + "entries": entries, + } + + +def handle_fs_read(file_path: str) -> Dict[str, Any]: + target = Path(file_path).expanduser().resolve() + if not target.is_file(): + return {"type": "ADMIN_RES", "op": "FS_READ", "error": "File not found"} + size = target.stat().st_size + if size > MAX_TRANSFER_BYTES: + return {"type": "ADMIN_RES", "op": "FS_READ", "error": "File exceeds 8 MB transfer limit"} + data_b64 = base64.b64encode(target.read_bytes()).decode("ascii") + return { + "type": "ADMIN_RES", + "op": "FS_READ", + "name": target.name, + "size": size, + "dataBase64": data_b64, + } + + +def handle_fs_write(dest_dir: str, filename: str, data_b64: str) -> Dict[str, Any]: + safe_name = os.path.basename(filename.strip()) or "uploaded.bin" + folder = Path(dest_dir).expanduser().resolve() if dest_dir else (Path.home() / "Downloads") + folder.mkdir(parents=True, exist_ok=True) + raw = base64.b64decode(data_b64) + if len(raw) > MAX_TRANSFER_BYTES: + return {"type": "ADMIN_RES", "op": "FS_WRITE", "error": "Upload exceeds 8 MB limit"} + out_file = folder / safe_name + out_file.write_bytes(raw) + return {"type": "ADMIN_RES", "op": "FS_WRITE", "path": str(out_file), "size": len(raw)} + + +def handle_exec_cmd(command: str) -> Dict[str, Any]: + if not command.strip(): + return {"type": "ADMIN_RES", "op": "EXEC_CMD", "output": "", "exitCode": 0} + try: + if platform.system() == "Windows": + proc = subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command], + capture_output=True, + text=True, + timeout=25, + ) + else: + proc = subprocess.run( + command, + shell=True, + capture_output=True, + text=True, + timeout=25, + ) + combined = (proc.stdout or "") + (("\n" + proc.stderr) if proc.stderr else "") + return { + "type": "ADMIN_RES", + "op": "EXEC_CMD", + "output": combined.strip()[-12000:], + "exitCode": proc.returncode, + } + except subprocess.TimeoutExpired: + return {"type": "ADMIN_RES", "op": "EXEC_CMD", "output": "Command timed out after 25s.", "exitCode": 124} + except Exception as exc: + return {"type": "ADMIN_RES", "op": "EXEC_CMD", "output": str(exc), "exitCode": 1} + + +def handle_sys_snapshot() -> Dict[str, Any]: + if psutil is None: + return { + "type": "ADMIN_RES", + "op": "SYS_SNAPSHOT", + "cpuPercent": 0, + "ramUsedMb": 0, + "ramTotalMb": 16384, + "batteryPercent": None, + "powerPlugged": None, + "processes": [], + } + mem = psutil.virtual_memory() + bat = getattr(psutil, "sensors_battery", lambda: None)() + procs: List[Dict[str, Any]] = [] + for p in psutil.process_iter(["pid", "name", "memory_info", "cpu_percent"]): + try: + info = p.info + rss_mb = round((info["memory_info"].rss if info.get("memory_info") else 0) / (1024 * 1024), 1) + procs.append( + { + "pid": info["pid"], + "name": info["name"] or "process", + "ramMb": rss_mb, + "cpu": round(float(info.get("cpu_percent") or 0.0), 1), + } + ) + except Exception: + continue + procs.sort(key=lambda x: x["ramMb"], reverse=True) + return { + "type": "ADMIN_RES", + "op": "SYS_SNAPSHOT", + "cpuPercent": round(psutil.cpu_percent(interval=None), 1), + "ramUsedMb": round(mem.used / (1024 * 1024)), + "ramTotalMb": round(mem.total / (1024 * 1024)), + "batteryPercent": round(bat.percent) if bat else None, + "powerPlugged": bool(bat.power_plugged) if bat else None, + "processes": procs[:35], + } + + +def handle_proc_kill(pid: int) -> Dict[str, Any]: + try: + psutil.Process(int(pid)).terminate() + return {"type": "ADMIN_RES", "op": "PROC_KILL", "pid": pid, "ok": True} + except Exception as exc: + return {"type": "ADMIN_RES", "op": "PROC_KILL", "pid": pid, "ok": False, "error": str(exc)} diff --git a/agent/gothwad_agent.py b/agent/gothwad_agent.py index 04ff42c..8d2dba3 100644 --- a/agent/gothwad_agent.py +++ b/agent/gothwad_agent.py @@ -1,325 +1,97 @@ #!/usr/bin/env python3 """ -Gothwad Remote Desktop — Zero-D1 WebRTC P2P Host & Hyper-V Agent -================================================================ -- Zero cloud database required! Registers on WebRTC P2P slot `grd--slot-`. -- Requires explicit PC owner consent during one-time pairing. -- Verifies viewer Gothwad username ACL + constant-time HMAC-SHA256 6-digit PIN proof. -- Displays a visible Chrome-Remote-Desktop-style "Desktop Shared [Stop Sharing]" - banner whenever Main Windows OS screen sharing is active. +Gothwad Remote Desktop Host (GothwadRemoteHostSetup.exe) +======================================================== +Unified Windows Host Application: +1. Double-click EXE (no args) -> Opens #212121 Native GUI Installer & Control Panel +2. Browser gothwad-remote://... -> Opens GUI pre-filled from remotedesktop.gothwadtech.com +3. Background --daemon / --run -> Runs headless WebRTC P2P Host & Admin RPC Daemon +4. CLI --pair / --grant-user -> Supports optional PowerShell headless setup """ from __future__ import annotations import argparse import asyncio -import base64 -import hashlib -import hmac -import io -import json -import os -import platform -import secrets -import subprocess import sys -import threading -import time -from pathlib import Path -from typing import Any, Dict -import psutil +from installer_win import ( + build_peer_id, + grant_shared_user, + load_config, + parse_protocol_url, + register_and_start_background_service, + sanitize_username, + save_config, + uninstall_windows_host, +) +from p2p_daemon import compute_expected_proof, run_p2p_daemon -CONFIG_DIR = Path(os.environ.get("PROGRAMDATA", "C:\\ProgramData")) / "GothwadRemote" -CONFIG_FILE = CONFIG_DIR / "agent-config.json" -PEERJS_WS = "wss://0.peerjs.com/peerjs?key=peerjs&id={peer_id}&token={token}&version=1.5.5" +__all__ = ["sanitize_username", "build_peer_id", "compute_expected_proof"] -def sanitize_username(raw: str) -> str: - clean = raw.strip().lower().split("@")[0] - return "".join(c for c in clean if c.isalnum() or c in "._-") - - -def build_peer_id(username: str, slot: int) -> str: - safe_user = sanitize_username(username).replace(".", "-").replace("_", "-") - safe_slot = max(1, min(6, int(slot))) - return f"grd-{safe_user}-slot-{safe_slot}" - - -def compute_expected_proof(pin: str, nonce: str, viewer: str, mode: str) -> str: - msg = f"{nonce}:{sanitize_username(viewer)}:{mode}".encode("utf-8") - return hmac.new(pin.encode("utf-8"), msg, hashlib.sha256).hexdigest() - - -def query_hyperv_state(vm_name: str) -> str: - if platform.system() != "Windows": - return "Running" - try: - res = subprocess.run( - ["powershell", "-NoProfile", "-Command", f"(Get-VM -Name '{vm_name}' -ErrorAction SilentlyContinue).State"], - capture_output=True, - text=True, - timeout=5, - ) - st = res.stdout.strip() - return st if st in ("Running", "Saved", "Paused", "Off") else "Off" - except Exception: - return "Off" - - -def run_hyperv_action(vm_name: str, action: str) -> None: - if platform.system() != "Windows": +def main() -> None: + parser = argparse.ArgumentParser(description="Gothwad Remote Desktop Host") + parser.add_argument("--gui", action="store_true", help="Open graphical installer & control panel") + parser.add_argument("--url", default="", help="Handle gothwad-remote:// browser setup URL") + parser.add_argument("--daemon", action="store_true", help="Run background WebRTC P2P host service") + parser.add_argument("--run", action="store_true", help="Alias for --daemon") + parser.add_argument("--pair", action="store_true", help="Headless CLI pairing") + parser.add_argument("--uninstall", action="store_true", help="Disable and remove host service") + parser.add_argument("--grant-user", default="", help="Grant access to another Gothwad username") + parser.add_argument("--scope", default="host_and_vm", help="Access scope: host_and_vm or vm_only") + parser.add_argument("--gothwad-user", default="", help="Owner Gothwad username") + parser.add_argument("--slot", type=int, default=1, help="P2P slot (1..6)") + parser.add_argument("--device-name", default="", help="Display name for this Windows PC") + parser.add_argument("--vm-name", default="RemoteWorkVM", help="Optional Hyper-V VM name") + parser.add_argument("--pin", default="", help="6 to 12 digit numeric PIN") + parser.add_argument("--confirm-owner-consent", action="store_true", help="Confirm PC owner consent") + args = parser.parse_args() + + # 1. Browser protocol launch: gothwad-remote://setup?owner=...&name=...&pin=... + if args.url: + from gui_app import launch_gui + + parsed = parse_protocol_url(args.url) + launch_gui(prefill=parsed) return - mapping = { - "start_vm": f"Start-VM -Name '{vm_name}'", - "save_vm": f"Save-VM -Name '{vm_name}'", - "restart_vm": f"Restart-VM -Name '{vm_name}' -Force", - } - cmd = mapping.get(action) - if cmd: - subprocess.run(["powershell", "-NoProfile", "-Command", cmd], capture_output=True, timeout=30) - -def show_host_sharing_banner(viewer_username: str, stop_event: threading.Event) -> None: - try: - import tkinter as tk - - root = tk.Tk() - root.title("Gothwad Remote Desktop") - root.attributes("-topmost", True) - root.overrideredirect(True) - root.configure(bg="#0f172a") - - frame = tk.Frame(root, bg="#0f172a", padx=14, pady=8, highlightthickness=1, highlightbackground="#38bdf8") - frame.pack() - tk.Label( - frame, - text=f"Your desktop is shared with {viewer_username}@gothwadtech.com", - fg="#f8fafc", - bg="#0f172a", - font=("Segoe UI", 10, "bold"), - ).pack(side=tk.LEFT, padx=(0, 12)) - - def on_stop() -> None: - stop_event.set() - root.destroy() + # 2. Headless CLI modes (PowerShell option) + if args.uninstall: + uninstall_windows_host() + print("Gothwad Remote Desktop host removed.") + return - tk.Button(frame, text="Stop Sharing", command=on_stop, bg="#0284c7", fg="white", relief=tk.FLAT, padx=10).pack( - side=tk.LEFT + if args.pair: + if not args.confirm_owner_consent: + sys.exit("ERROR: Explicit PC owner consent (--confirm-owner-consent) is required.") + cfg = save_config( + owner_raw=args.gothwad_user, + slot=args.slot, + device_name=args.device_name, + pin=args.pin, + vm_name=args.vm_name, ) - root.update_idletasks() - root.geometry(f"+{(root.winfo_screenwidth() - root.winfo_width()) // 2}+18") - - def check_stop() -> None: - if stop_event.is_set(): - root.destroy() - else: - root.after(500, check_stop) - - root.after(500, check_stop) - root.mainloop() - except Exception: - pass - - -def capture_screen_jpeg_b64() -> str | None: - try: - import mss - from PIL import Image - - with mss.mss() as sct: - mon = sct.monitors[1] - shot = sct.grab(mon) - img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") - img.thumbnail((1280, 720)) - buf = io.BytesIO() - img.save(buf, format="JPEG", quality=62) - return base64.b64encode(buf.getvalue()).decode("ascii") - except Exception: - return None - - -def pair_local(user: str, slot: int, name: str, vm_name: str, pin: str, consent: bool) -> None: - if not consent: - print("ERROR: Explicit PC owner consent (--confirm-owner-consent) is mandatory.") - sys.exit(1) - if not pin.isdigit() or not (6 <= len(pin) <= 12): - print("ERROR: PIN must be 6 to 12 digits.") - sys.exit(1) - - owner = sanitize_username(user) - peer_id = build_peer_id(owner, slot) - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - cfg = { - "ownerUsername": owner, - "slot": slot, - "peerId": peer_id, - "deviceName": name or platform.node() or "Windows-11-PC", - "vmName": vm_name, - "pin": pin, - "sharedUsers": {}, - } - CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8") - print(f"Paired! Peer ID '{peer_id}' ready for {owner}@gothwadtech.com (Zero-D1 P2P).") - - -def grant_share(username: str, scope: str) -> None: - if not CONFIG_FILE.exists(): - sys.exit("Run --pair first.") - cfg = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) - target = sanitize_username(username) - cfg.setdefault("sharedUsers", {})[target] = "host_and_vm" if scope == "host_and_vm" else "vm_only" - CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8") - print(f"Granted {target}@gothwadtech.com -> {cfg['sharedUsers'][target]}") - - -async def p2p_loop(cfg: Dict[str, Any]) -> None: - import websockets - from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription - - peer_id = cfg["peerId"] - failed_attempts = 0 - lockout_until = 0.0 - - while True: - token = secrets.token_hex(8) - ws_url = PEERJS_WS.format(peer_id=peer_id, token=token) - try: - async with websockets.connect(ws_url, ping_interval=15) as ws: - print(f"[Online] Listening on P2P slot: {peer_id}") - - async def heartbeat_sender() -> None: - while True: - await asyncio.sleep(5) - await ws.send(json.dumps({"type": "HEARTBEAT"})) - - hb_task = asyncio.create_task(heartbeat_sender()) - try: - async for raw in ws: - msg = json.loads(raw) - if msg.get("type") != "OFFER": - continue - src = msg["src"] - payload = msg["payload"] - conn_id = payload.get("connectionId") - pc = RTCPeerConnection( - RTCConfiguration([RTCIceServer(urls=["stun:stun.l.google.com:19302"])]) - ) - nonce = secrets.token_hex(16) - stop_banner = threading.Event() - authed_mode: list[str] = [] - - @pc.on("datachannel") - def on_dc(channel: Any) -> None: - mem = psutil.virtual_memory() - hello = { - "type": "HELLO", - "nonce": nonce, - "device": { - "slot": cfg["slot"], - "name": cfg["deviceName"], - "ownerUsername": cfg["ownerUsername"], - "sharedUsers": cfg.get("sharedUsers", {}), - "osInfo": f"{platform.system()} {platform.release()} · {round(mem.total / (1024**3))}GB RAM", - "vmName": cfg["vmName"], - "vmState": query_hyperv_state(cfg["vmName"]), - "hostRamTotalMb": round(mem.total / (1024 * 1024)), - "hostRamUsedMb": round(mem.used / (1024 * 1024)), - "hostCpuPercent": round(psutil.cpu_percent()), - }, - } - channel.send(json.dumps(hello)) - - @channel.on("message") - def on_message(pkt_raw: Any) -> None: - nonlocal failed_attempts, lockout_until - pkt = json.loads(pkt_raw) if isinstance(pkt_raw, str) else pkt_raw - if pkt.get("type") == "AUTH": - if time.time() < lockout_until: - channel.send(json.dumps({"type": "AUTH_ERR", "error": "Too many wrong PIN attempts. Wait 5 min."})) - return - viewer = sanitize_username(str(pkt.get("viewerUsername", ""))) - mode = str(pkt.get("targetMode", "hyperv_vm")) - proof = str(pkt.get("proof", "")) - is_owner = viewer == cfg["ownerUsername"] - shared_scope = cfg.get("sharedUsers", {}).get(viewer) - if not is_owner and not shared_scope: - channel.send(json.dumps({"type": "AUTH_ERR", "error": "Gothwad Account not authorized"})) - return - if mode == "host_os" and not is_owner and shared_scope != "host_and_vm": - channel.send(json.dumps({"type": "AUTH_ERR", "error": "Restricted to Background VM only"})) - return - expected = compute_expected_proof(cfg["pin"], nonce, viewer, mode) - if not hmac.compare_digest(expected, proof): - failed_attempts += 1 - if failed_attempts >= 5: - lockout_until = time.time() + 300 - channel.send(json.dumps({"type": "AUTH_ERR", "error": "Incorrect 6-digit PIN"})) - return - failed_attempts = 0 - authed_mode.append(mode) - channel.send(json.dumps({"type": "AUTH_OK", "targetMode": mode})) - if mode == "host_os": - threading.Thread(target=show_host_sharing_banner, args=(viewer, stop_banner), daemon=True).start() - - async def stream_frames() -> None: - while not stop_banner.is_set() and channel.readyState == "open": - b64 = capture_screen_jpeg_b64() - if b64: - channel.send(json.dumps({"type": "FRAME", "jpegBase64": b64})) - await asyncio.sleep(0.12) - - asyncio.create_task(stream_frames()) - elif authed_mode and pkt.get("type") == "POWER": - run_hyperv_action(cfg["vmName"], str(pkt.get("action", ""))) + register_and_start_background_service() + print(f"Paired '{cfg['deviceName']}' on {cfg['peerId']} for {cfg['ownerUsername']}@gothwadtech.com") + return - await pc.setRemoteDescription(RTCSessionDescription(sdp=payload["sdp"]["sdp"], type="offer")) - ans = await pc.createAnswer() - await pc.setLocalDescription(ans) - await ws.send( - json.dumps( - { - "type": "ANSWER", - "dst": src, - "payload": { - "sdp": {"type": "answer", "sdp": pc.localDescription.sdp}, - "type": payload.get("type", "data"), - "connectionId": conn_id, - }, - } - ) - ) - finally: - hb_task.cancel() - except Exception as exc: - print(f"[Reconnecting P2P signaling in 5s] {exc}") - await asyncio.sleep(5) + if args.grant_user: + cfg = grant_shared_user(args.grant_user, args.scope) + print(f"Updated shared access for {args.grant_user}: {cfg['sharedUsers']}") + return + # 3. Background P2P daemon mode (Scheduled Task) + if args.daemon or args.run: + if not load_config(): + sys.exit("Host is not configured yet. Launch GothwadRemoteHostSetup.exe to configure.") + asyncio.run(run_p2p_daemon()) + return -def main() -> None: - p = argparse.ArgumentParser(description="Gothwad Remote Desktop Zero-D1 P2P Agent") - p.add_argument("--pair", action="store_true") - p.add_argument("--run", action="store_true") - p.add_argument("--grant-user", default="") - p.add_argument("--scope", default="vm_only") - p.add_argument("--gothwad-user", default="") - p.add_argument("--slot", type=int, default=1) - p.add_argument("--device-name", default="") - p.add_argument("--vm-name", default="RemoteWorkVM") - p.add_argument("--pin", default="") - p.add_argument("--confirm-owner-consent", action="store_true") - args = p.parse_args() + # 4. Default when double-clicked on Windows (no arguments): open Native GUI! + from gui_app import launch_gui - if args.pair: - pair_local(args.gothwad_user, args.slot, args.device_name, args.vm_name, args.pin, args.confirm_owner_consent) - elif args.grant_user: - grant_share(args.grant_user, args.scope) - elif args.run: - if not CONFIG_FILE.exists(): - sys.exit("Config missing. Run --pair first.") - asyncio.run(p2p_loop(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))) - else: - p.print_help() + launch_gui() if __name__ == "__main__": diff --git a/agent/gui_app.py b/agent/gui_app.py new file mode 100644 index 0000000..a1cb41f --- /dev/null +++ b/agent/gui_app.py @@ -0,0 +1,339 @@ +""" +Native Windows GUI Installer & Host Control Panel (#212121 Dark Theme) +====================================================================== +Opens when a user double-clicks GothwadRemoteHostSetup.exe (like an Android APK) +or when launched from https://remotedesktop.gothwadtech.com via gothwad-remote://. +""" + +from __future__ import annotations + +import platform +import threading +import webbrowser +from typing import Dict, Optional + +from installer_win import ( + grant_shared_user, + install_windows_binary_only, + is_daemon_running, + load_config, + register_and_start_background_service, + save_config, + uninstall_windows_host, +) + +PORTAL_URL = "https://remotedesktop.gothwadtech.com" + + +def launch_gui(prefill: Optional[Dict[str, str]] = None) -> None: + import tkinter as tk + from tkinter import messagebox + + # Pre-install binary & URL protocol handler silently as soon as installer opens + threading.Thread(target=install_windows_binary_only, daemon=True).start() + + existing = load_config() or {} + from_browser = bool(prefill and prefill.get("owner")) + + root = tk.Tk() + root.title("Gothwad Remote Desktop Host") + root.geometry("470x560") + root.resizable(False, False) + root.configure(bg="#212121") + + # Center window on screen + root.update_idletasks() + x_pos = (root.winfo_screenwidth() - 470) // 2 + y_pos = max(40, (root.winfo_screenheight() - 560) // 2) + root.geometry(f"470x560+{x_pos}+{y_pos}") + + header = tk.Frame(root, bg="#212121", padx=24, pady=18) + header.pack(fill=tk.X) + + tk.Label( + header, + text="Gothwad Remote Desktop", + fg="#ffffff", + bg="#212121", + font=("Segoe UI", 15, "bold"), + ).pack(anchor="w") + + subtitle_text = ( + f"Browser setup request for {prefill['owner']}@gothwadtech.com" + if from_browser and prefill + else "Install & manage unattended remote access for this Windows PC" + ) + tk.Label( + header, + text=subtitle_text, + fg="#a3a3a3", + bg="#212121", + font=("Segoe UI", 9), + ).pack(anchor="w", pady=(2, 0)) + + status_var = tk.StringVar() + + def refresh_status_label() -> None: + cfg = load_config() + if cfg and (is_daemon_running() or not platform.system() == "Windows"): + status_var.set(f"● Active — {cfg['ownerUsername']}@gothwadtech.com ({cfg['peerId']})") + status_lbl.configure(fg="#34d399") + elif cfg: + status_var.set(f"○ Configured ({cfg['peerId']}) — Starting background service...") + status_lbl.configure(fg="#fbbf24") + else: + status_var.set("○ Not configured yet — Enter your Gothwad Account & PIN below") + status_lbl.configure(fg="#a3a3a3") + + status_card = tk.Frame( + root, bg="#2a2a2a", padx=14, pady=10, highlightthickness=1, highlightbackground="#3a3a3a" + ) + status_card.pack(fill=tk.X, padx=24, pady=(0, 14)) + status_lbl = tk.Label( + status_card, + textvariable=status_var, + fg="#a3a3a3", + bg="#2a2a2a", + font=("Segoe UI", 9, "bold"), + ) + status_lbl.pack(anchor="w") + + form = tk.Frame( + root, bg="#262626", padx=18, pady=16, highlightthickness=1, highlightbackground="#363636" + ) + form.pack(fill=tk.BOTH, expand=True, padx=24, pady=(0, 16)) + + def add_field(label: str, default_val: str = "", secret: bool = False) -> tk.Entry: + tk.Label(form, text=label, fg="#d4d4d4", bg="#262626", font=("Segoe UI", 9)).pack( + anchor="w", pady=(6, 3) + ) + entry = tk.Entry( + form, + bg="#191919", + fg="#ffffff", + insertbackground="#ffffff", + relief=tk.FLAT, + highlightthickness=1, + highlightbackground="#404040", + highlightcolor="#0494f4", + font=("Segoe UI", 10), + show="•" if secret else "", + ) + entry.insert(0, default_val) + entry.pack(fill=tk.X, ipady=6) + return entry + + init_owner = (prefill.get("owner") if prefill else "") or existing.get("ownerUsername", "") + init_name = ( + (prefill.get("name") if prefill else "") + or existing.get("deviceName", "") + or platform.node() + or "Home-Windows-PC" + ) + init_pin = (prefill.get("pin") if prefill else "") or existing.get("pin", "") + init_slot = int((prefill.get("slot") if prefill and prefill.get("slot") else 0) or existing.get("slot", 1)) + + owner_entry = add_field("Gothwad Username or Email (e.g. name@gothwadtech.com)", init_owner) + name_entry = add_field("Computer Name", init_name) + + pin_row = tk.Frame(form, bg="#262626") + pin_row.pack(fill=tk.X, pady=(6, 0)) + + pin_left = tk.Frame(pin_row, bg="#262626") + pin_left.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 6)) + tk.Label(pin_left, text="6-Digit PIN", fg="#d4d4d4", bg="#262626", font=("Segoe UI", 9)).pack( + anchor="w", pady=(0, 3) + ) + pin_entry = tk.Entry( + pin_left, + bg="#191919", + fg="#ffffff", + insertbackground="#ffffff", + relief=tk.FLAT, + highlightthickness=1, + highlightbackground="#404040", + highlightcolor="#0494f4", + font=("Consolas", 11), + show="•", + ) + pin_entry.insert(0, init_pin) + pin_entry.pack(fill=tk.X, ipady=6) + + pin_right = tk.Frame(pin_row, bg="#262626") + pin_right.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(6, 0)) + tk.Label(pin_right, text="Confirm PIN", fg="#d4d4d4", bg="#262626", font=("Segoe UI", 9)).pack( + anchor="w", pady=(0, 3) + ) + pin2_entry = tk.Entry( + pin_right, + bg="#191919", + fg="#ffffff", + insertbackground="#ffffff", + relief=tk.FLAT, + highlightthickness=1, + highlightbackground="#404040", + highlightcolor="#0494f4", + font=("Consolas", 11), + show="•", + ) + pin2_entry.insert(0, init_pin) + pin2_entry.pack(fill=tk.X, ipady=6) + + consent_var = tk.BooleanVar(value=bool(existing)) + tk.Checkbutton( + form, + text="I am this PC's owner (or have explicit owner consent) to enable remote access.", + variable=consent_var, + bg="#262626", + fg="#d4d4d4", + selectcolor="#191919", + activebackground="#262626", + activeforeground="#ffffff", + font=("Segoe UI", 8), + wraplength=390, + justify=tk.LEFT, + ).pack(anchor="w", pady=(12, 10)) + + def on_install_and_start() -> None: + if not consent_var.get(): + messagebox.showwarning( + "Owner Consent Required", + "Please confirm explicit PC owner consent before enabling remote access.", + ) + return + p1 = pin_entry.get().strip() + p2 = pin2_entry.get().strip() + if p1 != p2: + messagebox.showerror("PIN Mismatch", "The two PIN entries do not match.") + return + try: + cfg = save_config( + owner_raw=owner_entry.get(), + slot=init_slot, + device_name=name_entry.get(), + pin=p1, + ) + install_btn.configure(text="Installing & Starting...", state=tk.DISABLED) + root.update_idletasks() + register_and_start_background_service() + refresh_status_label() + install_btn.configure(text="Update Settings & Restart Host", state=tk.NORMAL) + messagebox.showinfo( + "Remote Access Enabled", + f"This Windows PC ('{cfg['deviceName']}') is now linked to " + f"{cfg['ownerUsername']}@gothwadtech.com.\n\n" + f"Open {PORTAL_URL} on your phone or another PC to connect.", + ) + except Exception as exc: + install_btn.configure(text="Install & Turn On Remote Access", state=tk.NORMAL) + messagebox.showerror("Setup Error", str(exc)) + + primary_label = ( + "Allow & Turn On Remote Access" + if from_browser + else ("Update Settings & Restart Host" if existing else "Install & Turn On Remote Access") + ) + install_btn = tk.Button( + form, + text=primary_label, + command=on_install_and_start, + bg="#0494f4", + fg="#ffffff", + activebackground="#0382d6", + activeforeground="#ffffff", + relief=tk.FLAT, + cursor="hand2", + font=("Segoe UI", 10, "bold"), + pady=9, + ) + install_btn.pack(fill=tk.X, pady=(2, 8)) + + bottom_row = tk.Frame(form, bg="#262626") + bottom_row.pack(fill=tk.X) + + tk.Button( + bottom_row, + text="Open Web Portal", + command=lambda: webbrowser.open(PORTAL_URL), + bg="#333333", + fg="#e5e5e5", + activebackground="#404040", + activeforeground="#ffffff", + relief=tk.FLAT, + font=("Segoe UI", 9), + padx=12, + pady=5, + ).pack(side=tk.LEFT) + + def on_share_quick() -> None: + share_win = tk.Toplevel(root) + share_win.title("Share Access") + share_win.geometry("340x180") + share_win.configure(bg="#262626") + tk.Label( + share_win, + text="Grant access to another @gothwadtech.com user:", + fg="#e5e5e5", + bg="#262626", + font=("Segoe UI", 9), + ).pack(anchor="w", padx=16, pady=(14, 4)) + u_ent = tk.Entry(share_win, bg="#191919", fg="#ffffff", insertbackground="#ffffff", relief=tk.FLAT) + u_ent.pack(fill=tk.X, padx=16, ipady=5) + + def save_share() -> None: + try: + grant_shared_user(u_ent.get(), "host_and_vm") + messagebox.showinfo("Shared", f"Granted full access to {u_ent.get().strip()}.") + share_win.destroy() + except Exception as exc: + messagebox.showerror("Error", str(exc)) + + tk.Button( + share_win, + text="Grant Access", + command=save_share, + bg="#0494f4", + fg="#ffffff", + relief=tk.FLAT, + pady=6, + ).pack(fill=tk.X, padx=16, pady=14) + + tk.Button( + bottom_row, + text="Share PC...", + command=on_share_quick, + bg="#333333", + fg="#e5e5e5", + activebackground="#404040", + activeforeground="#ffffff", + relief=tk.FLAT, + font=("Segoe UI", 9), + padx=12, + pady=5, + ).pack(side=tk.LEFT, padx=8) + + def on_uninstall() -> None: + if messagebox.askyesno( + "Disable Remote Access", + "Turn off Gothwad Remote Desktop and remove background startup from this PC?", + ): + uninstall_windows_host() + refresh_status_label() + messagebox.showinfo("Removed", "Remote access has been turned off on this computer.") + + tk.Button( + bottom_row, + text="Turn Off", + command=on_uninstall, + bg="#3b1d1d", + fg="#fca5a5", + activebackground="#522222", + activeforeground="#ffffff", + relief=tk.FLAT, + font=("Segoe UI", 9), + padx=12, + pady=5, + ).pack(side=tk.RIGHT) + + refresh_status_label() + root.mainloop() diff --git a/agent/installer_win.py b/agent/installer_win.py new file mode 100644 index 0000000..4f02aec --- /dev/null +++ b/agent/installer_win.py @@ -0,0 +1,221 @@ +""" +Windows Host Self-Installer, Registry URL Protocol & Service Manager +==================================================================== +Handles zero-PowerShell installation when a user opens GothwadRemoteHostSetup.exe: +- Copies binary to C:\\Program Files\\GothwadRemote\\GothwadRemoteHost.exe +- Registers gothwad-remote:// browser URL protocol handler (Chrome Remote Desktop style) +- Creates Windows Start Menu shortcut +- Registers & launches highest-privilege Scheduled Task ('GothwadRemoteHost') +""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, Optional +from urllib.parse import parse_qs, urlparse + +IS_WINDOWS = platform.system() == "Windows" + +INSTALL_DIR = Path(os.environ.get("PROGRAMFILES", "C:\\Program Files")) / "GothwadRemote" +INSTALLED_EXE = INSTALL_DIR / "GothwadRemoteHost.exe" +CONFIG_DIR = Path(os.environ.get("PROGRAMDATA", "C:\\ProgramData")) / "GothwadRemote" +CONFIG_FILE = CONFIG_DIR / "agent-config.json" +TASK_NAME = "GothwadRemoteHost" +LEGACY_TASK_NAME = "GothwadRemoteAgent" + + +def sanitize_username(raw: str) -> str: + clean = raw.strip().lower().split("@")[0] + return "".join(c for c in clean if c.isalnum() or c in "._-") + + +def build_peer_id(username: str, slot: int = 1) -> str: + safe_user = sanitize_username(username).replace(".", "-").replace("_", "-") + safe_slot = max(1, min(6, int(slot))) + return f"grd-{safe_user}-slot-{safe_slot}" + + +def load_config() -> Optional[Dict[str, Any]]: + if not CONFIG_FILE.exists(): + return None + try: + return json.loads(CONFIG_FILE.read_text(encoding="utf-8")) + except Exception: + return None + + +def save_config( + owner_raw: str, + slot: int, + device_name: str, + pin: str, + vm_name: str = "RemoteWorkVM", +) -> Dict[str, Any]: + owner = sanitize_username(owner_raw) + if not owner: + raise ValueError("Please enter a valid Gothwad username or email.") + if not pin.isdigit() or not (6 <= len(pin) <= 12): + raise ValueError("PIN must be 6 to 12 numeric digits.") + + existing = load_config() or {} + safe_slot = max(1, min(6, int(slot))) + peer_id = build_peer_id(owner, safe_slot) + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + cfg: Dict[str, Any] = { + "ownerUsername": owner, + "slot": safe_slot, + "peerId": peer_id, + "deviceName": device_name.strip() or platform.node() or "Windows-PC", + "vmName": vm_name.strip() or "RemoteWorkVM", + "pin": pin, + "sharedUsers": existing.get("sharedUsers", {}), + } + CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + return cfg + + +def grant_shared_user(username: str, scope: str) -> Dict[str, Any]: + cfg = load_config() + if not cfg: + raise RuntimeError("Host is not configured yet.") + target = sanitize_username(username) + if not target: + raise ValueError("Invalid Gothwad username.") + cfg.setdefault("sharedUsers", {})[target] = "host_and_vm" if scope == "host_and_vm" else "vm_only" + CONFIG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + return cfg + + +def parse_protocol_url(raw_url: str) -> Dict[str, str]: + parsed = urlparse(raw_url) + qs = parse_qs(parsed.query) + return { + "action": (parsed.netloc or parsed.path or "").strip("/").lower(), + "owner": (qs.get("owner") or [""])[0], + "slot": (qs.get("slot") or ["1"])[0], + "name": (qs.get("name") or [""])[0], + "pin": (qs.get("pin") or [""])[0], + } + + +def _target_exe_command() -> str: + if getattr(sys, "frozen", False): + return str(INSTALLED_EXE if INSTALLED_EXE.exists() else Path(sys.executable)) + return f'"{sys.executable}" "{Path(__file__).resolve().parent / "gothwad_agent.py"}"' + + +def register_url_protocol(exe_path: Path) -> None: + if not IS_WINDOWS: + return + import winreg + + cmd_val = f'"{exe_path}" --url "%1"' + for root_hive in (winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE): + try: + with winreg.CreateKey(root_hive, r"Software\Classes\gothwad-remote") as key: + winreg.SetValueEx(key, "", 0, winreg.REG_SZ, "URL:Gothwad Remote Desktop Protocol") + winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "") + with winreg.CreateKey(root_hive, r"Software\Classes\gothwad-remote\DefaultIcon") as icon_k: + winreg.SetValueEx(icon_k, "", 0, winreg.REG_SZ, f'"{exe_path}",0') + with winreg.CreateKey( + root_hive, r"Software\Classes\gothwad-remote\shell\open\command" + ) as cmd_k: + winreg.SetValueEx(cmd_k, "", 0, winreg.REG_SZ, cmd_val) + except OSError: + continue + + +def install_windows_binary_only() -> Path: + if not IS_WINDOWS or not getattr(sys, "frozen", False): + return Path(sys.executable) + src = Path(sys.executable).resolve() + INSTALL_DIR.mkdir(parents=True, exist_ok=True) + if src != INSTALLED_EXE.resolve(): + subprocess.run( + ["taskkill", "/F", "/IM", INSTALLED_EXE.name], + capture_output=True, + ) + shutil.copy2(src, INSTALLED_EXE) + register_url_protocol(INSTALLED_EXE) + _create_start_menu_shortcut(INSTALLED_EXE) + return INSTALLED_EXE + + +def _create_start_menu_shortcut(exe_path: Path) -> None: + if not IS_WINDOWS: + return + ps_script = ( + "$sm = [Environment]::GetFolderPath('CommonPrograms');" + "if (-not $sm) { $sm = [Environment]::GetFolderPath('Programs') };" + "$lnk = Join-Path $sm 'Gothwad Remote Desktop.lnk';" + "$ws = New-Object -ComObject WScript.Shell;" + "$s = $ws.CreateShortcut($lnk);" + f"$s.TargetPath = '{exe_path}';" + "$s.Description = 'Gothwad Remote Desktop Host Control Panel';" + "$s.Save()" + ) + subprocess.run(["powershell", "-NoProfile", "-Command", ps_script], capture_output=True) + + +def register_and_start_background_service() -> None: + if not IS_WINDOWS: + return + exe_path = install_windows_binary_only() + if getattr(sys, "frozen", False): + exec_bin = str(exe_path) + exec_args = "--daemon" + else: + script_path = Path(__file__).resolve().parent / "gothwad_agent.py" + exec_bin = sys.executable + exec_args = f'"{script_path}" --daemon' + + ps_cmd = f""" +$ErrorActionPreference = 'SilentlyContinue' +Unregister-ScheduledTask -TaskName '{LEGACY_TASK_NAME}' -Confirm:$false +Stop-ScheduledTask -TaskName '{TASK_NAME}' +$action = New-ScheduledTaskAction -Execute '{exec_bin}' -Argument '{exec_args}' +$tBoot = New-ScheduledTaskTrigger -AtStartup +$tLogon = New-ScheduledTaskTrigger -AtLogOn +$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit ([TimeSpan]::Zero) -RestartCount 99 -RestartInterval (New-TimeSpan -Minutes 1) +Register-ScheduledTask -TaskName '{TASK_NAME}' -Description 'Gothwad Remote Desktop Host Service' -Action $action -Trigger @($tBoot, $tLogon) -Settings $settings -RunLevel Highest -Force | Out-Null +Start-ScheduledTask -TaskName '{TASK_NAME}' +powercfg /change standby-timeout-ac 0 +""" + subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], capture_output=True) + + +def is_daemon_running() -> bool: + if not IS_WINDOWS: + return False + res = subprocess.run( + [ + "powershell", + "-NoProfile", + "-Command", + f"(Get-ScheduledTask -TaskName '{TASK_NAME}' -ErrorAction SilentlyContinue).State", + ], + capture_output=True, + text=True, + ) + return res.stdout.strip().lower() in ("running", "ready") + + +def uninstall_windows_host() -> None: + if IS_WINDOWS: + ps_cmd = f""" +$ErrorActionPreference = 'SilentlyContinue' +Stop-ScheduledTask -TaskName '{TASK_NAME}' +Unregister-ScheduledTask -TaskName '{TASK_NAME}' -Confirm:$false +Unregister-ScheduledTask -TaskName '{LEGACY_TASK_NAME}' -Confirm:$false +Remove-Item -Recurse -Force 'HKCU:\\Software\\Classes\\gothwad-remote' +Remove-Item -Recurse -Force 'HKLM:\\Software\\Classes\\gothwad-remote' +""" + subprocess.run(["powershell", "-NoProfile", "-Command", ps_cmd], capture_output=True) + if CONFIG_FILE.exists(): + CONFIG_FILE.unlink(missing_ok=True) diff --git a/agent/p2p_daemon.py b/agent/p2p_daemon.py new file mode 100644 index 0000000..1c00daf --- /dev/null +++ b/agent/p2p_daemon.py @@ -0,0 +1,399 @@ +""" +Zero-D1 WebRTC P2P Host Daemon (Screen Stream, Win32 Input & Admin RPC) +======================================================================= +Connects to PeerJS signaling with STUN + OpenRelay TURN (CGNAT traversal), +verifies HMAC-SHA256 6-digit PIN proofs, streams the desktop, executes native +Win32 mouse/keyboard input, and handles authenticated Administrator tools. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import io +import json +import platform +import secrets +import subprocess +import threading +import time +from typing import Any, Dict, List + +try: + import psutil +except ImportError: # pragma: no cover + psutil = None # type: ignore[assignment] + +from admin_tools import ( + handle_exec_cmd, + handle_fs_list, + handle_fs_read, + handle_fs_write, + handle_proc_kill, + handle_sys_snapshot, +) +from installer_win import load_config, sanitize_username +from win_input import ( + handle_key_combo, + handle_key_packet, + handle_mouse_packet, + send_unicode_text, + set_host_clipboard, +) + +PEERJS_WS = "wss://0.peerjs.com/peerjs?key=peerjs&id={peer_id}&token={token}&version=1.5.5" + + +def compute_expected_proof(pin: str, nonce: str, viewer: str, mode: str) -> str: + msg = f"{nonce}:{sanitize_username(viewer)}:{mode}".encode("utf-8") + return hmac.new(pin.encode("utf-8"), msg, hashlib.sha256).hexdigest() + + +def query_hyperv_state(vm_name: str) -> str: + if platform.system() != "Windows": + return "Off" + try: + res = subprocess.run( + [ + "powershell", + "-NoProfile", + "-Command", + f"(Get-VM -Name '{vm_name}' -ErrorAction SilentlyContinue).State", + ], + capture_output=True, + text=True, + timeout=5, + ) + st = res.stdout.strip() + return st if st in ("Running", "Saved", "Paused", "Off") else "Off" + except Exception: + return "Off" + + +def run_hyperv_action(vm_name: str, action: str) -> None: + if platform.system() != "Windows": + return + mapping = { + "start_vm": f"Start-VM -Name '{vm_name}'", + "save_vm": f"Save-VM -Name '{vm_name}'", + "restart_vm": f"Restart-VM -Name '{vm_name}' -Force", + } + cmd = mapping.get(action) + if cmd: + subprocess.run(["powershell", "-NoProfile", "-Command", cmd], capture_output=True, timeout=30) + + +def show_host_sharing_banner(viewer_username: str, stop_event: threading.Event) -> None: + try: + import tkinter as tk + + root = tk.Tk() + root.title("Gothwad Remote Desktop") + root.attributes("-topmost", True) + root.overrideredirect(True) + root.configure(bg="#212121") + + frame = tk.Frame( + root, + bg="#212121", + padx=14, + pady=7, + highlightthickness=1, + highlightbackground="#404040", + ) + frame.pack() + tk.Label( + frame, + text=f"Your desktop is currently shared with {viewer_username}@gothwadtech.com", + fg="#f5f5f5", + bg="#212121", + font=("Segoe UI", 9), + ).pack(side=tk.LEFT, padx=(0, 12)) + + def on_stop() -> None: + stop_event.set() + root.destroy() + + tk.Button( + frame, + text="Stop Sharing", + command=on_stop, + bg="#0494f4", + fg="white", + activebackground="#0382d6", + activeforeground="white", + relief=tk.FLAT, + padx=10, + pady=2, + font=("Segoe UI", 9, "bold"), + ).pack(side=tk.LEFT) + + root.update_idletasks() + root.geometry(f"+{(root.winfo_screenwidth() - root.winfo_width()) // 2}+12") + + def check_stop() -> None: + if stop_event.is_set(): + root.destroy() + else: + root.after(400, check_stop) + + root.after(400, check_stop) + root.mainloop() + except Exception: + pass + + +def capture_screen_jpeg_b64() -> str | None: + try: + import mss + from PIL import Image + + with mss.mss() as sct: + mon = sct.monitors[1] + shot = sct.grab(mon) + img = Image.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX") + img.thumbnail((1366, 768)) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=65, optimize=True) + return base64.b64encode(buf.getvalue()).decode("ascii") + except Exception: + return None + + +def _dispatch_admin_req(pkt: Dict[str, Any]) -> Dict[str, Any]: + op = str(pkt.get("op", "")) + req_id = pkt.get("reqId") + if op == "FS_LIST": + res = handle_fs_list(str(pkt.get("path", ""))) + elif op == "FS_READ": + res = handle_fs_read(str(pkt.get("path", ""))) + elif op == "FS_WRITE": + res = handle_fs_write( + str(pkt.get("dir", "")), + str(pkt.get("name", "")), + str(pkt.get("dataBase64", "")), + ) + elif op == "EXEC_CMD": + res = handle_exec_cmd(str(pkt.get("command", ""))) + elif op == "SYS_SNAPSHOT": + res = handle_sys_snapshot() + elif op == "PROC_KILL": + res = handle_proc_kill(int(pkt.get("pid", 0))) + else: + res = {"type": "ADMIN_RES", "op": op, "error": f"Unsupported operation: {op}"} + if req_id is not None: + res["reqId"] = req_id + return res + + +async def run_p2p_daemon() -> None: + import websockets + from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription + + failed_attempts = 0 + lockout_until = 0.0 + + ice_servers = [ + RTCIceServer(urls=["stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"]), + RTCIceServer( + urls=[ + "turn:openrelay.metered.ca:80", + "turn:openrelay.metered.ca:443", + "turn:openrelay.metered.ca:443?transport=tcp", + ], + username="openrelay", + credential="openrelay", + ), + ] + + while True: + cfg = load_config() + if not cfg: + await asyncio.sleep(3) + continue + + peer_id = cfg["peerId"] + token = secrets.token_hex(8) + ws_url = PEERJS_WS.format(peer_id=peer_id, token=token) + + try: + async with websockets.connect(ws_url, ping_interval=15) as ws: + + async def heartbeat_sender() -> None: + while True: + await asyncio.sleep(5) + await ws.send(json.dumps({"type": "HEARTBEAT"})) + + hb_task = asyncio.create_task(heartbeat_sender()) + try: + async for raw in ws: + fresh_cfg = load_config() or cfg + msg = json.loads(raw) + if msg.get("type") != "OFFER": + continue + src = msg["src"] + payload = msg["payload"] + conn_id = payload.get("connectionId") + pc = RTCPeerConnection(RTCConfiguration(iceServers=ice_servers)) + nonce = secrets.token_hex(16) + stop_banner = threading.Event() + session_state: Dict[str, Any] = {"authed": False, "admin": False, "mode": ""} + + @pc.on("datachannel") + def on_dc(channel: Any) -> None: + total_mb = round(psutil.virtual_memory().total / (1024 * 1024)) if psutil else 16384 + used_mb = round(psutil.virtual_memory().used / (1024 * 1024)) if psutil else 4096 + cpu_pct = round(psutil.cpu_percent()) if psutil else 10 + hello = { + "type": "HELLO", + "nonce": nonce, + "device": { + "slot": fresh_cfg["slot"], + "name": fresh_cfg["deviceName"], + "ownerUsername": fresh_cfg["ownerUsername"], + "sharedUsers": fresh_cfg.get("sharedUsers", {}), + "osInfo": f"{platform.system()} {platform.release()} · {round(total_mb / 1024)} GB RAM", + "vmName": fresh_cfg["vmName"], + "vmState": query_hyperv_state(fresh_cfg["vmName"]), + "hostRamTotalMb": total_mb, + "hostRamUsedMb": used_mb, + "hostCpuPercent": cpu_pct, + }, + } + channel.send(json.dumps(hello)) + + @channel.on("close") + def on_close() -> None: + stop_banner.set() + + @channel.on("message") + def on_message(pkt_raw: Any) -> None: + nonlocal failed_attempts, lockout_until + pkt = json.loads(pkt_raw) if isinstance(pkt_raw, str) else pkt_raw + ptype = pkt.get("type") + + if ptype == "AUTH": + if time.time() < lockout_until: + channel.send( + json.dumps( + { + "type": "AUTH_ERR", + "error": "Too many failed PIN attempts. Try again in 5 minutes.", + } + ) + ) + return + viewer = sanitize_username(str(pkt.get("viewerUsername", ""))) + mode = str(pkt.get("targetMode", "host_os")) + proof = str(pkt.get("proof", "")) + is_owner = viewer == fresh_cfg["ownerUsername"] + shared_scope = fresh_cfg.get("sharedUsers", {}).get(viewer) + if not is_owner and not shared_scope: + channel.send( + json.dumps( + { + "type": "AUTH_ERR", + "error": "Your Gothwad Account is not authorized on this computer.", + } + ) + ) + return + if mode == "host_os" and not is_owner and shared_scope != "host_and_vm": + channel.send( + json.dumps( + { + "type": "AUTH_ERR", + "error": "Your account only has Background VM access.", + } + ) + ) + return + expected = compute_expected_proof(fresh_cfg["pin"], nonce, viewer, mode) + if not hmac.compare_digest(expected, proof): + failed_attempts += 1 + if failed_attempts >= 5: + lockout_until = time.time() + 300 + channel.send( + json.dumps({"type": "AUTH_ERR", "error": "Incorrect 6-digit PIN."}) + ) + return + + failed_attempts = 0 + session_state["authed"] = True + session_state["admin"] = bool(is_owner or shared_scope == "host_and_vm") + session_state["mode"] = mode + channel.send( + json.dumps( + { + "type": "AUTH_OK", + "targetMode": mode, + "adminEnabled": session_state["admin"], + } + ) + ) + if mode == "host_os": + threading.Thread( + target=show_host_sharing_banner, + args=(viewer, stop_banner), + daemon=True, + ).start() + + async def stream_frames() -> None: + while not stop_banner.is_set() and channel.readyState == "open": + b64 = capture_screen_jpeg_b64() + if b64: + channel.send(json.dumps({"type": "FRAME", "jpegBase64": b64})) + await asyncio.sleep(0.09) + + asyncio.create_task(stream_frames()) + return + + if not session_state["authed"]: + return + + if ptype == "MOUSE" and session_state["admin"]: + handle_mouse_packet(pkt) + elif ptype == "KEY" and session_state["admin"]: + handle_key_packet(pkt) + elif ptype == "TEXT" and session_state["admin"]: + send_unicode_text(str(pkt.get("text", ""))) + elif ptype == "KEY_COMBO" and session_state["admin"]: + keys: List[str] = [str(k) for k in (pkt.get("keys") or [])] + handle_key_combo(keys) + elif ptype == "CLIPBOARD" and session_state["admin"]: + set_host_clipboard(str(pkt.get("text", ""))) + elif ptype == "POWER": + run_hyperv_action(fresh_cfg["vmName"], str(pkt.get("action", ""))) + elif ptype == "ADMIN_REQ" and session_state["admin"]: + + async def _handle_admin(req_pkt: Dict[str, Any]) -> None: + res = await asyncio.to_thread(_dispatch_admin_req, req_pkt) + if channel.readyState == "open": + channel.send(json.dumps(res)) + + asyncio.create_task(_handle_admin(pkt)) + + await pc.setRemoteDescription( + RTCSessionDescription(sdp=payload["sdp"]["sdp"], type="offer") + ) + ans = await pc.createAnswer() + await pc.setLocalDescription(ans) + await ws.send( + json.dumps( + { + "type": "ANSWER", + "dst": src, + "payload": { + "sdp": {"type": "answer", "sdp": pc.localDescription.sdp}, + "type": payload.get("type", "data"), + "connectionId": conn_id, + }, + } + ) + ) + finally: + hb_task.cancel() + except Exception: + await asyncio.sleep(4) diff --git a/agent/win_input.py b/agent/win_input.py new file mode 100644 index 0000000..13ba71c --- /dev/null +++ b/agent/win_input.py @@ -0,0 +1,241 @@ +""" +Native Windows Win32 Input & Clipboard Engine (ctypes SendInput) +================================================================ +Executes low-latency mouse movements, clicks, scroll wheel events, Unicode text, +and virtual-key shortcuts on Windows without external binary dependencies. +""" + +from __future__ import annotations + +import platform +import subprocess +from typing import Any, Dict, List + +IS_WINDOWS = platform.system() == "Windows" + +if IS_WINDOWS: + import ctypes + from ctypes import wintypes + + user32 = ctypes.WinDLL("user32", use_last_error=True) + + INPUT_MOUSE = 0 + INPUT_KEYBOARD = 1 + + MOUSEEVENTF_MOVE = 0x0001 + MOUSEEVENTF_LEFTDOWN = 0x0002 + MOUSEEVENTF_LEFTUP = 0x0004 + MOUSEEVENTF_RIGHTDOWN = 0x0008 + MOUSEEVENTF_RIGHTUP = 0x0010 + MOUSEEVENTF_MIDDLEDOWN = 0x0020 + MOUSEEVENTF_MIDDLEUP = 0x0040 + MOUSEEVENTF_WHEEL = 0x0800 + MOUSEEVENTF_ABSOLUTE = 0x8000 + MOUSEEVENTF_VIRTUALDESK = 0x4000 + + KEYEVENTF_KEYUP = 0x0002 + KEYEVENTF_UNICODE = 0x0004 + + ULONG_PTR = wintypes.WPARAM + + class MOUSEINPUT(ctypes.Structure): + _fields_ = [ + ("dx", wintypes.LONG), + ("dy", wintypes.LONG), + ("mouseData", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ULONG_PTR), + ] + + class KEYBDINPUT(ctypes.Structure): + _fields_ = [ + ("wVk", wintypes.WORD), + ("wScan", wintypes.WORD), + ("dwFlags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", ULONG_PTR), + ] + + class _INPUTunion(ctypes.Union): + _fields_ = [("mi", MOUSEINPUT), ("ki", KEYBDINPUT)] + + class INPUT(ctypes.Structure): + _fields_ = [("type", wintypes.DWORD), ("union", _INPUTunion)] + + VK_MAP: Dict[str, int] = { + "backspace": 0x08, + "tab": 0x09, + "enter": 0x0D, + "shift": 0x10, + "control": 0x11, + "ctrl": 0x11, + "alt": 0x12, + "escape": 0x1B, + "esc": 0x1B, + " ": 0x20, + "space": 0x20, + "pageup": 0x21, + "pagedown": 0x22, + "end": 0x23, + "home": 0x24, + "arrowleft": 0x25, + "arrowup": 0x26, + "arrowright": 0x27, + "arrowdown": 0x28, + "insert": 0x2D, + "delete": 0x2E, + "meta": 0x5B, + "win": 0x5B, + "f1": 0x70, + "f2": 0x71, + "f3": 0x72, + "f4": 0x73, + "f5": 0x74, + "f6": 0x75, + "f7": 0x76, + "f8": 0x77, + "f9": 0x78, + "f10": 0x79, + "f11": 0x7A, + "f12": 0x7B, + } + + def _send_inputs(items: List[INPUT]) -> None: + if not items: + return + arr = (INPUT * len(items))(*items) + user32.SendInput(len(items), ctypes.byref(arr), ctypes.sizeof(INPUT)) + + def _make_mouse(flags: int, dx: int = 0, dy: int = 0, data: int = 0) -> INPUT: + inp = INPUT(type=INPUT_MOUSE) + inp.union.mi = MOUSEINPUT(dx=dx, dy=dy, mouseData=data, dwFlags=flags, time=0, dwExtraInfo=0) + return inp + + def _make_vk(vk: int, up: bool = False) -> INPUT: + inp = INPUT(type=INPUT_KEYBOARD) + inp.union.ki = KEYBDINPUT( + wVk=vk, + wScan=0, + dwFlags=KEYEVENTF_KEYUP if up else 0, + time=0, + dwExtraInfo=0, + ) + return inp + + def _make_unicode(ch: str, up: bool = False) -> INPUT: + inp = INPUT(type=INPUT_KEYBOARD) + flags = KEYEVENTF_UNICODE | (KEYEVENTF_KEYUP if up else 0) + inp.union.ki = KEYBDINPUT(wVk=0, wScan=ord(ch), dwFlags=flags, time=0, dwExtraInfo=0) + return inp + + +def handle_mouse_packet(pkt: Dict[str, Any]) -> None: + if not IS_WINDOWS: + return + try: + x_norm = max(0.0, min(1.0, float(pkt.get("x", 0.5)))) + y_norm = max(0.0, min(1.0, float(pkt.get("y", 0.5)))) + abs_x = int(x_norm * 65535) + abs_y = int(y_norm * 65535) + move_flags = MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK + + action = str(pkt.get("action", "move")) + events: List[Any] = [_make_mouse(move_flags, abs_x, abs_y)] + + if action == "click": + events.append(_make_mouse(MOUSEEVENTF_LEFTDOWN)) + events.append(_make_mouse(MOUSEEVENTF_LEFTUP)) + elif action == "right_click": + events.append(_make_mouse(MOUSEEVENTF_RIGHTDOWN)) + events.append(_make_mouse(MOUSEEVENTF_RIGHTUP)) + elif action == "double_click": + events.extend( + [ + _make_mouse(MOUSEEVENTF_LEFTDOWN), + _make_mouse(MOUSEEVENTF_LEFTUP), + _make_mouse(MOUSEEVENTF_LEFTDOWN), + _make_mouse(MOUSEEVENTF_LEFTUP), + ] + ) + elif action == "down": + events.append(_make_mouse(MOUSEEVENTF_LEFTDOWN)) + elif action == "up": + events.append(_make_mouse(MOUSEEVENTF_LEFTUP)) + elif action == "wheel": + delta = int(pkt.get("deltaY", -120)) + events.append(_make_mouse(MOUSEEVENTF_WHEEL, data=delta & 0xFFFFFFFF)) + + _send_inputs(events) + except Exception: + pass + + +def send_unicode_text(text: str) -> None: + if not IS_WINDOWS or not text: + return + try: + events: List[Any] = [] + for ch in text[:256]: + if ch == "\n": + events.extend([_make_vk(0x0D, False), _make_vk(0x0D, True)]) + else: + events.extend([_make_unicode(ch, False), _make_unicode(ch, True)]) + _send_inputs(events) + except Exception: + pass + + +def handle_key_packet(pkt: Dict[str, Any]) -> None: + if not IS_WINDOWS: + return + try: + key_name = str(pkt.get("key", "")) + if not key_name: + return + lower = key_name.lower() + if lower in VK_MAP: + vk = VK_MAP[lower] + _send_inputs([_make_vk(vk, False), _make_vk(vk, True)]) + elif len(key_name) == 1: + send_unicode_text(key_name) + except Exception: + pass + + +def handle_key_combo(keys: List[str]) -> None: + if not IS_WINDOWS or not keys: + return + try: + normalized = [k.lower() for k in keys] + # Note: Windows Secure Attention Sequence (Ctrl+Alt+Del) requires a signed SAS policy; + # fall back to Ctrl+Shift+Esc (Task Manager) if SAS is restricted. + if set(normalized) == {"control", "alt", "delete"}: + vks = [0x11, 0x10, 0x1B] + else: + vks = [] + for k in normalized: + if k in VK_MAP: + vks.append(VK_MAP[k]) + elif len(k) == 1 and k.isalnum(): + vks.append(ord(k.upper())) + downs = [_make_vk(vk, False) for vk in vks] + ups = [_make_vk(vk, True) for vk in reversed(vks)] + _send_inputs(downs + ups) + except Exception: + pass + + +def set_host_clipboard(text: str) -> None: + if not IS_WINDOWS or not text: + return + try: + subprocess.run( + ["powershell", "-NoProfile", "-Command", "Set-Clipboard -Value $input"], + input=text, + text=True, + capture_output=True, + timeout=4, + ) + except Exception: + pass diff --git a/docs/07-gothwad-ecosystem-setup.md b/docs/07-gothwad-ecosystem-setup.md index 314fff8..bbbfe3a 100644 --- a/docs/07-gothwad-ecosystem-setup.md +++ b/docs/07-gothwad-ecosystem-setup.md @@ -28,9 +28,9 @@ --- -## 3. Build Standalone Windows `.exe` Release via GitHub Actions +## 3. Automatic Standalone Windows GUI `.exe` Release via GitHub Actions -Repository workflow [`.github/workflows/build-windows-agent.yml`](../.github/workflows/build-windows-agent.yml) compiles `agent/gothwad_agent.py` into `GothwadRemoteAgent-win64.exe` on `windows-latest`: +Repository workflow [`.github/workflows/build-windows-agent.yml`](../.github/workflows/build-windows-agent.yml) automatically compiles the graphical installer **`GothwadRemoteHostSetup.exe`** (`--onefile --noconsole --uac-admin`) on `windows-latest` whenever `main` is updated and publishes it to **GitHub Releases**: -1. Open **GitHub -> Actions -> Build Windows Agent EXE & Publish GitHub Release -> Run workflow** (or push a version tag such as `v1.0.0`). -2. Download `GothwadRemoteAgent-win64.exe` from Releases or run the one-line PowerShell installer inside the **Set up via PowerShell** tab on `https://remotedesktop.gothwadtech.com`. +1. Every push to `main` touching `agent/**` or `scripts/**` automatically builds and publishes `GothwadRemoteHostSetup.exe` to the Latest Release (`https://github.com/GothwadTech/remotedesk/releases/latest/download/GothwadRemoteHostSetup.exe`). +2. Users simply click **Turn on** on `https://remotedesktop.gothwadtech.com`, open the downloaded `GothwadRemoteHostSetup.exe`, and finish setup in the GUI or directly from the browser. diff --git a/scripts/install-gothwad-agent.ps1 b/scripts/install-gothwad-agent.ps1 index a226532..7ba2d68 100644 --- a/scripts/install-gothwad-agent.ps1 +++ b/scripts/install-gothwad-agent.ps1 @@ -1,12 +1,11 @@ <# .SYNOPSIS - One-Time Transparent Installer for Gothwad Remote Desktop Agent (Zero-D1 WebRTC P2P). + PowerShell Installer for Gothwad Remote Desktop Host (Zero-D1 WebRTC P2P). .DESCRIPTION - Requires explicit confirmation from the PC owner. - - Downloads GothwadRemoteAgent-win64.exe from GothwadTech/remotedesk GitHub Releases. - - Links this PC (Main Windows OS + Hyper-V VM) to your @gothwadtech.com P2P slot. - - Registers a transparent startup task ('GothwadRemoteAgent') so it reconnects on boot. + - Downloads GothwadRemoteHostSetup.exe from GothwadTech/remotedesk GitHub Releases. + - Configures and starts the background Windows Host service. #> param( @@ -23,13 +22,13 @@ param( [string]$VMName = "RemoteWorkVM", [Parameter(Mandatory = $false)] - [string]$ReleaseExeUrl = "https://github.com/GothwadTech/remotedesk/releases/latest/download/GothwadRemoteAgent-win64.exe" + [string]$ReleaseExeUrl = "https://github.com/GothwadTech/remotedesk/releases/latest/download/GothwadRemoteHostSetup.exe" ) $ErrorActionPreference = "Stop" Write-Host "================================================================" -ForegroundColor Cyan -Write-Host " Gothwad Remote Desktop — Zero-D1 WebRTC P2P Setup " -ForegroundColor Cyan +Write-Host " Gothwad Remote Desktop Host — Windows Setup " -ForegroundColor Cyan Write-Host " Portal: https://remotedesktop.gothwadtech.com " -ForegroundColor Cyan Write-Host "================================================================" -ForegroundColor Cyan Write-Host "IMPORTANT: Run ONLY with the explicit permission of this PC's owner." -ForegroundColor Yellow @@ -48,27 +47,13 @@ if ($pinPlain -notmatch '^\d{6,12}$') { $installDir = "C:\Program Files\GothwadRemote" New-Item -ItemType Directory -Force -Path $installDir | Out-Null -$exePath = Join-Path $installDir "GothwadRemoteAgent.exe" +$exePath = Join-Path $installDir "GothwadRemoteHost.exe" -Write-Host "[1/3] Downloading GothwadRemoteAgent from GitHub Releases..." -ForegroundColor Green +Write-Host "[1/2] Downloading GothwadRemoteHostSetup.exe from GitHub Releases..." -ForegroundColor Green Invoke-WebRequest -Uri $ReleaseExeUrl -OutFile $exePath -Write-Host "[2/3] Configuring Zero-D1 P2P Slot $Slot for '$GothwadUser@gothwadtech.com'..." -ForegroundColor Green +Write-Host "[2/2] Configuring Slot $Slot and starting background host service..." -ForegroundColor Green & $exePath --pair --gothwad-user $GothwadUser --slot $Slot --device-name $DeviceName --vm-name $VMName --pin $pinPlain --confirm-owner-consent -Write-Host "[3/3] Registering startup Scheduled Task 'GothwadRemoteAgent'..." -ForegroundColor Green -$action = New-ScheduledTaskAction -Execute $exePath -Argument "--run" -$trigger = New-ScheduledTaskTrigger -AtStartup -$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 2) - -Register-ScheduledTask -TaskName "GothwadRemoteAgent" ` - -Description "Gothwad Remote Desktop & Hyper-V P2P Agent (https://remotedesktop.gothwadtech.com)" ` - -Action $action ` - -Trigger $trigger ` - -Settings $settings ` - -RunLevel Highest ` - -Force | Out-Null - -Start-ScheduledTask -TaskName "GothwadRemoteAgent" Write-Host "" -Write-Host "Done! Open https://remotedesktop.gothwadtech.com in your browser to control Main OS or Hyper-V VM '$VMName'." -ForegroundColor Cyan +Write-Host "Done! Open https://remotedesktop.gothwadtech.com on your phone or browser to connect." -ForegroundColor Cyan diff --git a/src/App.tsx b/src/App.tsx index d475cad..1b3c43d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { Download, Monitor, Plus, Share2, Terminal } from 'lucide-react'; +import { Monitor, Plus, Share2, Terminal } from 'lucide-react'; import type { ActiveConnection, RemoteDevice, SessionPayload, TargetMode } from './types'; import { AuthRequiredError, resolveGothwadSession } from './lib/gothwadAuth'; import { @@ -14,6 +14,7 @@ import { PairDeviceModal } from './components/PairDeviceModal'; import { PinPromptModal } from './components/PinPromptModal'; import { RemoteSupportView } from './components/RemoteSupportView'; import { RemoteViewer } from './components/RemoteViewer'; +import { SetupRemoteCard } from './components/SetupRemoteCard'; import { SetupWizard } from './components/SetupWizard'; import { ShareAccessModal } from './components/ShareAccessModal'; @@ -304,32 +305,11 @@ export function App() { )} - {/* Card 2: Set up remote access */} -
-
-
-
- -
-
-

- Set up remote access -

-

- Access your Windows 11 computer from your phone or another PC anytime. -

-
-
- - -
-
+ {/* Card 2: Exact Chrome Remote Desktop inline setup card */} + void loadWorkspace(authuser, true)} + /> )} diff --git a/src/components/PairDeviceModal.tsx b/src/components/PairDeviceModal.tsx index d0d9e55..8261c8d 100644 --- a/src/components/PairDeviceModal.tsx +++ b/src/components/PairDeviceModal.tsx @@ -13,7 +13,7 @@ interface PairDeviceModalProps { } const RELEASE_EXE_URL = - 'https://github.com/GothwadTech/remotedesk/releases/latest/download/GothwadRemoteAgent-win64.exe'; + 'https://github.com/GothwadTech/remotedesk/releases/latest/download/GothwadRemoteHostSetup.exe'; export function PairDeviceModal({ open, diff --git a/src/components/RemoteAdminDrawer.tsx b/src/components/RemoteAdminDrawer.tsx new file mode 100644 index 0000000..c3f322d --- /dev/null +++ b/src/components/RemoteAdminDrawer.tsx @@ -0,0 +1,368 @@ +import { useEffect, useState } from 'react'; +import { + Activity, + ArrowUp, + Download, + FileText, + Folder, + Play, + RefreshCw, + Terminal, + Trash2, + Upload, + X, +} from 'lucide-react'; +import type { P2PSessionHandle } from '../types'; + +interface RemoteAdminDrawerProps { + open: boolean; + p2pHandle: P2PSessionHandle | null; + onClose: () => void; +} + +interface FsEntry { + name: string; + path: string; + isDir: boolean; + size: number; +} + +interface ProcEntry { + pid: number; + name: string; + ramMb: number; + cpu: number; +} + +export function RemoteAdminDrawer({ open, p2pHandle, onClose }: RemoteAdminDrawerProps) { + const [tab, setTab] = useState<'files' | 'shell' | 'tasks'>('files'); + const [busy, setBusy] = useState(false); + const [statusMsg, setStatusMsg] = useState(null); + + // Files state + const [currentPath, setCurrentPath] = useState(''); + const [parentPath, setParentPath] = useState('__roots__'); + const [entries, setEntries] = useState([]); + + // PowerShell state + const [cmdInput, setCmdInput] = useState('Get-ComputerInfo | Select-Object CsName, WindowsProductName'); + const [cmdLog, setCmdLog] = useState('Administrator PowerShell ready.\n'); + + // Task manager state + const [sysInfo, setSysInfo] = useState<{ + cpuPercent: number; + ramUsedMb: number; + ramTotalMb: number; + batteryPercent: number | null; + powerPlugged: boolean | null; + processes: ProcEntry[]; + } | null>(null); + + const loadFolder = async (pathReq = '__roots__') => { + if (!p2pHandle) return; + setBusy(true); + setStatusMsg(null); + try { + const res = await p2pHandle.requestAdmin({ op: 'FS_LIST', path: pathReq }); + if (res.error) { + setStatusMsg(String(res.error)); + } else { + setCurrentPath(String(res.path || '')); + setParentPath(String(res.parent || '__roots__')); + setEntries((res.entries as FsEntry[]) || []); + } + } catch (err) { + setStatusMsg(err instanceof Error ? err.message : 'Failed to list folder'); + } finally { + setBusy(false); + } + }; + + const loadTasks = async () => { + if (!p2pHandle) return; + setBusy(true); + try { + const res = await p2pHandle.requestAdmin({ op: 'SYS_SNAPSHOT' }); + setSysInfo({ + cpuPercent: Number(res.cpuPercent || 0), + ramUsedMb: Number(res.ramUsedMb || 0), + ramTotalMb: Number(res.ramTotalMb || 16384), + batteryPercent: typeof res.batteryPercent === 'number' ? res.batteryPercent : null, + powerPlugged: typeof res.powerPlugged === 'boolean' ? res.powerPlugged : null, + processes: (res.processes as ProcEntry[]) || [], + }); + } catch (err) { + setStatusMsg(err instanceof Error ? err.message : 'Failed to load tasks'); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + if (!open || !p2pHandle) return; + if (tab === 'files') void loadFolder(currentPath || '__roots__'); + if (tab === 'tasks') void loadTasks(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, tab]); + + if (!open) return null; + + const handleDownloadFile = async (entry: FsEntry) => { + if (!p2pHandle) return; + setBusy(true); + try { + const res = await p2pHandle.requestAdmin({ op: 'FS_READ', path: entry.path }); + if (res.error) { + setStatusMsg(String(res.error)); + return; + } + const b64 = String(res.dataBase64 || ''); + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const blob = new Blob([bytes]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = String(res.name || entry.name); + a.click(); + URL.revokeObjectURL(url); + setStatusMsg(`Downloaded ${entry.name}`); + } finally { + setBusy(false); + } + }; + + const handleUploadFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !p2pHandle) return; + setBusy(true); + try { + const buf = new Uint8Array(await file.arrayBuffer()); + let bin = ''; + for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]); + const res = await p2pHandle.requestAdmin({ + op: 'FS_WRITE', + dir: currentPath, + name: file.name, + dataBase64: btoa(bin), + }); + if (res.error) setStatusMsg(String(res.error)); + else { + setStatusMsg(`Uploaded to ${String(res.path)}`); + await loadFolder(currentPath || '__roots__'); + } + } finally { + setBusy(false); + e.target.value = ''; + } + }; + + const handleRunCmd = async (e: React.FormEvent) => { + e.preventDefault(); + if (!p2pHandle || !cmdInput.trim()) return; + const toRun = cmdInput.trim(); + setBusy(true); + setCmdLog((prev) => `${prev}\n> ${toRun}\n`); + try { + const res = await p2pHandle.requestAdmin({ op: 'EXEC_CMD', command: toRun }); + setCmdLog((prev) => `${prev}${String(res.output || '(completed with no output)')}\n`); + } catch (err) { + setCmdLog((prev) => `${prev}Error: ${err instanceof Error ? err.message : 'Failed'}\n`); + } finally { + setBusy(false); + } + }; + + const handleKillProc = async (pid: number) => { + if (!p2pHandle) return; + await p2pHandle.requestAdmin({ op: 'PROC_KILL', pid }); + await loadTasks(); + }; + + return ( +
+
+
+ + + +
+ + +
+ + {statusMsg && ( +
+ {statusMsg} +
+ )} + + {tab === 'files' && ( +
+
+
+ {currentPath && ( + + )} + + {currentPath || 'Drives & Quick Folders'} + +
+ + +
+ +
+ {entries.map((item) => ( +
+ + {!item.isDir && ( + + )} +
+ ))} +
+
+ )} + + {tab === 'shell' && ( +
+
+            {cmdLog}
+          
+
void handleRunCmd(e)} className="mt-3 flex gap-2"> + setCmdInput(e.target.value)} + placeholder="Enter PowerShell command..." + className="min-w-0 flex-1 rounded-xl border border-neutral-700 bg-[#181818] px-3 py-2 font-mono text-xs text-white focus:border-[#0494f4] focus:outline-hidden" + /> + +
+
+ )} + + {tab === 'tasks' && ( +
+
+
+ CPU: {sysInfo?.cpuPercent ?? 0}% + + RAM: {sysInfo?.ramUsedMb ?? 0} / {sysInfo?.ramTotalMb ?? 0} MB + + {sysInfo?.batteryPercent !== null && sysInfo?.batteryPercent !== undefined && ( + + Battery: {sysInfo.batteryPercent}% {sysInfo.powerPlugged ? '(AC)' : ''} + + )} +
+ +
+ +
+ {(sysInfo?.processes || []).map((proc) => ( +
+
+

{proc.name}

+

+ PID {proc.pid} · {proc.ramMb} MB · {proc.cpu}% CPU +

+
+ +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/RemoteViewer.tsx b/src/components/RemoteViewer.tsx index c187704..d290c83 100644 --- a/src/components/RemoteViewer.tsx +++ b/src/components/RemoteViewer.tsx @@ -4,6 +4,7 @@ import { ChevronUp, Clipboard, Command, + FolderOpen, Hand, HardDrive, Keyboard, @@ -14,6 +15,7 @@ import { X, } from 'lucide-react'; import type { ActiveConnection, TargetMode } from '../types'; +import { RemoteAdminDrawer } from './RemoteAdminDrawer'; interface RemoteViewerProps { connection: ActiveConnection; @@ -27,6 +29,7 @@ export function RemoteViewer({ connection, onSwitchMode, onDisconnect }: RemoteV const mobileInputRef = useRef(null); const [barOpen, setBarOpen] = useState(true); + const [adminOpen, setAdminOpen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [touchMode, setTouchMode] = useState<'touch' | 'trackpad'>('touch'); const [cursor, setCursor] = useState({ x: 50, y: 50 }); @@ -61,7 +64,12 @@ export function RemoteViewer({ connection, onSwitchMode, onDisconnect }: RemoteV } }; - const sendPointer = (type: 'move' | 'click', xPct: number, yPct: number) => { + const sendPointer = ( + type: 'move' | 'click' | 'right_click' | 'double_click' | 'wheel', + xPct: number, + yPct: number, + deltaY?: number, + ) => { const clampedX = Math.max(0, Math.min(100, xPct)); const clampedY = Math.max(0, Math.min(100, yPct)); setCursor({ x: clampedX, y: clampedY }); @@ -70,20 +78,42 @@ export function RemoteViewer({ connection, onSwitchMode, onDisconnect }: RemoteV action: type, x: Number((clampedX / 100).toFixed(4)), y: Number((clampedY / 100).toFixed(4)), + deltaY, }); }; + const coordsFromMouse = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + return { + xPct: ((e.clientX - rect.left) / rect.width) * 100, + yPct: ((e.clientY - rect.top) / rect.height) * 100, + }; + }; + const handleStageClick = (e: React.MouseEvent) => { if (touchMode === 'trackpad') { sendPointer('click', cursor.x, cursor.y); return; } - const rect = e.currentTarget.getBoundingClientRect(); - const xPct = ((e.clientX - rect.left) / rect.width) * 100; - const yPct = ((e.clientY - rect.top) / rect.height) * 100; + const { xPct, yPct } = coordsFromMouse(e); sendPointer('click', xPct, yPct); }; + const handleStageContextMenu = (e: React.MouseEvent) => { + e.preventDefault(); + const { xPct, yPct } = coordsFromMouse(e); + sendPointer('right_click', xPct, yPct); + }; + + const handleStageDoubleClick = (e: React.MouseEvent) => { + const { xPct, yPct } = coordsFromMouse(e); + sendPointer('double_click', xPct, yPct); + }; + + const handleStageWheel = (e: React.WheelEvent) => { + sendPointer('wheel', cursor.x, cursor.y, e.deltaY > 0 ? -120 : 120); + }; + const handleTouchStart = (e: React.TouchEvent) => { if (touchMode !== 'trackpad' || e.touches.length !== 1) return; dragRef.current = { lastX: e.touches[0].clientX, lastY: e.touches[0].clientY }; @@ -101,7 +131,7 @@ export function RemoteViewer({ connection, onSwitchMode, onDisconnect }: RemoteV const handleSendCtrlAltDel = () => { connection.p2pHandle?.sendInput({ type: 'KEY_COMBO', keys: ['Control', 'Alt', 'Delete'] }); - flashToast('Sent Ctrl + Alt + Del'); + flashToast('Sent Task Manager shortcut'); }; const handleSyncClipboard = async () => { @@ -137,7 +167,7 @@ export function RemoteViewer({ connection, onSwitchMode, onDisconnect }: RemoteV ref={containerRef} tabIndex={0} onKeyDown={(e) => { - if (document.activeElement === mobileInputRef.current) return; + if (adminOpen || document.activeElement === mobileInputRef.current) return; connection.p2pHandle?.sendInput({ type: 'KEY', key: e.key }); }} className="fixed inset-0 z-50 flex flex-col bg-black text-white select-none focus:outline-hidden" @@ -177,7 +207,7 @@ export function RemoteViewer({ connection, onSwitchMode, onDisconnect }: RemoteV
- + {connection.device.name} @@ -213,6 +243,22 @@ export function RemoteViewer({ connection, onSwitchMode, onDisconnect }: RemoteV + {connection.adminEnabled && ( + + )} + {canSwitch && ( )}
); } diff --git a/src/components/SetupRemoteCard.tsx b/src/components/SetupRemoteCard.tsx new file mode 100644 index 0000000..2104077 --- /dev/null +++ b/src/components/SetupRemoteCard.tsx @@ -0,0 +1,286 @@ +import { useState } from 'react'; +import { CheckCircle2, Download } from 'lucide-react'; + +interface SetupRemoteCardProps { + currentUsername: string; + onFinished: () => void; +} + +export const HOST_SETUP_EXE_URL = + 'https://github.com/GothwadTech/remotedesk/releases/latest/download/GothwadRemoteHostSetup.exe'; + +type InlineStep = 'download' | 'install' | 'name' | 'pin' | 'launched'; + +export function SetupRemoteCard({ currentUsername, onFinished }: SetupRemoteCardProps) { + const [step, setStep] = useState('download'); + const [pcName, setPcName] = useState('Home-Windows-PC'); + const [slot, setSlot] = useState(1); + const [pin, setPin] = useState(''); + const [pinConfirm, setPinConfirm] = useState(''); + const [pinError, setPinError] = useState(null); + + const triggerDownload = () => { + const a = document.createElement('a'); + a.href = HOST_SETUP_EXE_URL; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + document.body.appendChild(a); + a.click(); + a.remove(); + setStep('install'); + }; + + const handleLaunchProtocol = (e: React.FormEvent) => { + e.preventDefault(); + const p1 = pin.trim(); + const p2 = pinConfirm.trim(); + if (!/^\d{6,12}$/.test(p1)) { + setPinError('PIN must be 6 to 12 numeric digits.'); + return; + } + if (p1 !== p2) { + setPinError('PIN entries do not match.'); + return; + } + setPinError(null); + const params = new URLSearchParams({ + owner: currentUsername, + slot: String(slot), + name: pcName.trim() || 'Home-Windows-PC', + pin: p1, + }); + window.location.href = `gothwad-remote://setup?${params.toString()}`; + setStep('launched'); + setTimeout(() => onFinished(), 2500); + }; + + return ( +
+
+

+ Set up remote access +

+
+ + {step === 'download' && ( +
+
+ +
+

+ Turn on remote access on this computer +

+

+ Download and open GothwadRemoteHostSetup.exe on Windows 11 — just + like Chrome Remote Desktop. +

+
+
+ + +
+ )} + + {step === 'install' && ( +
+
+

+ 1. Run GothwadRemoteHostSetup.exe +

+

+ Open GothwadRemoteHostSetup.exe from your browser downloads and + approve the Windows Administrator prompt. You can complete setup inside the app window + or click Accept & Configure below. +

+
+ +
+ + Download installer again + +
+ + +
+
+
+ )} + + {step === 'name' && ( +
{ + e.preventDefault(); + setStep('pin'); + }} + className="space-y-3.5 p-4 sm:p-5" + > +

+ 2. Choose a name +

+
+
+ + setPcName(e.target.value)} + className="w-full rounded-xl border border-neutral-300 bg-white px-3.5 py-2 text-sm text-neutral-900 focus:border-[#0494f4] focus:outline-hidden dark:border-neutral-700 dark:bg-[#1b1b1b] dark:text-white" + /> +
+
+ + +
+
+
+ + +
+
+ )} + + {step === 'pin' && ( +
+

+ 3. Choose a PIN +

+

+ Choose a PIN of at least 6 numeric digits to protect access to{' '} + {pcName || 'this computer'}. +

+
+
+ + setPin(e.target.value)} + placeholder="••••••" + className="w-full rounded-xl border border-neutral-300 bg-white px-3.5 py-2 font-mono text-sm tracking-widest text-neutral-900 focus:border-[#0494f4] focus:outline-hidden dark:border-neutral-700 dark:bg-[#1b1b1b] dark:text-white" + /> +
+
+ + setPinConfirm(e.target.value)} + placeholder="••••••" + className="w-full rounded-xl border border-neutral-300 bg-white px-3.5 py-2 font-mono text-sm tracking-widest text-neutral-900 focus:border-[#0494f4] focus:outline-hidden dark:border-neutral-700 dark:bg-[#1b1b1b] dark:text-white" + /> +
+
+ + {pinError &&

{pinError}

} + +
+ + +
+
+ )} + + {step === 'launched' && ( +
+
+ +

+ Confirm Allow & Turn On Remote Access in the Gothwad Remote + Desktop window on your PC. Your computer will appear in Remote devices above. +

+
+ +
+ )} +
+ ); +} diff --git a/src/components/ShareAccessModal.tsx b/src/components/ShareAccessModal.tsx index 42bb9af..5020726 100644 --- a/src/components/ShareAccessModal.tsx +++ b/src/components/ShareAccessModal.tsx @@ -23,7 +23,7 @@ export function ShareAccessModal({ device, onClose }: ShareAccessModalProps) { if (!device) return null; const cleanTarget = sanitizeGothwadUsername(username) || 'friend'; - const shareCmd = `& "C:\\Program Files\\GothwadRemote\\GothwadRemoteAgent.exe" --grant-user "${cleanTarget}" --scope "${scope}"`; + const shareCmd = `& "C:\\Program Files\\GothwadRemote\\GothwadRemoteHost.exe" --grant-user "${cleanTarget}" --scope "${scope}"`; const handleGrantDemo = (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/lib/peerRegistry.ts b/src/lib/peerRegistry.ts index 590c0c3..8334473 100644 --- a/src/lib/peerRegistry.ts +++ b/src/lib/peerRegistry.ts @@ -2,6 +2,7 @@ import Peer, { type DataConnection } from 'peerjs'; import type { AccessScope, ActiveConnection, + P2PSessionHandle, RemoteDevice, ShareEntry, TargetMode, @@ -20,6 +21,86 @@ const BOOKMARKS_KEY = 'gothwad_rd_peer_bookmarks_v1'; // In-memory state for the preview Demo PC (zero D1) let demoVmState: VmPowerState = 'Running'; let demoShares: Record = { alex: 'vm_only' }; +let demoFiles = [ + { name: 'Desktop', path: 'C:\\Users\\Pawan\\Desktop', isDir: true, size: 0 }, + { name: 'Downloads', path: 'C:\\Users\\Pawan\\Downloads', isDir: true, size: 0 }, + { name: 'C: Drive', path: 'C:\\', isDir: true, size: 512 * 1024 * 1024 * 1024 }, + { name: 'project-notes.txt', path: 'C:\\Users\\Pawan\\Desktop\\project-notes.txt', isDir: false, size: 128 }, +]; +let demoProcs = [ + { pid: 4120, name: 'chrome.exe', ramMb: 842.4, cpu: 6.2 }, + { pid: 7812, name: 'Code.exe', ramMb: 618.0, cpu: 3.1 }, + { pid: 2290, name: 'explorer.exe', ramMb: 194.5, cpu: 0.8 }, + { pid: 5510, name: 'GothwadRemoteHost.exe', ramMb: 64.2, cpu: 1.4 }, +]; + +function createDemoSessionHandle(): P2PSessionHandle { + return { + sendInput: () => undefined, + onFrame: () => undefined, + close: () => undefined, + requestAdmin: async (pkt) => { + const op = String(pkt.op || ''); + if (op === 'FS_LIST') { + return { + type: 'ADMIN_RES', + op: 'FS_LIST', + path: String(pkt.path || 'C:\\Users\\Pawan'), + parent: '__roots__', + entries: demoFiles, + }; + } + if (op === 'FS_READ') { + return { + type: 'ADMIN_RES', + op: 'FS_READ', + name: 'project-notes.txt', + size: 46, + dataBase64: btoa('Gothwad Remote Desktop preview file download.\n'), + }; + } + if (op === 'FS_WRITE') { + const name = String(pkt.name || 'uploaded.txt'); + demoFiles = [ + ...demoFiles, + { name, path: `C:\\Users\\Pawan\\Downloads\\${name}`, isDir: false, size: 1024 }, + ]; + return { + type: 'ADMIN_RES', + op: 'FS_WRITE', + path: `C:\\Users\\Pawan\\Downloads\\${name}`, + }; + } + if (op === 'EXEC_CMD') { + const cmd = String(pkt.command || ''); + return { + type: 'ADMIN_RES', + op: 'EXEC_CMD', + output: `PS C:\\Windows\\system32> ${cmd}\nComputerName : HOME-WIN11-LAPTOP\nUser : NT AUTHORITY\\SYSTEM (Demo Preview)`, + exitCode: 0, + }; + } + if (op === 'SYS_SNAPSHOT') { + return { + type: 'ADMIN_RES', + op: 'SYS_SNAPSHOT', + cpuPercent: 14.5, + ramUsedMb: 6180, + ramTotalMb: 16384, + batteryPercent: 86, + powerPlugged: true, + processes: demoProcs, + }; + } + if (op === 'PROC_KILL') { + const pid = Number(pkt.pid || 0); + demoProcs = demoProcs.filter((p) => p.pid !== pid); + return { type: 'ADMIN_RES', op: 'PROC_KILL', pid, ok: true }; + } + return { type: 'ADMIN_RES', op, error: 'Unsupported preview operation' }; + }, + }; +} export function listBookmarkedPeers(): string[] { try { @@ -60,7 +141,7 @@ function makeDemoDeviceFor(viewerUsername: string): RemoteDevice | null { slot: 1, name: 'Home-Win11-Laptop', ownerUsername: 'pawan', - osInfo: 'Windows 11 Pro · 16GB RAM · 512GB SSD', + osInfo: 'Windows 11 Pro · 16 GB RAM · 512 GB SSD', vmName: 'RemoteWorkVM', vmState: demoVmState, hostRamTotalMb: 16384, @@ -83,7 +164,7 @@ function makeDemoDeviceFor(viewerUsername: string): RemoteDevice | null { slot: 1, name: 'Home-Win11-Laptop', ownerUsername: 'pawan', - osInfo: 'Windows 11 Pro · 16GB RAM · 512GB SSD', + osInfo: 'Windows 11 Pro · 16 GB RAM · 512 GB SSD', vmName: 'RemoteWorkVM', vmState: demoVmState, hostRamTotalMb: 16384, @@ -154,7 +235,7 @@ function probeSinglePeer( vmName: String(d.vmName || 'RemoteWorkVM'), vmState: (['Running', 'Saved', 'Paused', 'Off'].includes(String(d.vmState)) ? d.vmState - : 'Running') as VmPowerState, + : 'Off') as VmPowerState, hostRamTotalMb: Number(d.hostRamTotalMb) || 16384, hostRamUsedMb: Number(d.hostRamUsedMb) || 6144, hostCpuPercent: Number(d.hostCpuPercent) || 10, @@ -246,7 +327,7 @@ export async function connectP2PDevice( viewerUsername: string, ): Promise { if (targetMode === 'host_os' && !device.canControlHostOs) { - throw new Error('Your Gothwad Account only has VM-Only access on this PC.'); + throw new Error('Your Gothwad Account only has Background VM access on this PC.'); } if (device.isDemo) { @@ -257,14 +338,17 @@ export async function connectP2PDevice( targetMode, streamUrl: null, hostNoticeActive: targetMode === 'host_os', + adminEnabled: device.canControlHostOs, device, - p2pHandle: null, + p2pHandle: createDemoSessionHandle(), }; } return new Promise((resolve, reject) => { const peer = new Peer({ config: { iceServers: DEFAULT_ICE_SERVERS } }); let frameListener: ((jpegUrl: string) => void) | null = null; + const pendingAdmin = new Map) => void>(); + let reqCounter = 0; let settled = false; const fail = (msg: string) => { @@ -299,11 +383,29 @@ export async function connectP2PDevice( targetMode, streamUrl: typeof msg.streamUrl === 'string' ? msg.streamUrl : device.streamUrl || null, hostNoticeActive: targetMode === 'host_os', + adminEnabled: Boolean(msg.adminEnabled ?? device.canControlHostOs), device, p2pHandle: { sendInput: (pkt) => { if (conn.open) conn.send(pkt); }, + requestAdmin: (pkt) => + new Promise((resAdmin, rejAdmin) => { + if (!conn.open) { + rejAdmin(new Error('Connection closed')); + return; + } + const reqId = `r_${++reqCounter}`; + const t = setTimeout(() => { + pendingAdmin.delete(reqId); + rejAdmin(new Error('Host response timed out')); + }, 28000); + pendingAdmin.set(reqId, (payload) => { + clearTimeout(t); + resAdmin(payload); + }); + conn.send({ ...pkt, type: 'ADMIN_REQ', reqId }); + }), onFrame: (cb) => { frameListener = cb; }, @@ -315,6 +417,12 @@ export async function connectP2PDevice( }); } else if (msg?.type === 'FRAME' && typeof msg.jpegBase64 === 'string') { frameListener?.(`data:image/jpeg;base64,${msg.jpegBase64}`); + } else if (msg?.type === 'ADMIN_RES' && typeof msg.reqId === 'string') { + const resolver = pendingAdmin.get(msg.reqId); + if (resolver) { + pendingAdmin.delete(msg.reqId); + resolver(msg); + } } }); @@ -340,7 +448,6 @@ export async function triggerVmPowerOnHost( demoVmState = nextState; return nextState; } - // Live P2P power command (authenticated on active or quick channel) return nextState; } diff --git a/src/types.ts b/src/types.ts index 6bf8f8e..6a58c4c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,6 +51,7 @@ export interface RemoteDevice { export interface P2PSessionHandle { sendInput: (packet: Record) => void; + requestAdmin: (packet: Record) => Promise>; onFrame: (cb: (jpegDataUrl: string) => void) => void; close: () => void; } @@ -59,6 +60,7 @@ export interface ActiveConnection { targetMode: TargetMode; streamUrl: string | null; hostNoticeActive: boolean; + adminEnabled: boolean; device: RemoteDevice; p2pHandle: P2PSessionHandle | null; }