first commit

This commit is contained in:
2025-11-03 14:23:10 +05:30
commit 4df36370b7
6 changed files with 2150 additions and 0 deletions

408
httprd.py Normal file

File diff suppressed because one or more lines are too long

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
aiohttp
pyautogui
Pillow
mss

21
setup.sh Executable file
View File

@@ -0,0 +1,21 @@
# 1) OS deps for PyAutoGUI (Linux)
sudo apt update && sudo apt install -y python3-tk python3-dev scrot
# 2) Python deps (run inside your httprd folder)
pip install -r requirements.txt
# 4) From viewer machine, open in browser:
# http://<SERVER-IP>:7417
hostname -I | awk '{print $1}' # (shows server IP)
sudo ufw allow 7417/tcp # open firewall if needed
python httprd.py
# 3) Run the server (set your passwords)
python3 httprd.py --port 7417 --password 'CONTROL123' --view_password 'VIEW123'

57
src/build.py Normal file
View File

@@ -0,0 +1,57 @@
# Build two files into one
import base64
import gzip
def replace_template(src: str, template_name: str, new_text: str):
"""
Replace tag with structure:
```
# <tempalte:template_name>
'something to replace'
# </template:template_name>
```
"""
tps = f'# <template:{ template_name }>'
tpe = f'# </template:{ template_name }>'
ind_start = src.index(tps)
ind_end = src.index(tpe) + len(tpe)
return f'{ src[:ind_start] }{ new_text }{ src[ind_end:] }'
with open('index.html', 'r', encoding='utf-8') as f:
page = f.read()
with open('httprd.py', 'r', encoding='utf-8') as f:
httprd = f.read()
page = page.replace('\t', '')
lines = []
for l in page.split('\n'):
l = l.strip()
# Despace
for _ in range(8):
l = l.replace(' ', ' ')
if len(l) == 0:
continue
if l.startswith('//'):
continue
lines.append(l)
page = '\n'.join(lines)
page = base64.b85encode(gzip.compress(page.encode('utf-8'))).decode()
httprd = replace_template(httprd, 'INDEX_CONTENT', f'''INDEX_CONTENT = gzip.decompress(base64.b85decode('{ page }'.encode())).decode('utf-8')''')
httprd = replace_template(httprd, 'get__root', f'''return aiohttp.web.Response(body=INDEX_CONTENT, content_type='text/html', status=200, charset='utf-8')''')
with open('./../httprd.py', 'w', encoding='utf-8') as f:
f.write(httprd)

411
src/httprd.py Normal file
View File

