本章做一个远程桌面app,依然和网络相关。我们用“真正的”应用层协议进行通信,解决一个复杂的问题。

简单介绍一下:首先我们的目的是实现一个经典的桌面应用——远程连接,允许用户通过网络操纵其他电脑。这类应用在技术支持和远程协助中使用广泛。

其次,介绍两个术语:主机(host machine)是远程控制者(运行远程控制服务器),客户机(client)是主机控制的系统。远程系统管理基本上就是这样的用户交互过程,主机把客户机当作代理来使用。

因此,关键的两个步骤就是:

  • 收集客户机用户的输入(如鼠标和键盘动作),并应用到主机
  • 从主机向客户机发送任何输出(最常见的是截屏,还有声频、视频等)

远程桌面就是这两个步骤的不断重复。很多商业软件都会实现这些功能,有一些甚至允许运行视频游戏——通过图形和游戏控制器加速。我们准备实现的一些功能如下:

  • 用户的输入只支持鼠标点击或Tab切换
  • 输出只有屏幕截图,因为通过网络抓取声音比较复杂
  • 主机只支持Windows平台,任何桌面版本都行。客户端没有限制,因为Kivy app哪儿都可以运行

最后一条实在遗憾,因为不同的系统截屏和模拟点击行为的API不同,我们只能选择最流行的系统来实现。其他平台的支持以后会实现,原理都一样。

中国用户可以忽略这条:如果没Windows,自己找虚拟机安装一个。Mac上也可以用Parallers安装,就是要换点钱。

本章教学大纲如下:

  • 用Python的Flask微框架写一个HTTP服务器
  • 用PIL(Pillow)实现截屏
  • 用WinAPI功能模拟Windows点击
  • 做一个简单的JavaScript客户端原型,用它来测试
  • 做一个基于Kivy的HTTP客户端app连接到远程桌面服务器

服务器

为了方便测试和使用,我们希望用容易实现的应用层协议做服务器。我们选择HTTP,要容易实现和简单测试,需要具体两个特征:

  • 支持很多特性,包括服务器端和客户端。HTTP是最流行的协议,完全符合。
  • 与其他协议不同,使用HTTP协议,我们可以用JavaScript轻易写出一个可以在浏览器上运行的客户端。这和本书的主题关系不大,但是依然是一个流行的做法

建服务器的模块,我们用Flask,Django更流行,不过太大材小用了。要安装Flask,直接用pip安装即可:

pip install Flask

简单高效,完全开源,文档详细,推荐学习一下。

Flask服务器

服务器通常就是由一系列绑定到不同URL的handler组成的。这些绑定通常叫做路由(routing)。Flask可以非常容易的实现这些功能。我们建立一个网页的服务器server.py

from flask import Flask

app = Flask(__name__)


@app.route("/")
def index():
    return "Hello, Flask"


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=7080, debug=True)

在Flask里面,路由是以修饰器方式实现的,如@app.route('/'),在URL比较少的时候这么做很方便。

'/'路由是服务器域名,对应一个IP地址。运行server.py后,在浏览器打开http://127.0.0.1:7080,看到Hello, Flask说明服务器ok了。

flaskserver

这里,app.run()里面的参数0.0.0.0不是一个有效的IP地址,不能通过正常访问。服务器绑定这个IP表示我们的app会监听所有IPV4接口——也就是说,从任何一个可用的IP发出的请求都会得到相应。

这和默认设置(localhost,127.0.0.1)不同。localhost的IP只允许监听同一个机器传来的请求。因此,这个IP适合调试和测试用。但是,当我们发布产品后,0.0.0.0就是面向世界,开张圣听。不过,注意这不会自动绕过路由器;它可以在你的局域网工作,但是在公网工作可能需要其他配置。

还有,记得设置防火墙策略,因为它需要满足应用层设置的优先级。

端口选择

用哪个端口并不重要,重要的是服务器和客户端要用同一个端口,无论是一个浏览器还是一个Kivy app。

还要注意,几乎所有的系统中1024以下的端口一般只能由授权用户使用(root或admin)。而且很多端口已经分配了固定用途,所以建议选择1024以上的端口。

默认的HTTP端口是80,默认端口通常不需要指定,http://www.w3.orghttp://www.w3.org:80/是一样的。

你可能发现Python开发实在太容易了——几行代码就可以运行一个服务器。不过,不是样样都很简单,有些事情还不能立竿见影。

这是Python的优势:如果你要实现一些不太复杂的功能,试试Python吧,通常都会取得好效果。

服务器新功能——截屏

