#!/usr/bin/env python3
#
# Display local desktop on remove host over LAN without VGA/HDMI cable.
# Copyright (c) 2019, Hiroyuki Ohsaki.
# All rights reserved.
#

# 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
# 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/>.

import contextlib
import os
import socket
import struct
import sys
import time
import zlib

from Xlib import X, display
from perlcompat import die, warn, getopts
with contextlib.redirect_stdout(None):
    import pygame
    import pygame.gfxdraw
    import pygame.transform
import rgbconv

DEBUG = False

HEADER_SIZE = 48
MAX_SEGMENT_SIZE = 50000

BLANK_COLOR = 0, 0, 255, 255
POINTER_COLOR = 255, 255, 0, 128
POINTER_SIZE = 10
FONT_SIZE = 20
LABEL_COLOR = 255, 255, 255, 128

def usage():
    die("""\
usage: {} [-vrf] [-s host] [-p port] [-W width] [-H height] [-F #]
  -v       verbose mode
  -r       receiver mode
  -f       full speed mode (no frame rate limit)
  -s host  specify receiver hostname/address
  -p port  port number
  -W #     screen width (default: 800 [pixel])
  -H #     screen height (default: 600 [pixel])
  -F #     maximum frame rate (default: 5 [frame/second])
""".format(sys.argv[0]))

class FrameError(Exception):
    pass

def debug(msg):
    if DEBUG:
        warn(msg)

def pointer_position():
    """Obtain the current position of the pointer (i.e., mouse) on X11 screen.
    This code is contributed by Hal."""
    xscreen = display.Display().screen()
    stat = xscreen.root.query_pointer()
    x, y = stat.root_x, stat.root_y
    return x, y

def dump_root_window(width, height):
    """Return the rectangle of the root window of WIDTH x HEIGHT in RGBX format."""
    xscreen = display.Display().screen()
    img = xscreen.root.get_image(0, 0, width, height, X.ZPixmap, 0xffffffff)
    # format conversion from BGRX to RGBX
    rgbconv.bgra2rgba(img.data)
    return img.data

class Frame:
    def __init__(self, width=800, height=600, label='NO LABEL'):
        self.type_ = 0
        self.size = None
        self.width = width
        self.height = height
        self.pnt_x = None
        self.pnt_y = None
        self.label = label
        self.img = None

    def header(self, size, type=None):
        """Compose a 48-byte frame header.  The payload size must be specified
        by SIZE.

        sendscreen frame header format:
        name        size    description
        identifier  4       'SSCR'
        type        1       payload type
        reserved    3
        size        4       payload size (int)
        width       2       frame width (short)
        height      2       frame height (short)
        pnt_x       2       pointer x (short)
        pnt_y       2       pointer y (short)
        label       28
        """
        if type is None:
            type = self.type_
        label = bytes(self.label, encoding='utf-8')
        return struct.pack('4s Bxxx I HH HH 28s', b'SSCR', type, size,
                           self.width, self.height, self.pnt_x, self.pnt_y,
                           label)

    def parse_header(self, buf):
        """Parse byte string BUF and store the header values in object
        attributes."""
        ident, type_, size, width, height, pnt_x, pnt_y, label = struct.unpack(
            '4s Bxxx I HH HH 28s', buf)
        if ident != b'SSCR':
            raise FrameError('invalid header.')
        self.type_ = type_
        self.size = size
        self.width = width
        self.height = height
        self.pnt_x = pnt_x
        self.pnt_y = pnt_y
        # FIXME: could be written simpler
        label = label.replace(b'\x00', b'')
        self.label = label.decode()
        return True

    def capture_image(self):
        """Capture the current screen and store the position of the current
        mouse pointer in object attributes."""
        self.img = dump_root_window(self.width, self.height)
        self.pnt_x, self.pnt_y = pointer_position()

    def store_image(self, zimg):
        """Decode zlib-compressed image data ZIMG and record the size of the
        (compressed) image in attribute SIZE."""
        try:
            self.img = zlib.decompress(zimg)
        except zlib.error:
            raise FrameError('decompression failed.')
        self.size = len(zimg)

    def full_message(self):
        """Compose and return type-0 (full image) message composed of the
        header and the payload (Zlib-compressed frame image)."""
        zimg = zlib.compress(self.img)
        header = self.header(len(zimg))
        return header + zimg

    def delta_message(self, last_img):
        """Compose and return type-1 (delta image) message composed of the
        header and the payload (Zlib-compressed delta frame image)."""
        # create a copy to avoid changing the original
        delta_img = bytes(bytearray(self.img))
        rgbconv.sub_bytes(delta_img, last_img)
        zimg = zlib.compress(delta_img)
        header = self.header(len(zimg), type=1)
        return header + zimg