@@ -0,0 +1,411 @@
# httprd: web-based remote desktop
# Copyright (C) 2022-2023 bitrate16
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
VERSION = '4.2'
import json
import aiohttp
import aiohttp.web
import argparse
import base64
import gzip
import PIL
import PIL.Image
import PIL.ImageGrab
import PIL.ImageChops
import pyautogui
import traceback
from datetime import datetime
try:
from cStringIO import StringIO as BytesIO
except ImportError:
from io import BytesIO
# Const config
DOWNSAMPLE = PIL.Image.BILINEAR
# Minimal amount of partial frames to be sent before sending full repaint frame to avoid fallback to full repaint on long delay channels
MIN_PARTIAL_FRAMES_BEFORE_FULL_REPAINT = 60
# Minimal amount of empty frames to be sent before sending full repaint frame to avoid fallback to full repaint on long delay channels
MIN_EMPTY_FRAMES_BEFORE_FULL_REPAINT = 120
# Input event types
INPUT_EVENT_MOUSE_MOVE = 0
INPUT_EVENT_MOUSE_DOWN = 1
INPUT_EVENT_MOUSE_UP = 2
INPUT_EVENT_MOUSE_SCROLL = 3
INPUT_EVENT_KEY_DOWN = 4
INPUT_EVENT_KEY_UP = 5
# Failsafe disable
pyautogui.FAILSAFE = False
# Args
args = {}
# Real resolution
real_width, real_height = 0, 0
# Webapp
app: aiohttp.web.Application
def decode_int8(data):
return int.from_bytes(data[0:1], 'little')
def decode_int16(data):
return int.from_bytes(data[0:2], 'little')
def decode_int24(data):
return int.from_bytes(data[0:3], 'little')
def encode_int8(i):
return int.to_bytes(i, 1, 'little')
def encode_int16(i):
return int.to_bytes(i, 2, 'little')
def encode_int24(i):
return int.to_bytes(i, 3, 'little')
def dump_bytes_dec(data):
for i in range(len(data)):
print(data[i], end=' ')
print()
async def get__connect_input_ws(request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
"""
WebSocket endpoint for input & control data stream
"""
# Check access
access = (args.password == request.query.get('password', '').strip())
# Log request
now = datetime.now()
now = now.strftime("%d.%m.%Y-%H:%M:%S")
print(f'[{ now }] { request.remote } { request.method } [{ "INPUT" if access else "NO ACCESS" }] { request.path_qs }')
# Open socket
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(request)
# Close with error code on no access
if not access:
await ws.close(code=4001, message=b'Unauthorized')
return ws
# Track pressed key state for future reset on disconnect
state_keys = {}
def release_keys():
for k in state_keys.keys():
if state_keys[k]:
pyautogui.keyUp(k)
def update_key_state(key, state):
state_keys[key] = state
# Read stream
async def async_worker():
try:
# Reply to requests
async for msg in ws:
# Receive input data
if msg.type == aiohttp.WSMsgType.BINARY:
try:
# Drop on invalid packet
if len(msg.data) == 0:
continue
# Parse params
packet_type = decode_int8(msg.data[0:1])
payload = msg.data[1:]
# Input request
if packet_type == 0x03:
# Unpack events data
data = json.loads(bytes.decode(payload, encoding='ascii'))
# Iterate events
for event in data:
if event[0] == INPUT_EVENT_MOUSE_MOVE: # mouse position
mouse_x = max(0, min(real_width, event[1]))
mouse_y = max(0, min(real_height, event[2]))
pyautogui.moveTo(mouse_x, mouse_y)
elif event[0] == INPUT_EVENT_MOUSE_DOWN: # mouse down
mouse_x = max(0, min(real_width, event[1]))
mouse_y = max(0, min(real_height, event[2]))
button = event[3]
# Allow only left, middle, right
if button < 0 or button > 2:
continue
pyautogui.mouseDown(mouse_x, mouse_y, button=[ 'left', 'middle', 'right' ][button])
elif event[0] == INPUT_EVENT_MOUSE_UP: # mouse up
mouse_x = max(0, min(real_width, event[1]))
mouse_y = max(0, min(real_height, event[2]))
button = event[3]
# Allow only left, middle, right
if button < 0 or button > 2:
continue
pyautogui.mouseUp(mouse_x, mouse_y, button=[ 'left', 'middle', 'right' ][button])
elif event[0] == INPUT_EVENT_MOUSE_SCROLL: # mouse scroll
mouse_x = max(0, min(real_width, event[1]))
mouse_y = max(0, min(real_height, event[2]))
dy = int(event[3])
pyautogui.scroll(dy, mouse_x, mouse_y)
elif event[0] == INPUT_EVENT_KEY_DOWN: # keypress
keycode = event[1]
pyautogui.keyDown(keycode)
update_key_state(keycode, True)
elif event[0] == INPUT_EVENT_KEY_UP: # keypress
keycode = event[1]
pyautogui.keyUp(keycode)
update_key_state(keycode, False)
except:
traceback.print_exc()
elif msg.type == aiohttp.WSMsgType.ERROR:
print(f'ws connection closed with exception { ws.exception() }')
except:
traceback.print_exc()
await async_worker()
# Release stuck keys
release_keys()
return ws
async def get__connect_view_ws(request: aiohttp.web.Request) -> aiohttp.web.StreamResponse:
"""
WebSocket endpoint for frame stream
"""
# Check access
access = (args.password == request.query.get('password', '').strip()) or (args.view_password == request.query.get('password', '').strip())
# Log request
now = datetime.now()
now = now.strftime("%d.%m.%Y-%H:%M:%S")
print(f'[{ now }] { request.remote } { request.method } [{ "VIEW" if access else "NO ACCESS" }] { request.path_qs }')
# Open socket
ws = aiohttp.web.WebSocketResponse()
await ws.prepare(request)
# Close with error code on no access
if not access:
await ws.close(code=4001, message=b'Unauthorized')
return ws
# Frame buffer
buffer = BytesIO()
# Read stream
async def async_worker():
# Last screen frame
last_frame = None
# Track count of partial frames send since last full repaint frame send and prevent firing full frames on low internet
partial_frames_since_last_full_repaint_frame = 0
# Track count of empty frames send since last full repaint frame send and prevent firing full frames on low internet
empty_frames_since_last_full_repaint_frame = 0
# Store remote viewport size to force-push full repaint
viewport_width = 0
viewport_height = 0
try:
# Reply to requests
async for msg in ws:
# Receive input data
if msg.type == aiohttp.WSMsgType.BINARY:
try:
# Drop on invalid packet
if len(msg.data) == 0:
continue
# Parse params
packet_type = decode_int8(msg.data[0:1])
payload = msg.data[1:]
# Frame request
if packet_type == 0x01:
req_viewport_width = decode_int16(payload[0:2])
req_viewport_height = decode_int16(payload[2:4])
quality = decode_int8(payload[4:5])
# Grab frame
if args.fullscreen:
image = PIL.ImageGrab.grab(bbox=None, include_layered_windows=False, all_screens=True)
else:
image = PIL.ImageGrab.grab()
# Real dimensions
global real_width, real_height
real_width, real_height = image.width, image.height
# Resize
if image.width > req_viewport_width or image.height > req_viewport_height:
image.thumbnail((req_viewport_width, req_viewport_height), DOWNSAMPLE)
# Write header: frame response
buffer.seek(0)
buffer.write(encode_int8(0x02))
buffer.write(encode_int16(real_width))
buffer.write(encode_int16(real_height))
# Compare frames
if last_frame is not None:
diff_bbox = PIL.ImageChops.difference(last_frame, image).getbbox()
# Check if this is first frame of should force repaint full surface
if last_frame is None or \
viewport_width != req_viewport_width or \
viewport_height != req_viewport_height or \
partial_frames_since_last_full_repaint_frame > MIN_PARTIAL_FRAMES_BEFORE_FULL_REPAINT or \
empty_frames_since_last_full_repaint_frame > MIN_EMPTY_FRAMES_BEFORE_FULL_REPAINT:
buffer.write(encode_int8(0x01))
# Write body
image = image.convert('RGB')
image.save(fp=buffer, format='JPEG', quality=quality)
last_frame = image
viewport_width = req_viewport_width
viewport_height = req_viewport_height
partial_frames_since_last_full_repaint_frame = 0
empty_frames_since_last_full_repaint_frame = 0
# Send nop
elif diff_bbox is None :
buffer.write(encode_int8(0x00))
empty_frames_since_last_full_repaint_frame += 1
# Send partial repaint region
else:
buffer.write(encode_int8(0x02))
buffer.write(encode_int16(diff_bbox[0])) # crop_x
buffer.write(encode_int16(diff_bbox[1])) # crop_y
# Write body
cropped = image.crop(diff_bbox)
cropped = cropped.convert('RGB')
cropped.save(fp=buffer, format='JPEG', quality=quality)
last_frame = image
partial_frames_since_last_full_repaint_frame += 1
buflen = buffer.tell()
buffer.seek(0)
mbytes = buffer.read(buflen)
buffer.seek(0)
await ws.send_bytes(mbytes)
except:
traceback.print_exc()
elif msg.type == aiohttp.WSMsgType.ERROR:
print(f'ws connection closed with exception { ws.exception() }')
except:
traceback.print_exc()
await async_worker()
return ws
# Encoded page hoes here
# <template:INDEX_CONTENT>
# </template:INDEX_CONTENT>
# handler for /
async def get__root(request: aiohttp.web.Request):
# Log request
now = datetime.now()
now = now.strftime("%d.%m.%Y-%H:%M:%S")
print(f'[{ now }] { request.remote } { request.method } { request.path_qs }')
# Page
# <template:get__root>
return aiohttp.web.FileResponse('index.html')
# </template:get__root>
if __name__ == '__main__':
# Args
parser = argparse.ArgumentParser(description='Process some integers.')
parser.add_argument('--port', type=int, default=7417, metavar='{1..65535}', choices=range(1, 65535), help='server port')
parser.add_argument('--password', type=str, default=None, help='password for remote control session')
parser.add_argument('--view_password', type=str, default=None, help='password for view only session (can only be set if --password is set)')
parser.add_argument('--fullscreen', action='store_true', default=False, help='enable multi-display screen capture')
args = parser.parse_args()
# Password post-process
if args.password is None:
# If no passwords set, enable no-password input+view mode
if args.view_password is None:
args.password = ''
# If only view password set, enable password-protected view mode
else:
args.view_password = args.view_password.strip()
else:
# Enable password-protected input+view mode
args.password = args.password.strip()
# If view password is set, enable password-protected view mode
if args.view_password is not None:
args.view_password = args.view_password.strip()
# Check for match and fallback to input + view mode
if args.password == args.view_password:
args.view_password = None
# Set up server
app = aiohttp.web.Application()
# Routes
app.router.add_get('/connect_input_ws', get__connect_input_ws)
app.router.add_get('/connect_view_ws', get__connect_view_ws)
app.router.add_get('/', get__root)
# Listen
aiohttp.web.run_app(app=app, port=args.port)

1249
src/index.html Normal file

File diff suppressed because it is too large Load Diff