协议和服务器实现之后,我们来做截屏和模拟点击功能。这里仅实现Windows的实现,Mac和Linux支持可以做练习。

PIL可以实现,用PIL.ImageGrab.grab()就行,我们把截图保存为RGB格式。之后就是把图片接到Flask上,这样就可以通过HTTP传送了。

PIL已经不再维护了,现在有Pillow实现PIL,直接用pip安装即可,具体参阅文档

pip install Pillow

实现的代码如下:

from flask import send_file
from PIL import ImageGrab
from StringIO import StringIO

# # more compatible
# try:
#     from StringIO import StringIO # python2
# except ImportError:
#     from io import BytesIO as # python3


@app.route("/desktop.jpeg")
def desktop():
    screen = ImageGrab.grab()
    buf = StringIO()
    screen.save(buf, "JPEG", quality=75)
    buf.seek(0)
    return send_file(buf, mimetype="image/jpeg")

这里StringIO是把内容存在内存,不是磁盘上。使用API处理临时数据的时候这种“虚拟文件”很有用。本例中,我们不想要保存截屏内容,就是一个临时文件。如果连续下载文件到磁盘上效率很低,不如直接放到内存里,用完就释放。

代码很简单,就是用PIL.ImageGrab.grab()截屏叫screen,然后用screen.save()保存为JPEG格式,这样省流量一些,最后用MIME type形式'image/jpeg'发送到Flask,这样就可以直接现在在浏览器上了。

为了实现更好的速度,用比分辨率的图片更合适,因为每一个帧都要截图是很费流量的。

这里又一次充分证明了一点:验证新概念或市场研究时,快速原型是多么高效。

