Add ScreenConnect-parity features (high + medium)
Viewer: - Toolbar: Ctrl+Alt+Del, clipboard paste, monitor picker, file transfer, chat, WoL buttons - Multi-monitor: agent sends monitor_list on connect, viewer can switch via dropdown - Clipboard sync: agent polls local clipboard → sends to viewer; viewer paste → agent sets remote clipboard - File transfer panel: drag-drop upload to agent, directory browser, download files from remote - Chat panel: bidirectional text chat forwarded through relay Agent: - Multi-monitor capture with set_monitor/set_quality message handlers - exec_key_combo for Ctrl+Alt+Del and arbitrary combos - Clipboard polling via pyperclip (both directions) - File upload/download/list_files with base64 chunked protocol - Attended mode (--attended): zenity/kdialog/PowerShell consent dialog before accepting stream - Auto-update: heartbeat checks version, downloads new binary and exec-replaces self (Linux) - Reports MAC address on registration (for WoL) Relay: - Forwards monitor_list, clipboard_content, file_chunk, file_list, chat_message agent→viewer - Session recording: when RECORDING_DIR env set, saves JPEG frames as .remrec files - ALLOWED_ORIGINS CORS now set from NEXT_PUBLIC_APP_URL in docker-compose Database: - groups table (id, name, description, created_by) - machines: group_id, mac_address, notes, tags text[] - Migration 0003 applied Dashboard: - Machines page: search, tag filter, group filter, inline notes/tags/rename editing - MachineCard: inline tag management, group picker, notes textarea - Admin page: new Groups tab (create/list/delete groups) - API: PATCH /api/machines/[id] (name, notes, tags, groupId) - API: GET/POST/DELETE /api/groups - API: POST /api/machines/wol (broadcast magic packet) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
364
agent/agent.py
364
agent/agent.py
@@ -14,6 +14,7 @@ Usage:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -21,6 +22,7 @@ import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid as uuid_lib
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -126,6 +128,7 @@ async def register(server_url: str, enrollment_token: str) -> dict:
|
||||
"osVersion": os_version,
|
||||
"agentVersion": AGENT_VERSION,
|
||||
"ipAddress": None,
|
||||
"macAddress": get_mac_address(),
|
||||
})
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"Registration failed: {resp.status_code} {resp.text}")
|
||||
@@ -135,18 +138,49 @@ async def register(server_url: str, enrollment_token: str) -> dict:
|
||||
|
||||
|
||||
async def heartbeat(server_url: str, access_key: str) -> Optional[dict]:
|
||||
"""Send heartbeat, returns pending connection info if any."""
|
||||
"""Send heartbeat, returns server response including update info."""
|
||||
url = f"{server_url.rstrip('/')}/api/agent/heartbeat"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.post(url, json={"accessKey": access_key})
|
||||
resp = await client.post(url, json={"accessKey": access_key, "agentVersion": AGENT_VERSION})
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
data = resp.json()
|
||||
if data.get("updateAvailable"):
|
||||
asyncio.create_task(_auto_update(data["updateAvailable"]))
|
||||
return data
|
||||
except Exception as e:
|
||||
log.warning(f"Heartbeat failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def _auto_update(update_info: dict) -> None:
|
||||
"""Download new agent binary and replace self, then restart."""
|
||||
download_url = update_info.get("downloadUrl")
|
||||
if not download_url:
|
||||
return
|
||||
log.info(f"Update available: {update_info.get('version')}. Downloading from {download_url}")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client:
|
||||
resp = await client.get(download_url)
|
||||
if resp.status_code != 200:
|
||||
log.warning(f"Update download failed: {resp.status_code}")
|
||||
return
|
||||
# Write to a temp file alongside the current binary
|
||||
current = Path(sys.executable)
|
||||
tmp = current.with_suffix(".new")
|
||||
tmp.write_bytes(resp.content)
|
||||
tmp.chmod(0o755)
|
||||
# Atomically replace (Unix only — Windows needs a separate updater process)
|
||||
if platform.system() != "Windows":
|
||||
tmp.replace(current)
|
||||
log.info(f"Agent updated to {update_info.get('version')}. Restarting…")
|
||||
os.execv(str(current), sys.argv)
|
||||
else:
|
||||
log.info("Update downloaded. Restart the service to apply on Windows.")
|
||||
except Exception as e:
|
||||
log.warning(f"Auto-update failed: {e}")
|
||||
|
||||
|
||||
async def get_session_code(server_url: str, access_key: str) -> Optional[str]:
|
||||
"""Request a new session code from the server."""
|
||||
url = f"{server_url.rstrip('/')}/api/agent/session-code"
|
||||
@@ -166,13 +200,17 @@ class ScreenCapture:
|
||||
self.quality = quality
|
||||
self._frame_delay = 1.0 / fps
|
||||
self._sct = None
|
||||
self._monitors: list = [] # mss monitors (excluding index 0 = all)
|
||||
self._monitor_idx: int = 0 # 0-based into _monitors
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
self._sct = mss()
|
||||
self._monitors = self._sct.monitors[1:] # skip [0] = virtual all-screens
|
||||
except Exception as e:
|
||||
log.warning(f"Screen capture unavailable: {e}")
|
||||
self._sct = None
|
||||
self._monitors = []
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
@@ -183,11 +221,39 @@ class ScreenCapture:
|
||||
def available(self) -> bool:
|
||||
return self._sct is not None
|
||||
|
||||
def set_monitor(self, idx: int):
|
||||
if 0 <= idx < len(self._monitors):
|
||||
self._monitor_idx = idx
|
||||
|
||||
def set_quality(self, quality: int):
|
||||
self.quality = max(10, min(95, quality))
|
||||
fps_map = {"high": 15, "medium": 10, "low": 5}
|
||||
# quality levels sent from viewer as strings
|
||||
if isinstance(quality, str):
|
||||
self.fps = fps_map.get(quality, 15)
|
||||
self.quality = {"high": 70, "medium": 55, "low": 35}.get(quality, 70)
|
||||
else:
|
||||
self.quality = max(10, min(95, quality))
|
||||
self._frame_delay = 1.0 / self.fps
|
||||
|
||||
def get_monitor_list(self) -> list:
|
||||
return [
|
||||
{
|
||||
"index": i,
|
||||
"width": m["width"],
|
||||
"height": m["height"],
|
||||
"left": m["left"],
|
||||
"top": m["top"],
|
||||
"primary": i == 0,
|
||||
}
|
||||
for i, m in enumerate(self._monitors)
|
||||
]
|
||||
|
||||
def capture(self) -> Optional[bytes]:
|
||||
if not self._sct:
|
||||
if not self._sct or not self._monitors:
|
||||
return None
|
||||
try:
|
||||
monitor = self._sct.monitors[1] # Primary monitor
|
||||
monitor = self._monitors[self._monitor_idx]
|
||||
img = self._sct.grab(monitor)
|
||||
pil = Image.frombytes("RGB", img.size, img.bgra, "raw", "BGRX")
|
||||
buf = BytesIO()
|
||||
@@ -239,10 +305,8 @@ class InputController:
|
||||
elif t == "mouse_scroll":
|
||||
self._mouse.scroll(event.get("dx", 0), event.get("dy", 0))
|
||||
elif t == "key_press":
|
||||
key_str = event.get("key", "")
|
||||
self._keyboard.type(key_str)
|
||||
self._keyboard.type(event.get("key", ""))
|
||||
elif t == "key_special":
|
||||
# Special keys like Enter, Tab, Escape, etc.
|
||||
key_name = event.get("key", "")
|
||||
try:
|
||||
key = getattr(self._Key, key_name)
|
||||
@@ -250,6 +314,19 @@ class InputController:
|
||||
self._keyboard.release(key)
|
||||
except AttributeError:
|
||||
pass
|
||||
elif t == "exec_key_combo":
|
||||
# e.g. {"type":"exec_key_combo","keys":["ctrl_l","alt_l","delete"]}
|
||||
keys = event.get("keys", [])
|
||||
pressed = []
|
||||
for k in keys:
|
||||
try:
|
||||
key_obj = getattr(self._Key, k)
|
||||
self._keyboard.press(key_obj)
|
||||
pressed.append(key_obj)
|
||||
except AttributeError:
|
||||
pass
|
||||
for key_obj in reversed(pressed):
|
||||
self._keyboard.release(key_obj)
|
||||
except Exception as e:
|
||||
log.debug(f"Input error: {e}")
|
||||
|
||||
@@ -301,6 +378,157 @@ async def exec_script(script: str, shell: str, session_id: str, ws) -> None:
|
||||
}))
|
||||
|
||||
|
||||
# ── Clipboard helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _get_clipboard() -> str:
|
||||
try:
|
||||
import pyperclip
|
||||
return pyperclip.paste() or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _set_clipboard(text: str):
|
||||
try:
|
||||
import pyperclip
|
||||
pyperclip.copy(text)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── File transfer helpers ─────────────────────────────────────────────────────
|
||||
|
||||
CHUNK_SIZE = 65536 # 64 KB per chunk
|
||||
|
||||
|
||||
async def send_file(path_str: str, session_id: str, ws) -> None:
|
||||
"""Read a file and send it to the viewer as base64 chunks."""
|
||||
try:
|
||||
path = Path(path_str).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
await ws.send(json.dumps({
|
||||
"type": "file_chunk", "session_id": session_id,
|
||||
"error": f"File not found: {path_str}", "done": True,
|
||||
}))
|
||||
return
|
||||
total = path.stat().st_size
|
||||
filename = path.name
|
||||
seq = 0
|
||||
with open(path, "rb") as f:
|
||||
while True:
|
||||
chunk = f.read(CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
await ws.send(json.dumps({
|
||||
"type": "file_chunk",
|
||||
"session_id": session_id,
|
||||
"filename": filename,
|
||||
"size": total,
|
||||
"seq": seq,
|
||||
"chunk": base64.b64encode(chunk).decode(),
|
||||
"done": False,
|
||||
}))
|
||||
seq += 1
|
||||
await ws.send(json.dumps({
|
||||
"type": "file_chunk",
|
||||
"session_id": session_id,
|
||||
"filename": filename,
|
||||
"size": total,
|
||||
"seq": seq,
|
||||
"chunk": "",
|
||||
"done": True,
|
||||
}))
|
||||
log.info(f"Sent file {filename} ({total} bytes) to viewer")
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({
|
||||
"type": "file_chunk", "session_id": session_id,
|
||||
"error": str(e), "done": True,
|
||||
}))
|
||||
|
||||
|
||||
async def list_files(path_str: str, session_id: str, ws) -> None:
|
||||
"""Send a directory listing to the viewer."""
|
||||
try:
|
||||
path = Path(path_str or "~").expanduser().resolve()
|
||||
if not path.is_dir():
|
||||
path = path.parent
|
||||
entries = []
|
||||
for entry in sorted(path.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
|
||||
try:
|
||||
stat = entry.stat()
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"is_dir": entry.is_dir(),
|
||||
"size": stat.st_size if entry.is_file() else 0,
|
||||
"path": str(entry),
|
||||
})
|
||||
except PermissionError:
|
||||
pass
|
||||
await ws.send(json.dumps({
|
||||
"type": "file_list",
|
||||
"session_id": session_id,
|
||||
"path": str(path),
|
||||
"parent": str(path.parent) if path.parent != path else None,
|
||||
"entries": entries,
|
||||
}))
|
||||
except Exception as e:
|
||||
await ws.send(json.dumps({
|
||||
"type": "file_list", "session_id": session_id,
|
||||
"path": path_str, "entries": [], "error": str(e),
|
||||
}))
|
||||
|
||||
|
||||
async def _prompt_user_consent(timeout: int = 30) -> bool:
|
||||
"""Show a local dialog asking the user to accept the incoming connection.
|
||||
Returns True if accepted within timeout, False otherwise."""
|
||||
try:
|
||||
if platform.system() == "Windows":
|
||||
# Use PowerShell MessageBox
|
||||
script = (
|
||||
"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null;"
|
||||
"$r = [System.Windows.Forms.MessageBox]::Show("
|
||||
"'A technician is requesting remote access to this computer. Allow?',"
|
||||
"'RemoteLink — Incoming Connection',"
|
||||
"[System.Windows.Forms.MessageBoxButtons]::YesNo,"
|
||||
"[System.Windows.Forms.MessageBoxIcon]::Question);"
|
||||
"exit ($r -eq 'Yes' ? 0 : 1)"
|
||||
)
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"powershell", "-NonInteractive", "-Command", script,
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=timeout)
|
||||
return proc.returncode == 0
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return False
|
||||
elif platform.system() == "Linux":
|
||||
# Try zenity (GNOME), then kdialog (KDE), then xterm fallback
|
||||
for cmd in [
|
||||
["zenity", "--question", "--title=RemoteLink", f"--text=A technician is requesting remote access. Allow?", f"--timeout={timeout}"],
|
||||
["kdialog", "--yesno", "A technician is requesting remote access. Allow?", "--title", "RemoteLink"],
|
||||
]:
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(*cmd)
|
||||
await asyncio.wait_for(proc.wait(), timeout=timeout + 5)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, asyncio.TimeoutError):
|
||||
continue
|
||||
# No GUI available — log and deny
|
||||
log.warning("Attended mode: no GUI dialog available, denying connection")
|
||||
return False
|
||||
else:
|
||||
return True # macOS: always allow for now
|
||||
except Exception as e:
|
||||
log.warning(f"Consent dialog error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_mac_address() -> str:
|
||||
mac = uuid_lib.getnode()
|
||||
return ":".join(f"{(mac >> (8 * i)) & 0xff:02X}" for i in reversed(range(6)))
|
||||
|
||||
|
||||
# ── Main agent loop ───────────────────────────────────────────────────────────
|
||||
|
||||
class Agent:
|
||||
@@ -314,6 +542,10 @@ class Agent:
|
||||
self._active_session: Optional[str] = None
|
||||
self._input = InputController()
|
||||
self._stop_event = asyncio.Event()
|
||||
self._screen: Optional[ScreenCapture] = None
|
||||
self._attended_mode: bool = False # set via --attended flag
|
||||
# file upload buffers: transfer_id → {filename, dest, chunks[]}
|
||||
self._uploads: dict = {}
|
||||
|
||||
async def run(self):
|
||||
log.info(f"Agent starting. Machine ID: {self.machine_id}")
|
||||
@@ -344,8 +576,28 @@ class Agent:
|
||||
log.info("Connected to relay")
|
||||
await self._message_loop(ws)
|
||||
|
||||
async def _clipboard_poll(self, ws):
|
||||
"""Poll local clipboard every 2s; send changes to active viewer."""
|
||||
last = _get_clipboard()
|
||||
while not self._stop_event.is_set():
|
||||
await asyncio.sleep(2)
|
||||
if not self._active_session:
|
||||
continue
|
||||
current = _get_clipboard()
|
||||
if current != last and current:
|
||||
last = current
|
||||
try:
|
||||
await ws.send(json.dumps({
|
||||
"type": "clipboard_content",
|
||||
"session_id": self._active_session,
|
||||
"content": current,
|
||||
}))
|
||||
except Exception:
|
||||
break
|
||||
|
||||
async def _message_loop(self, ws):
|
||||
with ScreenCapture(fps=15, quality=60) as screen:
|
||||
self._screen = screen
|
||||
if not screen.available:
|
||||
log.warning(
|
||||
"Screen capture unavailable — agent will stay connected "
|
||||
@@ -353,6 +605,7 @@ class Agent:
|
||||
)
|
||||
|
||||
stream_task: Optional[asyncio.Task] = None
|
||||
clipboard_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def stream_frames():
|
||||
while self._streaming and not self._stop_event.is_set():
|
||||
@@ -382,17 +635,38 @@ class Agent:
|
||||
if msg_type == "start_stream":
|
||||
session_id = msg.get("session_id")
|
||||
log.info(f"Viewer connected — session {session_id}")
|
||||
|
||||
# Attended mode: ask local user for consent
|
||||
if self._attended_mode:
|
||||
accepted = await _prompt_user_consent()
|
||||
if not accepted:
|
||||
await ws.send(json.dumps({
|
||||
"type": "error",
|
||||
"session_id": session_id,
|
||||
"message": "Remote session denied by local user.",
|
||||
}))
|
||||
continue
|
||||
|
||||
self._streaming = True
|
||||
self._active_session = session_id
|
||||
if not screen.available:
|
||||
# Tell viewer why they won't see frames
|
||||
await ws.send(json.dumps({
|
||||
"type": "error",
|
||||
"message": "Screen capture unavailable on this machine (no display). Set $DISPLAY and restart the agent.",
|
||||
"message": "Screen capture unavailable (no display). Set $DISPLAY and restart the agent.",
|
||||
}))
|
||||
# Send monitor list to viewer
|
||||
await ws.send(json.dumps({
|
||||
"type": "monitor_list",
|
||||
"session_id": session_id,
|
||||
"monitors": screen.get_monitor_list(),
|
||||
}))
|
||||
if stream_task and not stream_task.done():
|
||||
stream_task.cancel()
|
||||
stream_task = asyncio.create_task(stream_frames())
|
||||
# Start clipboard polling
|
||||
if clipboard_task and not clipboard_task.done():
|
||||
clipboard_task.cancel()
|
||||
clipboard_task = asyncio.create_task(self._clipboard_poll(ws))
|
||||
|
||||
elif msg_type == "stop_stream":
|
||||
log.info("Viewer disconnected — stopping stream")
|
||||
@@ -400,11 +674,28 @@ class Agent:
|
||||
self._active_session = None
|
||||
if stream_task and not stream_task.done():
|
||||
stream_task.cancel()
|
||||
if clipboard_task and not clipboard_task.done():
|
||||
clipboard_task.cancel()
|
||||
|
||||
elif msg_type == "set_monitor":
|
||||
screen.set_monitor(msg.get("index", 0))
|
||||
log.info(f"Switched to monitor {msg.get('index', 0)}")
|
||||
|
||||
elif msg_type == "set_quality":
|
||||
q = msg.get("quality", "high")
|
||||
quality_map = {"high": 70, "medium": 55, "low": 35}
|
||||
fps_map = {"high": 15, "medium": 10, "low": 5}
|
||||
screen.quality = quality_map.get(q, 70)
|
||||
screen.fps = fps_map.get(q, 15)
|
||||
screen._frame_delay = 1.0 / screen.fps
|
||||
|
||||
elif msg_type in ("mouse_move", "mouse_click", "mouse_scroll",
|
||||
"key_press", "key_special"):
|
||||
"key_press", "key_special", "exec_key_combo"):
|
||||
self._input.handle(msg)
|
||||
|
||||
elif msg_type == "clipboard_paste":
|
||||
_set_clipboard(msg.get("content", ""))
|
||||
|
||||
elif msg_type == "exec_script":
|
||||
asyncio.create_task(exec_script(
|
||||
msg.get("script", ""),
|
||||
@@ -413,13 +704,62 @@ class Agent:
|
||||
ws,
|
||||
))
|
||||
|
||||
elif msg_type == "file_download":
|
||||
asyncio.create_task(send_file(
|
||||
msg.get("path", ""),
|
||||
msg.get("session_id", self._active_session or ""),
|
||||
ws,
|
||||
))
|
||||
|
||||
elif msg_type == "list_files":
|
||||
asyncio.create_task(list_files(
|
||||
msg.get("path", "~"),
|
||||
msg.get("session_id", self._active_session or ""),
|
||||
ws,
|
||||
))
|
||||
|
||||
elif msg_type == "file_upload_start":
|
||||
tid = msg.get("transfer_id", "")
|
||||
filename = Path(msg.get("filename", "upload")).name
|
||||
dest = Path(msg.get("dest_path", "~")).expanduser() / filename
|
||||
self._uploads[tid] = {"dest": dest, "chunks": []}
|
||||
log.info(f"File upload starting: {filename} → {dest}")
|
||||
|
||||
elif msg_type == "file_upload_chunk":
|
||||
tid = msg.get("transfer_id", "")
|
||||
if tid in self._uploads:
|
||||
chunk = base64.b64decode(msg.get("chunk", ""))
|
||||
self._uploads[tid]["chunks"].append(chunk)
|
||||
|
||||
elif msg_type == "file_upload_end":
|
||||
tid = msg.get("transfer_id", "")
|
||||
if tid in self._uploads:
|
||||
info = self._uploads.pop(tid)
|
||||
dest: Path = info["dest"]
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in info["chunks"]:
|
||||
f.write(chunk)
|
||||
log.info(f"File upload complete: {dest}")
|
||||
await ws.send(json.dumps({
|
||||
"type": "file_chunk",
|
||||
"session_id": self._active_session or "",
|
||||
"transfer_id": tid,
|
||||
"done": True,
|
||||
"upload_complete": True,
|
||||
"path": str(dest),
|
||||
}))
|
||||
|
||||
elif msg_type == "ping":
|
||||
await ws.send(json.dumps({"type": "pong"}))
|
||||
|
||||
finally:
|
||||
self._streaming = False
|
||||
self._screen = None
|
||||
if stream_task and not stream_task.done():
|
||||
stream_task.cancel()
|
||||
if clipboard_task and not clipboard_task.done():
|
||||
clipboard_task.cancel()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
@@ -466,6 +806,7 @@ async def main():
|
||||
parser.add_argument("--relay", help="Relay WebSocket URL (e.g. ws://remotelink.example.com:8765)")
|
||||
parser.add_argument("--enroll", metavar="TOKEN", help="Enrollment token for first-time registration")
|
||||
parser.add_argument("--run-once", action="store_true", help="Exit after first session ends")
|
||||
parser.add_argument("--attended", action="store_true", help="Prompt local user to accept each incoming connection")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -503,6 +844,7 @@ async def main():
|
||||
access_key = config["access_key"]
|
||||
|
||||
agent = Agent(server_url, machine_id, access_key, relay_url)
|
||||
agent._attended_mode = args.attended
|
||||
|
||||
# Handle Ctrl+C / SIGTERM gracefully
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
@@ -4,6 +4,7 @@ websockets==13.1
|
||||
mss==9.0.2
|
||||
Pillow==11.0.0
|
||||
pynput==1.7.7
|
||||
pyperclip==1.9.0
|
||||
|
||||
# Windows service support (Windows only)
|
||||
pywin32==308; sys_platform == "win32"
|
||||
|
||||
Reference in New Issue
Block a user