class Sender:
    def __init__(self,
                 server='localhost',
                 port=5000,
                 width=800,
                 height=600,
                 fps=10):
        self.server = server
        self.port = port
        self.width = width
        self.height = height
        self.fps = fps
        self.last_display = time.time()
        self.last_img = None
        self.nsent = 0

    def compose_label(self):
        """Generate a label displayed on the screen."""
        user = os.getenv('USER')
        hostname = socket.gethostname()
        return '{} @ {}'.format(user, hostname)

    def build_message(self, frame):
        """Compose a message composed of the header and the payload."""
        every_5sec = self.nsent % (self.fps * 5) == 0
        if self.last_img and not every_5sec:
            msg = frame.delta_message(self.last_img)
        else:
            msg = frame.full_message()
        self.last_img = frame.img
        return msg

    def send_message(self, msg):
        """Send a message MSG via UDP.  If the message is larger than
        MAX_SEGMENT_SIZE, it will be split into several fragments."""
        addr = self.server, self.port
        debug('sending message ({:,} bytes) to {}:{}...'.format(
            len(msg), *addr))
        for i in range(0, len(msg), MAX_SEGMENT_SIZE):
            self.sk.sendto(msg[i:i + MAX_SEGMENT_SIZE], addr)
        self.nsent += 1

    def sync(self):
        """Adjust the timing to conform the specified frame rate."""
        if not self.fps:
            return
        delta = 1 / self.fps
        current_time = time.time()
        elapsed = current_time - self.last_display
        if elapsed < delta:
            time.sleep(delta - elapsed)
        self.last_display = current_time

    def mainloop(self):
        """Repeatedly send the rectangle of the current desktop to remote host
        using UDP."""
        label = self.compose_label()
        self.sk = socket.socket(family=socket.AF_INET, type=socket.SOCK_DGRAM)
        while True:
            frame = Frame(width=self.width, height=self.height, label=label)
            frame.capture_image()
            msg = self.build_message(frame)
            self.send_message(msg)
            self.sync()

class Receiver:
    def __init__(self, port=5000, width=800, height=600):
        self.port = port
        self.width = width
        self.height = height
        self.ndrawn = 0
        self.last_img = None

    def disable_screensaver(self):
        """Disable screen saver and display power management."""
        # FIXME: restore settings on exit
        os.system('xset s off')
        os.system('xset -dpms')

    def init_display(self):
        """Initialize display settings and create a window for display."""
        debug('initializing display...')
        pygame.display.init()
        pygame.font.init()
        self.screen = pygame.display.set_mode((self.width, self.height))
        self.font = pygame.font.SysFont('Helvetica', FONT_SIZE)

    def draw_img(self, screen, img, width, height):
        """Draw an image IMG with width WIDTH and height HEIGHT at the origin
        (0, 0) on screen SCREEN."""
        try:
            # convert RGBX image to pygame.Surface object
            img = pygame.image.fromstring(img, (width, height), 'RGBX')
        except ValueError:
            return
        screen.blit(img, (0, 0))

    def draw_pointer(self, screen, x, y):
        """Draw a virtual pointer at (X, Y) on screen SCREEN."""
        pygame.gfxdraw.filled_ellipse(screen, x, y, POINTER_SIZE, POINTER_SIZE,
                                      POINTER_COLOR)

    def draw_label(self, screen, font, label):
        """Draw a text LABEL using font FONT around the upper-right corner of
        the screen SCREEN."""
        text = font.render(label, 1, LABEL_COLOR)
        x = screen.get_width() - text.get_width() - FONT_SIZE // 2
        y = FONT_SIZE // 2
        screen.blit(text, (x, y))

    def draw(self, frame):
        """Draw frame FRAME at the origin (0, 0) of the current window."""
        self.screen.fill((0, 0, 0))
        self.draw_img(self.screen, frame.img, frame.width, frame.height)
        # blink the pointer every second
        if int(time.time() * 2) % 2 == 0:
            self.draw_pointer(self.screen, frame.pnt_x, frame.pnt_y)
        self.draw_label(self.screen, self.font, frame.label)
        pygame.display.update()
        self.ndrawn += 1

    def recv_message(self):
        """Receive an incoming message and decode the message.  Return the
        frame decoded"""
        frame = Frame()
        zimg = None
        addr = None, None
        while True:
            try:
                debug('waiting incoming UDP datagram...')
                buf, addr = self.sk.recvfrom(MAX_SEGMENT_SIZE)
            except socket.timeout:
                return False
            if buf.startswith(b'SSCR'):
                frame.parse_header(buf[:HEADER_SIZE])
                zimg = buf[HEADER_SIZE:]
            elif zimg:
                zimg += buf
            if zimg and len(zimg) >= frame.size:
                break
        debug('message received ({:,} bytes) from {}:{}...'.format(
            len(zimg), *addr))
        frame.store_image(zimg)
        return frame

    def decode(self, frame):
        """Decode the image in fram FRAME if necessary."""
        if frame.type_ == 1 and self.last_img:
            rgbconv.add_bytes(frame.img, self.last_img)
        self.last_img = frame.img

    def mainloop(self):
        """Repeatedly receive frames from a client, and display the frame in
        the window."""
        self.disable_screensaver()
        self.init_display()
        self.sk = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sk.settimeout(1.)
        self.sk.bind(('', self.port))  # all available interfaces
        while True:
            frame = self.recv_message()
            if frame and frame.img:
                self.decode(frame)
                self.draw(frame)
            else:
                self.screen.fill(BLANK_COLOR)
                pygame.display.update()

def main():
    opt = getopts('vrfs:p:w:h:F:') or usage()
    if opt.v:
        global DEBUG
        DEBUG = True
    receiver_mode = opt.r
    server = opt.s if opt.s else 'localhost'
    port = int(opt.p) if opt.p else 5000
    width = int(opt.w) if opt.w else 800
    height = int(opt.h) if opt.h else 600
    fps = int(opt.F) if opt.F else 5
    fps = 100 if opt.f else fps
    if receiver_mode:
        receiver = Receiver(port, width, height)
        receiver.mainloop()
    else:
        sender = Sender(server, port, width, height, fps=fps)
        sender.mainloop()

if __name__ == "__main__":
    main()