buf.seek(0)是为了倒回(rewindStringIO实例;否则,程序就到了数据的最后,不会给send_file()发送任何数据了。

现在就可以测试效果了,打开http://127.0.0.1:7080/desktop.jpeg就会看到当前屏幕的截图了。 screenshot

这里的路由很有意思"desktop.jpeg",URL的最后是文件曾经在服务器工具里面是一种习惯,像Personal Home Page (PHP),一种适合做简单的网站的简单编程语言。其实这里并没有路径的概念,你实际上只要把文件的名称输入地址栏然后从服务器获得它。

这么做是很不安全的行为,远程连接者可以看到服务器的配置,比如'/../../etc/passwd'输入地址栏就可以看到密码了,之后的各种木马病毒像Trojans木马(后门)就来了。

Python的网页服务器都经历过这些教训。你也可以这么写,但是强烈不推荐,这样太危险了。另外,Python的模块通常默认也不会使用这些配置。

今天,从文件系统直接获取文件的事情并不是没有,但主要是用于静态文件。另外,有时我们也会把动态网页(如/index.html/desktop.jpeg等)也写成文件名的形式是为了让用户更容易明白这些URL的作用。

模拟点击行为

截屏部分完成之后,我们需要实现的功能就是鼠标点击,用WinAPI可以实现,不过很麻烦,我们用Python的ctypes模块来做。

首先我们需要从URL或者点击的坐标。我们用GET方式来实现:/click?x=100&y=200,这种方法容易在浏览器测试,不像POST和其他HTTP方式需要其他工具来测试。

Flask支持这种URL带参数的方式:

from flask import request


@app.route("/click")
def click():
    try:
        x = int(request.args.get("x"))
        y = int(request.args.get("y"))
    except TypeError:
        return "error: expecting 2 ints, x and y"

建立原型的时候在可能出错的地方加异常处理代码是必要的,因为经常会忘记或发送了不合理的参数,所以我们要做异常处理,所有我们需要检查GET请求的参数是否可用。调试的时候如果出现错误就会显示信息,这样就知道问题出在哪里了。

有了点击的坐标之后,我们就要用WinAPI来调用它们。这里需要两个函数都在user32.dllSetCursorPos()是设置鼠标光标的位置,mouse_event()模拟鼠标的点击事件,比如按下或松开按键。

user32.dll这个32和你系统的32位或64位没关系。Win32 API首次出现在 Windows NT,比之后的AMD64 (x86_64)架构早7年多,之所以这Win32是为了和之前的Win16区分。

mouse_event()的第一个参数是事件类型,一个C语言枚举类型(一组整型常量)。我们可以在Python里面定义这些常量,用常量2表示鼠标按下,4表示鼠标松开并不是很直观。可以这样:

import ctypes

# this is the user32.dll reference
user32 = ctypes.windll.user32

MOUSEEVENTF_LEFTDOWN = 2
MOUSEEVENTF_LEFTUP = 4

Win API文档可以到Microsoft Developer Network (MSDN)去查:SetCursorPos()mouse_event()。 限于篇幅不做详细介绍,而且也不会有到很多功能;WinAPI可谓包罗万象,内容十分丰富,感兴趣自行研究。

模拟点击的代码如下:

@app.route("/click")
def click():
    try:
        x = int(request.args.get("x"))
        y = int(request.args.get("y"))
    except:
        return "error"

    user32.SetCursorPos(x, y)
    user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
    user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
    return "done"

代码很直白,就是设置鼠标位置,然后单击一下(按下,松开)。

现在你可以启动Flask的服务器然后试试点击操作,可以在地址栏输入http://127.0.0.1:7080/click?x=10&y=10,如果左上角(10,10)这个位置有图标,图标会被选中。

当然也可以实现一个双击行为,如果页面刷新的足够快的话(因为打开文件时屏幕变化很大,需要截取很多图片),这可能需要在另外一个设备上运行浏览器,记得修改对应的服务器IP地址。

JavaScript客户端

在这一章,我们将尝试一下JavaScript远程桌面客户端,因为我们用的是HTTP协议,JavaScript非常合适。这个简单的客户端可以在浏览器运行,作为Kivy客户端桌面应用的原型。

如果你不熟悉JavaScript,不用担心,其语法很简单,与Python很相似。我们还要用到jQuery来处理DOM表和AJAX。

很多人可能不赞成在产品设计阶段使用jQuery,尤其是对那些性能要求高的应用。但是,要实现网页app的快速原型,jQuery还是很不错的,因为它用法简单,实现高效。

做网页app,我们得把原来的Hello, Flask替换成。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Remote Desktop</title>
</head>
    <body>
        <script src="//code.jquery.com/jquery-1.11.1.js"></script>
        <script>
        // code goes here
        </script>
    </body>
</html>

Flask要使用这个HTML,需要对index()做一点调整:

@app.route("/")
def index():
    return app.send_static_file("index.html")

这和前面的desktop()函数是一样的,不过是从硬盘读取HTML文件再显示。

截屏的无限循环

现在,让我们展示一个连续的屏幕显示方案:我们的程序每两秒请求一个截屏,然后立刻向用户显示出来。因为我们写的是网页应用,所有的复杂情况都由浏览器处理:用<img>标签加载图片显示到屏幕上。实现步骤如下:

  1. 删除旧的<img>标签,如果有的话
  2. 增加一个新的<img>标签
  3. 每2秒重复一次

用JavaScript实现如下:

function reload_desktop() {
    $('img').remove()
    $('<img>', {src: '/desktop.jpeg?' + 
        Date.now()}).appendTo('body')
}
setInterval(reload_desktop, 2000)

代码解释如下:

  • $()是jQuery选择网页元素的函数,我们可以在元素上实现各种操作,如.remove().insert()
  • Date.now()返回当前时间戳,就是1970年1月1日到当前时间的毫秒数。我们用这个数据来阻止缓存,这样每次的信息就会更新了。

让我们把图片的调整为适合浏览器的尺寸,去掉所有的边距。用CSS也很容易实现:

<style>
    body { margin: 0 }
    img { max-width: 100% }
</style>

应用的截图如下所示:

remotedesk

加载的时候你会发现图片在闪烁,因为在完全加载之前就立刻显示desktop.jpeg。还有一个问题就是下载频率是固定的,我们随意设定为两秒。由于网速的原因,用户可能还没看到图片加载完成就又改变了。

这里只是原型,在Kivy设计的时候我们会纠正这个问题。

把点击传回主机

下面我们要把<img>截图上的点击传给服务器。对<body>元素使用.bind()方法可以实现。因为我们不断的增删元素,图片上绑定的内容在更新后会丢失,而重新绑定也没必要。

function send_click(event) {
    var fac = this.naturalWidth / this.width
    $.get('/click', {x: 0|fac * event.clientX,
               y: 0|fac * event.clientY})
}
$('body').on('click', 'img', send_click)

这里我们计算了点击的真实位置:通过图片缩放比例对坐标位置进行调整。

$$x,y_{server} = \frac {width_{natural}}{width_{scaled}} \times x,y_{client}$$

JavaScript里面的0|expression表达式是Math.floor()的另外一种形式,更快更精确,只是语法有点差异。

然后用jQuery的$.get()函数把前面计算的结果发给服务器。由于我们打算立刻显示一个新截屏,所以就没有对服务器响应进行处理——如果我们最后一个动作有任何变化,都会立即显示出来。

用这个远程桌面原型,我们已经可以看到桌面,执行机器上的程序了。现在,让我们用Kivy来实现这个原型,同时做些改进,增加滚动条、去掉屏幕闪烁,让它更适用于移动设备。

Kivy远程桌面app

我们可以重用上一章聊天app的一些功能。这里个应用很相似,都是由两个屏幕构成,一个屏幕是带服务器地址的登录界面。我们就把chat.kv文件改造成remotedesktop.kv文件,里面的ScreenManager保留。

登录界面

就是下面的代码,包含三个部分——标题、输入文本框、登录按钮——位于屏幕的顶部:

Screen:
    name: 'login'

    BoxLayout:
        orientation: 'horizontal'
        y: root.height - self.height

        Label:
            text: 'Server IP:'
            size_hint: (0.4, 1)
        TextInput:
            id: server
            text: '10.211.55.5' # put your server IP here
        Button:
            text: 'Connect'
            on_press: app.connect()
            size_hint: (0.4, 1)

只要一个输入文本框Server IP。其实你也可以用服务器名称,但是这需要配置,直接IP省事儿。

虽然用IP地址不是很直观,但是我们暂时没更好的选择。要做一个自动发现的网络服务来避免IP地址,用着更舒服,但是也更复杂(需要一大堆技术来实现)。

这里,需要掌握基本的网络技能,比如把设备连接到路由器。这些都超出本文的范围,简单说下:

  • 当你所有的设备都连接到同一网络是测试更容易
  • 通一台设备上使用虚拟机来测试也行。这样可以避开扫描网络、输密码、连线等等操作。
  • 要看设备的IP地址,用ipconfig(Windows)或ifconfig。公网IP不会显示出来,但局域网IP会显示。

登录窗口很简单,前面已经学习过,下面让我们来实现远程桌面窗口。

远程桌面窗口

这个屏幕界面应该有二维的滚动条,可以在不同屏幕上选择位置。我们还按照上一章聊天app的滚动条来实现。不同的是:这里是二维的,这次我们不想让让任何超限再触底反弹的效果以避免冲突,操作系统桌面通常不需要这些效果。

这个屏幕的remotedesktop.kv文件如下:

Screen:
    name: 'desktop'

    ScrollView:
        effect_cls: ScrollEffect

        Image:
            id: desktop
            nocache: True
            on_touch_down: app.send_click(args[1])
            size: self.texture_size
            size_hint: (None, None)

ScrollView里,我们effect_cls: ScrollEffect来禁止超限行为。如果你想要这种行为,可以不用这行。由于ScrollEffect不是默认存在的,需要导入:

#:import ScrollEffect kivy.effects.scroll.ScrollEffect

关键点是把Imagesize_hint属性设置为(None, None);否则Kivy就会自动放缩图片来适合屏幕尺寸,这样滚动条就没用了,而且在不同比例的屏幕上,桌面会失真。设置为None表示要是手动调节。

之后,我们把size属性设置成self.texture_size。这样就可以把服务生成的desktop.jpeg的尺寸传递到屏幕了(这是有主机的屏幕决定的,我们不能改变)。

还有nocache: True属性,是让Kivy取消缓存,不要保存截图的临时文件。最后,Imageon_touch_down属性。我们想传递精确的坐标值和触摸事件的其他属性,就得用args[1]args[0]是要点击的部件,也就是图片本身(我们只有一个Image实例,所以不需要把它传递到事件handler)。

截屏在Kivy里的循环

现在让我们把前面这些模块集成到Python里。和JavaScript实现相反,我们不用加载图片和相关的功能,所以要写点儿代码;不过这些很容易实现,而且更容易维护。

为了异步加载图片,我们要用Kivy的Loader类,实现思路如下:

  1. 当用户填好Server IP,在登录窗口点击Connect后,RemoteDesktopApp.connect()函数启动
  2. 它把控制传递到reload_desktop(),这个函数会从/desktop.jpeg下载图片
  3. 图片加载之后,Loader调用desktop_loaded(),把图片展示到屏幕上,然后调用下一个reload_desktop()。这样我们就通过异步方式实现了截屏的循环

下面是main.py文件实现代码:

from kivy.loader import Loader


class RemoteDesktopApp(App):
    def connect(self):
        self.url = "http://%s:7080/desktop.jpeg" % self.root.ids.server.text
        self.send_url = "http://%s:7080/click?" % self.root.ids.server.text
        self.reload_desktop()

我们保持url(服务器IP和/desktop.jpeg)和send_url(服务器IP和/click),然后传递RemoteDesktopApp.reload_desktop()函数:

def reload_desktop(self, *args):
    desktop = Loader.image(self.url, nocache=True)
    desktop.bind(on_load=self.desktop_loaded)

前面函数就是下载图片。图片下载完之后,就把图片加载到RemoteDesktopApp.desktop_loaded()

不用忘了用nocache=True禁止缓存,没有这点桌面就只会加载一次,因为URL是一样的。在JavaScript里面,我们解决这个问题是把?timestamp放在URL之后,在Python我们可以不这样做,因为Kivy支持缓存控制功能。

然后就可以完成桌面加载程序了:

from kivy.clock import Clock


def desktop_loaded(self, desktop):
    if desktop.image.texture:
        self.root.ids.desktop.texture = desktop.image.texture

    Clock.schedule_once(self.reload_desktop, 1)

    if self.root.current == "login":
        self.root.current = "desktop"

函数接收新图片desktop,更新屏幕,然后每1秒循环一次。

在第一章的时钟app里面,我们讨论过Clock对象的更新问题,当时是用schedule_interval(),类似于JavaScript的setInterval(),这里我们想要持续状态,类似于JavaScript的setTimeout()功能。

现在屏幕更新就实现了,截图如下所示: desktopserver

传送点击

远程桌面已经完成,下面我们实现点击行为,这就需要监听图片的on_touch_down事件,把触摸的坐标值传递到send_click()函数。在remotedesktop.kv文件里面:

Screen:
    name: 'desktop'

    ScrollView:
        effect_cls: ScrollEffect
        Image:
            on_touch_down: app.send_click(args[1])
            # The rest of the properties unchanged

在Python的class RemoteDesktopApp中实现send_click()函数:

def send_click(self, event):
    params = {"x": int(event.x), "y": int(self.root.ids.desktop.size[1] - event.y)}
    urlopen(self.send_url + urlencode(params))

这里需要注意的是坐标值:在Kivy里面,y轴是向上的,和数学上的概念一致,而Windows和浏览器里面都是向下的。所以我们用桌面高度event.y来转换。

另一个容易出错的就是Python的urllib模块,Python2和Python3的差别很大,也可以用requests模块,不过这里用标准模块。可以通过异常处理开解决版本问题:

try:  # python 2
    from urllib import urlencode
except ImportError:  # python 3
    from urllib.parse import urlencode

try:  # python 2
    from urllib2 import urlopen
except ImportError:  # python 3
    from urllib.request import urlopen

虽然代码不是很简洁,但是可以实现兼容性,目前只能这样。连Guido老爹(Python创始人)现在也这么写,还能说啥呢。

后续任务

现在我们的远程桌面app就搞定了,建议在局域网和网速快的时候使用。当然,还有很多问题需要解决,很多新特性可以加入,如果你感兴趣,下面是一些努力的方向:

  • 把鼠标行为作为单独事件处理,可以实现双击、拖拽等
  • 考虑网络延迟。如果用户网速很慢,你可以把图片质量降低来保证流畅性,给用户一个选择
  • 让服务实现跨平台,包括Mac,Linux,Android和Chrome OS

另外,这是一个工业级强度的任务,做这种软件很难,更不用说让它完美,具有极快的速度。Kivy帮你在UI设计、图片下载和缓存处理方面省了很多精力,但也只能做这些了。

所有,如果有些功能无法很快实现,请不要担心——错误和失败都是正常的,可以深入学习一些计算机间相互通信的知识。

总结

这就远程桌面app的所有内容。这个app可以处理一下简单任务,比如点击iTunes里面的Play按钮,或者关闭一个程序。更复杂的任务,尤其是一些运维工作时,可能需要更专业的软件。

我们用Flask建立了服务器实现了对图片的动态处理,主机系统的交互。我们还做了一个轻量级的JavaScript客户端实现了想要的功能,这说明我们的Kivy app并不是非主流方法。而且,在写Kivy代码之前,我们就有了服务器和客户端原型。

这个原型帮助我们快速实现了我们的想法,可以立刻看到实现的效果。这里不打算讨论测试驱动开发(test-driven development,TDD),它是否成熟值得商榷,也不论事件驱动编程对这个案例是否有益。但是先做好每一个部分,然后把它们组合在一起,这种分而治之的方法还是比一下子写一个整体要更有效率。

最后,Kivy也能很好的处理网络GUI app。比如,上一章里面与Twisted协作,实现通过网络加载内容的功能——这些都为建立多用户的网络app提供了极大帮助。

现在,让我们进入另一个领域:Kivy游戏开发。