#!/usr/bin/env python
# coding: utf-8
# # Kivy指南-5-远程桌面app
#
# > 做一个远程桌面app,用“真正的”应用层协议进行通信
#
# - toc: true
# - badges: true
# - comments: true
# - categories: [jupyter,Kivy,Android,iOS]
# - image: kbpic/5.3remotedesk.png
# 本章做一个远程桌面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**
#
# 简单高效,完全开源,[文档](https://github.com/mitsuhiko/flask)详细,推荐学习一下。
# ### Flask服务器
# 服务器通常就是由一系列绑定到不同URL的handler组成的。这些绑定通常叫做路由(*routing*)。Flask可以非常容易的实现这些功能。我们建立一个网页的服务器`server.py`:
# In[2]:
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](kbpic/5.1flaskserver.png)
#
# 这里,`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.org`和`http://www.w3.org:80/`是一样的。
#
# 你可能发现Python开发实在太容易了——几行代码就可以运行一个服务器。不过,不是样样都很简单,有些事情还不能立竿见影。
#
# 这是Python的优势:如果你要实现一些不太复杂的功能,试试Python吧,通常都会取得好效果。
# ## 服务器新功能——截屏
# 协议和服务器实现之后,我们来做截屏和模拟点击功能。这里仅实现Windows的实现,Mac和Linux支持可以做练习。
#
# PIL可以实现,用`PIL.ImageGrab.grab()`就行,我们把截图保存为RGB格式。之后就是把图片接到Flask上,这样就可以通过HTTP传送了。
#
# PIL已经不再维护了,现在有Pillow实现PIL,直接用pip安装即可,具体参阅[文档](http://pillow.readthedocs.org/)。
#
# **pip install Pillow**
#
# 实现的代码如下:
# In[3]:
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)`是为了倒回(*rewind*)`StringIO`实例;否则,程序就到了数据的最后,不会给`send_file()`发送任何数据了。
#
# 现在就可以测试效果了,打开`http://127.0.0.1:7080/desktop.jpeg`就会看到当前屏幕的截图了。
#
# ![screenshot](kbpic/5.2screenshot.png)
# 这里的路由很有意思**"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带参数的方式:
# In[ ]:
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.dll`:`SetCursorPos()`是设置鼠标光标的位置,`mouse_event()`模拟鼠标的点击事件,比如按下或松开按键。
#
# >`user32.dll`这个32和你系统的32位或64位没关系。Win32 API首次出现在 Windows NT,比之后的AMD64 (x86_64)架构早7年多,之所以这Win32是为了和之前的Win16区分。
#
# `mouse_event()`的第一个参数是事件类型,一个C语言枚举类型(一组整型常量)。我们可以在Python里面定义这些常量,用常量2表示鼠标按下,4表示鼠标松开并不是很直观。可以这样:
# In[1]:
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()`](https://msdn.microsoft.com/en-us/library/windows/desktop/ms648394.aspx),[`mouse_event()`](https://msdn.microsoft.com/en-us/library/windows/desktop/ms646260.aspx)。
#
# >限于篇幅不做详细介绍,而且也不会有到很多功能;WinAPI可谓包罗万象,内容十分丰富,感兴趣自行研究。
#
# 模拟点击的代码如下:
# In[ ]:
@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**替换成。
#
# ```html
#
#
#
#
# Remote Desktop
#
#
#
#
#
#
# ```
#
# Flask要使用这个HTML,需要对`index()`做一点调整:
# In[ ]:
@app.route("/")
def index():
return app.send_static_file("index.html")
# 这和前面的`desktop()`函数是一样的,不过是从硬盘读取HTML文件再显示。
# ### 截屏的无限循环
# 现在,让我们展示一个连续的屏幕显示方案:我们的程序每两秒请求一个截屏,然后立刻向用户显示出来。因为我们写的是网页应用,所有的复杂情况都由浏览器处理:用``标签加载图片显示到屏幕上。实现步骤如下:
#
# 1. 删除旧的``标签,如果有的话
# 2. 增加一个新的``标签
# 3. 每2秒重复一次
#
# 用JavaScript实现如下:
#
# ```
# function reload_desktop() {
# $('img').remove()
# $('', {src: '/desktop.jpeg?' +
# Date.now()}).appendTo('body')
# }
# setInterval(reload_desktop, 2000)
# ```
# 代码解释如下:
#
# - `$()`是jQuery选择网页元素的函数,我们可以在元素上实现各种操作,如`.remove()`或`.insert()`
# - `Date.now()`返回当前时间戳,就是1970年1月1日到当前时间的毫秒数。我们用这个数据来阻止缓存,这样每次的信息就会更新了。
#
# 让我们把图片的调整为适合浏览器的尺寸,去掉所有的边距。用CSS也很容易实现:
#
# ```html
#
# ```
#
# 应用的截图如下所示:
#
# ![remotedesk](kbpic/5.3remotedesk.png)
#
# 加载的时候你会发现图片在闪烁,因为在完全加载之前就立刻显示`desktop.jpeg`。还有一个问题就是下载频率是固定的,我们随意设定为两秒。由于网速的原因,用户可能还没看到图片加载完成就又改变了。
#
# 这里只是原型,在Kivy设计的时候我们会纠正这个问题。
# ### 把点击传回主机
# 下面我们要把` `截图上的点击传给服务器。对``元素使用`.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`保留。
# ### 登录界面
# 就是下面的代码,包含三个部分——标题、输入文本框、登录按钮——位于屏幕的顶部:
#
# ```yaml
# 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`文件如下:
#
# ```yaml
# 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
# ```
# 关键点是把`Image`的`size_hint`属性设置为`(None, None)`;否则Kivy就会自动放缩图片来适合屏幕尺寸,这样滚动条就没用了,而且在不同比例的屏幕上,桌面会失真。设置为`None`表示要是手动调节。
#
# 之后,我们把`size`属性设置成`self.texture_size`。这样就可以把服务生成的`desktop.jpeg`的尺寸传递到屏幕了(这是有主机的屏幕决定的,我们不能改变)。
#
# 还有`nocache: True`属性,是让Kivy取消缓存,不要保存截图的临时文件。最后,`Image`的`on_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`文件实现代码:
# In[ ]:
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()`函数:
# In[ ]:
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支持缓存控制功能。
#
# 然后就可以完成桌面加载程序了:
# In[ ]:
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](kbpic/5.4desktopserver.png)
# ### 传送点击
# 远程桌面已经完成,下面我们实现点击行为,这就需要监听图片的`on_touch_down`事件,把触摸的坐标值传递到`send_click()`函数。在`remotedesktop.kv`文件里面:
#
# ```yaml
# 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()`函数:
# In[ ]:
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`模块](http://python-requests.org/),不过这里用标准模块。可以通过异常处理开解决版本问题:
# In[ ]:
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游戏开发。