一、屌炸天效果
1、网站效果
guoshao-service.test.upcdn.net/file-path/v…
2、小程序效果
guoshao-service.test.upcdn.net/file-path/v…
二、实现思路
要实现云手机四端的通讯【设备客户端+设备服务端+网站/小程序客户端+网站/小程序服务端】,流程是用户操作屏幕=>传递像素点和指令类型给网站/小程序服务端=>网站/小程序服务端里面去执行对应的设备客户端的send方法,去通知设备服务端触发指令,从而实现云手机应用、滑动屏幕等功能。
三、准备工作
1、准备一台二手iphone7 13系统的手机【咸鱼上230块钱】,然后进行设备越狱【淘宝30块钱解决】,然后安装触动专业版。
下载链接:guoshao-service.test.upcdn.net/file-path/a…
安装方法:zhuanlan.zhihu.com/p/467301188
2、打开触动专业版、启动服务。
3、手机需要跟电脑【或者上线后的服务器】在同一个局域网内,所以手机需要连接wifi。
四、代码实现
代码如下,只需要更改ts_screen.prototype.init方法里的最上面两行即可。
前端部分:
<html>
<head>
<title>跨屏控制</title>
<link rel="stylesheet" href="https://www.guoshao520.com/my-file-path/css/phone-style.css">
<script type="text/javascript">
function ts_screen() {
this.init();
}
/**
* 初始化
* @Author 郭少
* @DateTime 2021-05-29T15:15:42+0800
*/
ts_screen.prototype.init = function () {
let ip = "192.160.0.24"; // 手机连接wifi后的ip地址
this.wsUrl = "ws://192.160.0.95:8866"; // 本地ws服务的地址
this.params = {
ip: ip,// 设备ip
port: "50005",// 截屏端口
ws_port: "51008",// ws控制端口
frame_number: 8, // 每秒获取屏幕帧数
screen_render: ""// 参数为:uninterrupted 不间断渲染屏幕 为空则 一张渲染完再渲染下一张
};
this.params.imgScreen = document.querySelector("#imgScreen");
this.params.iframe = document.querySelector("#iframe");
// 获取屏幕
this.request_img();
// 绑定设备操作
this.screen_action();
}
/**
* 发送文字
* @param message 文字
* @param ip 设备ip
* @constructor
*/
ts_screen.prototype.Send_Text = function (type, x, y, auth, ip, state, cb, text) {
let params = this.params;
let move_type = 1;
switch (type) {
case "down":
this.params.Mouse_Down = true;
move_type = 1;
break;
case "move":
if (!this.params.Mouse_Down) {
move_type = 0;
} else
move_type = 2;
break;
case "up":
move_type = 3;
this.params.Mouse_Down = false;
break;
default:
move_type = type;
break;
}
let that = this;
if (move_type > 0) {
x = x * 2;
y = y * 2;
params.doSend({ input: { type: move_type, x: x, y: y, state: state || 0, text: text || "" } });
}
}
/**
* 输入文字加载
* @Author 郭少
* @DateTime 2021-05-29T11:36:09+0800
*/
ts_screen.prototype.init_input_content = function () {
let params = this.params;
let imgScreen = params.imgScreen;
let that = this;
// 对屏幕操作
let Mouse_Option = this.Mouse_Option.bind(this);
let input_content = document.querySelector(".input_content");
// 鼠标拖动输入框
let input_content_header = input_content.querySelector(".input_content_header");
input_content_header.onmousedown = function (ev) {
input_header_mouse("down", ev);
}
document.body.onmousemove = function (ev) {
input_header_mouse("move", ev);
}
input_content_header.onmouseup = function (ev) {
input_header_mouse("up", ev);
}
// 鼠标移动
let input_header_params = {};
// 鼠标拖动输入框事件
let input_header_mouse = function (type, ev) {
switch (type) {
case "down":
input_header_params.down = true;
// 记录输入框当前所在位置
input_header_params.clientX = input_content.offsetLeft;
input_header_params.clientY = input_content.offsetTop;
// 记录鼠标所在位置
input_header_params.x = ev.x;
input_header_params.y = ev.y;
break;
case "move":
if (input_header_params.down) {
// 计算移动到的位置 原始位置 - (鼠标按下的位置 - 鼠标当前的位置)
input_content.style.left = input_header_params.clientX - (input_header_params.x - ev.x) + "px";
input_content.style.top = input_header_params.clientY - (input_header_params.y - ev.y) + "px";
}
break;
case "up":
input_header_params.down = false;
break;
}
}
let main = document.querySelector(".main");
// 点击输入文字按钮显示文本框
let btnInput = document.querySelector("#btnInput");
btnInput.onclick = function () {
input_content.style.display = "block";
let x = main.offsetLeft + imgScreen.offsetWidth + 20;
let y = main.offsetTop + main.offsetHeight - input_content.offsetHeight;
input_content.style.left = x + "px";
input_content.style.top = y + "px";
}
// 隐藏输入文本框
let close_input = input_content.querySelector("#close_input");
close_input.onclick = function () {
input_content.style.display = "none";
}
// 点击发送文字到设备
let btnSend = input_content.querySelector("#btnSend");
let textarea = input_content.querySelector(".textarea");
btnSend.onclick = function () {
Mouse_Option(11, 0, 0, 0, function () { }, textarea.value);
}
}
/**
* 屏幕动作
* @Author 郭少
* @DateTime 2021-05-29T12:40:03+0800
*/
ts_screen.prototype.screen_action = function () {
let params = this.params;
let imgScreen = params.imgScreen;
let that = this;
// 首页
let btnHome = document.querySelector("#btnHome");
btnHome.onclick = function () {
Mouse_Option(20, 0, 0, 0);
}
// 多任务
let btnMultitask = document.querySelector("#btnMultitask");
btnMultitask.onclick = function () {
Mouse_Option(21, 0, 0, 0);
}
// 加载输入文字
this.init_input_content();
/**
* 屏幕加载出错
* @Author 郭少
* @DateTime 2021-05-29T12:32:02+0800
*/
imgScreen.onerror = function (err) {
console.log("error", err)
}
/**
* 屏幕加载完成
* @Author 郭少
* @DateTime 2021-05-29T12:32:27+0800
*/
imgScreen.onload = function () {
if (!params.sourceSize ||
(params.nowSize && params.nowSize.width != this.width) ||
(params.nowSize && params.nowSize.height != this.height)) {
let img = document.createElement("img");
img.src = this.src;
img.onload = function () {
params.sourceSize = { width: this.width, height: this.height };
}
params.nowSize = { width: this.width, height: this.height };
}
if (!params.websocket) {
that.websocket_connection();
}
if (params.screen_render !== "uninterrupted") {
setTimeout(function () {
that.request_img();
}, 1000 / params.frame_number);
}
}
let mousedown = false;
// 鼠标按下
imgScreen.onmousedown = function (ev) {
if (!params.websocket) return;
Mouse_Option("down", ev.offsetX, ev.offsetY);
mousedown = true;
}
// 鼠标在屏幕拖动
imgScreen.onmousemove = function (ev) {
if (!params.websocket) return;
Mouse_Option("move", ev.offsetX, ev.offsetY);
}
// 鼠标离开屏幕
imgScreen.onmouseout = function (ev) {
if (!params.websocket) return;
Mouse_Option("up", ev.offsetX, ev.offsetY);
mousedown = false;
}
// 鼠标在屏幕抬起
imgScreen.onmouseup = function (ev) {
if (!params.websocket) return;
Mouse_Option("up", ev.offsetX, ev.offsetY);
mousedown = false;
}
// 鼠标出现在body
document.body.onmouseout = function (ev) {
if (!params.websocket) return;
Mouse_Option("up", ev.offsetX, ev.offsetY);
mousedown = false;
}
// 对屏幕操作
let Mouse_Option = this.Mouse_Option.bind(this);
}
/**
* 对屏幕操作
* @Author 郭少
* @DateTime 2021-05-29T12:45:31+0800
*/
ts_screen.prototype.Mouse_Option = function (type, x, y, state, cb, text = this.params.ip) {
let params = this.params;
let move_type = 1;
switch (type) {
case "down":// 鼠标按下
params.Mouse_Down = true;
move_type = 1;
break;
case "move":// 鼠标在屏幕移动
if (!params.Mouse_Down) {
move_type = 0;
} else
move_type = 2;
break;
case "up":
move_type = 3;
params.Mouse_Down = false;
break;
default:
move_type = type;
break;
}
let that = this;
if (move_type > 0) {
if (!params.sourceSize) return;
// 通过获取到的原始屏幕大小计算出当前鼠标所在屏幕位置
let w_b = x / params.nowSize.width;
let h_b = y / params.nowSize.height;
let nowX = params.sourceSize.width * (w_b);
let nowY = params.sourceSize.height * (h_b);
if (typeof (type) == "number") {
params.iframe.src = "http://" + params.ip + ":" + params.port + "/event?type=" + move_type + "&state=" + (state || 0) +
"&text=" + encodeURIComponent(text || "");
} else {
// 往设备发送拖动屏幕请求
params.doSend({ input: { type: move_type, x: nowX, y: nowY, state: state || 0, text: text || "" } });
if (cb) {
setTimeout(function () {
cb();
}, 100);
}
}
}
}
/**
* 获取屏幕
* @Author 郭少
* @DateTime 2021-05-29T12:31:32+0800
*/
ts_screen.prototype.request_img = function () {
let params = this.params;
imgScreen.src = "http://" + params.ip + ":" + params.port + "/snapshot1?ext=jpg&orient=0&compress=0.00001&scale=1&t-" + new Date().getTime();
if (params.screen_render === "uninterrupted") {
let that = this;
setTimeout(function () {
that.request_img();
}, 1000 / params.frame_number);
}
}
ts_screen.prototype.heartCheck = function (ws) {
return {
timeout: 1000 * 10,// 60ms
timeoutObj: null,
serverTimeoutObj: null,
reset: function () {
clearTimeout(this.timeoutObj);
clearTimeout(this.serverTimeoutObj);
this.start();
},
start: function () {
let self = this;
this.timeoutObj = setTimeout(function () {
self.serverTimeoutObj = setTimeout(function () {
ws.close();// 如果onclose会执行reconnect,我们执行ws.close()就行了.如果直接执行reconnect 会触发onclose导致重连两次
}, self.timeout)
}, this.timeout)
},
}
}
/**
* websocket连接
* @Author 郭少
* @DateTime 2021-05-29T12:33:11+0800
*/
ts_screen.prototype.websocket_connection = function () {
let params = this.params;
let wsUri = this.wsUrl;
let that = this;
let WebSocket_start = function () {
let websocket = new WebSocket(wsUri);
let heartCheck = that.heartCheck(websocket);
websocket.onopen = function (evt) {
onOpen(evt)
};
websocket.onclose = function (evt) {
onClose(evt)
};
websocket.onmessage = function (evt) {
onMessage(evt)
};
websocket.onerror = function (evt) {
onError(evt)
};
params.websocket = websocket;
}
let onOpen = function (evt) {
writeToScreen("CONNECTED");
}
let onClose = function (evt) {
writeToScreen("DISCONNECTED");
setTimeout(function () {
WebSocket_start();
}, 3000);
}
let onMessage = function (evt) {
writeToScreen('<span style="color: blue;">RESPONSE: ' + evt.data + '</span>');
websocket.close();
}
let onError = function (evt) {
writeToScreen('<span style="color: red;">ERROR:</span> ' + evt.data);
}
params.doSend = function (message) {
message = JSON.stringify(message);
writeToScreen("SENT: " + message);
if (params.websocket.readyState === 1) {
params.websocket.send(message);
}
}
let writeToScreen = function (message) {
// console.log(message)
}
WebSocket_start();
}
let load = function () {
new ts_screen();
}
</script>
</head>
<body class="valign" onload="load()">
<div class="main">
<img draggable="false" id="imgScreen" class="screen">
<iframe id="iframe" src=""></iframe>
<div class="btnOption valign">
<button id="btnHome" class="valign">
<span>
<i class="icon"></i>
<label>首页</label>
</span>
</button>
<button id="btnMultitask" class="valign">
<span>
<i class="icon"></i>
<label>多任务</label>
</span>
</button>
<button id="btnInput" class="valign">
<span>
<i class="icon"></i>
<label>输入</label>
</span>
</button>
</div>
<div class="input_content">
<div class="input_content_header">
<label>发送文字</label>
</div>
<div class="input_content_body">
<label class="tips">
<span class="tips_icon"></span>
<span>请保证设备页面处于可输入状态</span>
</label>
<input type="text" class="textarea">
</div>
<div class="input_content_footer valign">
<button id="btnSend">发送</button>
<button id="close_input">取消</button>
</div>
</div>
</div>
</body>
</html>
后端部分:
实现:建立一个webSocket文件夹,然后下面新建一个index.js和一个modules文件夹,index.js用来启动服务,modules文件夹里面分别放2个端的对应文件【设备客户端phoneClient.js、网站/小程序服务端webServer.js】、初始化服务文件socketInit.js、公用方法文件common.js、配置全局变量文件globalData.js,然后设备服务端是一个公网ip的服务【端口51008】,并不是本地服务实现的。
1、phoneClient.js,通过循环创建了n个设备的客户端对象。然后分别去连接服务器(wx:ip地址:51008)。并将这些对象保存在全局变量connectionObj里面,通过ip去标识key值。
2、webServer.js,在text方法里面,接受消息里面的设备ip和文本内容,然后调用封装好的doSend方法,传递connectionObj[ip]和文本内容,触发设备客户端的send方法,发送给设备服务端去打开云手机应用、滑动屏幕等。
3、socketInit.js,引入phoneClient.js和webServer.js,先获取所有的设备,然后做初始化操作【比如:设置全局变量、检测断开重连等(根据测试发现,如果设备一直在操作的话,基本不会出现断开的情况。很长时间不操作会断开,所以检测某台设备如果10分钟都没点击,就进行重连操作)】
4、在node项目入口引入webSocket文件夹。然后cnpm i websocket => cnpm i nodejs-websocket => node index。
下面是代码的实现:
index.js文件代码如下:
const socInit = require("./modules/socketInit")
// 启动webSocket服务
socInit.webSocketStart()
/modules/phoneClient.js文件代码如下:
const global = require("./globalData")
const { client: WebSocketClient }= require('websocket')
// 遍历设备列表,构建手机的客户端
async function phone_Init(ip) {
const ipList = global.get("ipList")
const connecObj = global.get("connecObj")
if (ipList.length == 0) return
try {
for (let i = 0; i < ipList.length; i++) {
// 如果有设备ip传过来,就重新连接指定设备,否则就是所有设备连接
if (ip ? ipList[i] == ip : true) {
const client = new WebSocketClient(); // 创建客户端对象
// 连接失败执行
client.on('connectFailed',
function (error) {
console.log('Connect Error: ' + error.toString());
}
);
client.on('connect', function (connection) {
global.setProp("connecObj", ipList[i], connection) // 存放连接对象
// connecObj[ipList[i]] = connection
console.log('正在启动:' + ipList[i]);
// 连接错误抛出
connection.on('error', function (error) {
console.log("Connection Error: " + error.toString());
});
// 连接关闭执行
connection.on('close', function () {
console.log('echo-protocol Connection Closed');
});
// 收到服务器的返回消息
connection.on('message', function (message) {
if (message.type === 'utf8') {
console.log("Received: '" + message.utf8Data + "'");
}
});
});
client.connect(`ws://${ipList[i]}:51008/`); // 连接服务器
}
}
} catch (e) {
console.log("发生异常:" + e)
}
}
module.exports = {
phone_Init
}
/modules/webServer.js文件代码如下:
const ws = require('nodejs-websocket')
const global = require("./globalData")
const phoneClient = require("./phoneClient")
const { doSend, isJSON } = require("./common")
async function createWebServer(port = 8866) {
ws.createServer(function (conn) {
conn.on('text', function (text) {
const timeObj = global.get("timeObj")
const connecObj = global.get("connecObj")
// 重连设备
if (text.indexOf("rebinding") != -1) {
let ipText = text.split(":")[1]
let relloadIp = ipText
.substring(0, ipText.length - 1)
console.log("正在重连设备:" + relloadIp)
phoneClient.phone_Init(relloadIp) // 重连指定设备
} else {
console.log(text)
if (isJSON(text)) {
let data = JSON.parse(text)
if (data.hasOwnProperty("input")) {
let ip = data["input"]["text"]
console.log('发送的手机ip:' + ip);
console.log('收到的信息为:' + text);
if (timeObj[ip]) {
if ("isclick" in timeObj[ip]) {
global.setProp("timeObj", "ip", true, "isclick")
}
}
doSend(connecObj[ip], text)
}
} else {
console.log("这里接受到的消息:", text)
}
}
});
conn.on('message', function (code, reason) {
console.log('传递数据中');
});
conn.on('close', function (code, reason) {
console.log('关闭连接');
});
conn.on('error', function (code, reason) {
console.log('异常关闭');
});
}).listen(port);
}
console.log("服务器启动成功")
module.exports = {
createWebServer
}
/modules/socketInit.js文件代码如下:
在global.set("ipList"...这个地方,设置你自己的手机ip
const http = require('http');
const global = require("./globalData")
const socketCom = require("./common")
const phoneClient = require("./phoneClient")
const webServer = require("./webServer")
const phone_Init = phoneClient.phone_Init
//const PhoneallModel = require('../../../models/PhoneallModel')
async function webSocketStart() {
//const data = await PhoneallModel.findAllPhoneallList({
// pageIndex: 1,
// pageSize: 500
//});
//const phoneList = []
//data.rows.forEach(element => {
// const phoneIp =
// element.dataValues.phone_ip
// phoneList.push(phoneIp)
//});
// 设置全局变量
global.set("timeObj", {})
global.set("connecObj", {})
global.set("ipList", ["192.160.0.24"]) // 这里可以更改为phoneList
// 设备初始化操作
await socketCom.delay(1000)
await phoneClient.phone_Init()
await webServer.createWebServer(8866)
const ipList = global.get("ipList")
const timeObj = global.get("timeObj")
// 时间对象初始化
socketCom.timeObj_DataInit(ipList, timeObj)
// 检测设备连接状态
socketCom.timeObj_Examine(ipList, timeObj, phone_Init)
}
module.exports = {
webSocketStart
}
/modules/common.js文件代码如下:
const global = require("./globalData")
/***
* 时间方法
*/
// 日期格式转换
Date.prototype.format = function (fmt) {
var o = {
"M+": this.getMonth() + 1, //月份
"d+": this.getDate(), //日
"h+": this.getHours(), //小时
"m+": this.getMinutes(), //分
"s+": this.getSeconds(), //秒
"q+": Math.floor((this.getMonth() + 3) / 3), //季度
"S": this.getMilliseconds() //毫秒
};
if (/(y+)/.test(fmt)) {
fmt = fmt.replace(RegExp.$1, (this.getFullYear() + "").substr(4 - RegExp.$1.length));
}
for (var k in o) {
if (new RegExp("(" + k + ")").test(fmt)) {
fmt = fmt.replace(RegExp.$1, (RegExp.$1.length == 1) ? (o[k]) : (("00" + o[k]).substr(("" + o[k])
.length)));
}
}
return fmt;
}
// 延迟时钟
async function delay(times) {
return new Promise((reslove, reject) => {
setTimeout(() => {
reslove()
}, times)
})
}
// 计算时间差
function times(start_time, end_time, type) {
start_time = new Date(start_time)
end_time = new Date(end_time)
switch (type) {
case "秒":
return parseInt(end_time - start_time) / 1000;
case "分":
return parseInt(end_time - start_time) / 60000;
case "时":
return parseInt(end_time - start_time) / 3600000;
}
}
/***
* 功能方法
*/
// 发送消息到网页客户端
function doSend(connection, text) {
if (connection) {
console.log(connection.connected ? "连接成功!" : "连接失败!")
if (connection.connected) {
connection.send(text); //发送数据
}
}
}
// 判断是否为Json数据
function isJSON(str) {
if (typeof str == 'string') {
try {
var obj = JSON.parse(str);
if (typeof obj == 'object' && obj) {
return true;
} else {
return false;
}
} catch (e) {
console.log('error:' + str + '!!!' + e);
return false;
}
}
console.log('It is not a string!')
}
/***
* 状态设置方法
*/
// 时间对象初始化
function timeObj_DataInit(ipList, timeObj) {
for (let i = 0; i < ipList.length; i++) {
const startTime = new Date().format("yyyy/MM/dd hh:mm:ss")
global.setProp("timeObj", ipList[i], {})
global.setProp("timeObj", ipList[i], false, "isclick")
global.setProp("timeObj", ipList[i], startTime, "start_time")
console.log("设备ip:" + ipList[i])
// console.log("开始时间:" + timeObj[ipList[i]].start_time)
}
}
// 每10分钟检测一次连接状态
function timeObj_Examine(ipList, timeObj, phone_Init) {
setInterval(() => {
const startTime = new Date().format("yyyy/MM/dd hh:mm:ss")
for (let i = 0; i < ipList.length; i++) {
let ele_time = new Date().format("yyyy/MM/dd hh:mm:ss");
// 每10分钟检测一次
if(timeObj[ipList[i]]) {
if (times(timeObj[ipList[i]].start_time, ele_time, "分") >= 10) {
// 如果还未点击过
if (!timeObj[ipList[i]].isclick) {
phone_Init(ipList[i])
console.log(ipList[i] + '正在重连......')
global.setProp("timeObj", ipList[i], false, "isclick")
global.setProp("timeObj", ipList[i], startTime, "start_time")
}
}
}
}
}, 5000);
}
module.exports = {
times,
delay,
doSend,
isJSON,
timeObj_DataInit,
timeObj_Examine,
}
/modules/globalData.js文件代码如下:
const GLOABL_DATA = {}
function get(key) {
return GLOABL_DATA[key]
}
function set(key, value) {
GLOABL_DATA[key] = value
}
function setProp(key, prop, value, childProp) {
try {
if (!childProp) {
GLOABL_DATA[key][prop] = value
} else {
GLOABL_DATA[key][prop][childProp] = value
}
} catch (error) { }
}
module.exports = {
get,
set,
setProp
}
五、后续补充
现在展示的只是最初始的网页版测试代码,小程序版本的代码太多这里就不展示了。实际后续上线至少需要实现获取屏幕的接口,koa代码如下:
/**
* 获取设备屏幕
* @param ctx
* @returns 设备屏幕数据
*/
static async screen(ctx) {
let { ip, type = '', text = '' } = ctx.query
async function getImg(url) {
return new Promise((reslove, reject) => {
http.get(url, function (res) {
reslove(res)
})
})
}
try {
let imgUrl = ""
// type: 11:发送文字 20:回到首页 21:切换进程
if (type) {
imgUrl = "http://" + ip + ":50005/event?type=" + type + "&state=0&text=" + text;
} else {
imgUrl = "http://" + ip + ":50005/snapshot1?ext=jpg&orient=0&compress=0.00001&scale=1&t-" + new Date().getTime();
}
let data = await getImg(imgUrl)
ctx.body = data
} catch (err) {
ctx.response.status = 500;
ctx.body = {
code: 500,
message: err
}
}
}