migration from v1

This commit is contained in:
AkiChase 2024-04-13 09:53:41 +08:00
parent a896a51073
commit 5327e0f4fd
41 changed files with 4762 additions and 183 deletions

BIN
public/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,6 +0,0 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -15,4 +15,5 @@ tauri = { version = "2.0.0-beta", features = ["macos-private-api"] }
tauri-plugin-shell = "2.0.0-beta"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1.0"
tokio = { version = "1.36.0", features = ["rt-multi-thread", "net", "macros", "io-util", "time", "sync"] }

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src-tauri/resource/adb Executable file

Binary file not shown.

BIN
src-tauri/resource/adb.exe Normal file

Binary file not shown.

Binary file not shown.

162
src-tauri/src/adb.rs Normal file
View File

@ -0,0 +1,162 @@
use crate::resource::{ResHelper, ResourceName};
use std::{
io::BufRead,
path::PathBuf,
process::{Child, Command, Stdio},
};
use anyhow::{Context, Ok, Result};
#[derive(Clone, Debug, serde::Serialize)]
pub struct Device {
pub id: String,
pub status: String,
}
impl Device {
/// execute "adb push" to push file from src to des
pub fn cmd_push(res_dir: &PathBuf, id: &str, src: &str, des: &str) -> Result<String> {
let mut adb_command = Adb::cmd_base(res_dir);
let res = adb_command
.args(&["-s", id, "push", src, des])
.output()
.with_context(|| format!("Failed to execute 'adb push {} {}'", src, des))?;
Ok(String::from_utf8(res.stdout).unwrap())
}
/// execute "adb reverse" to reverse the device port to local port
pub fn cmd_reverse(res_dir: &PathBuf, id: &str, remote: &str, local: &str) -> Result<()> {
let mut adb_command = Adb::cmd_base(res_dir);
adb_command
.args(&["-s", id, "reverse", remote, local])
.output()
.with_context(|| format!("Failed to execute 'adb reverse {} {}'", remote, local))?;
Ok(())
}
/// execute "adb forward" to forward the local port to the device
pub fn cmd_forward(res_dir: &PathBuf, id: &str, local: &str, remote: &str) -> Result<()> {
let mut adb_command = Adb::cmd_base(res_dir);
adb_command
.args(&["-s", id, "forward", local, remote])
.output()
.with_context(|| format!("Failed to execute 'adb forward {} {}'", local, remote))?;
Ok(())
}
/// execute "adb shell" to execute shell command on the device
pub fn cmd_shell(res_dir: &PathBuf, id: &str, shell_args: &[&str]) -> Result<Child> {
let mut adb_command = Adb::cmd_base(res_dir);
let mut args = vec!["-s", id, "shell"];
args.extend_from_slice(shell_args);
Ok(adb_command
.args(args)
.stdout(Stdio::piped())
.spawn()
.context("Failed to execute 'adb shell'")?)
}
pub fn cmd_screen_size(res_dir: &PathBuf, id: &str) -> Result<(u16, u16)> {
let mut adb_command = Adb::cmd_base(res_dir);
let output = adb_command
.args(&["-s", id, "shell", "wm", "size"])
.output()
.context("Failed to execute 'adb shell wm size'")?;
let lines = output.stdout.lines();
let mut size = (0, 0);
for line in lines {
if let std::result::Result::Ok(s) = line {
println!("{}", s);
if s.starts_with("Physical size:") {
let mut iter = s.split_whitespace();
iter.next();
iter.next();
let mut size_str = iter.next().unwrap().split('x');
let width = size_str.next().unwrap().parse::<u16>().unwrap();
let height = size_str.next().unwrap().parse::<u16>().unwrap();
size = (width, height);
break;
}
}
}
Ok(size)
}
}
pub struct Adb;
/// Module to execute adb command and fetch output.
/// But some output of command won't be output, like adb service startup information.
impl Adb {
fn cmd_base(res_dir: &PathBuf) -> Command {
Command::new(ResHelper::get_file_path(res_dir, ResourceName::Adb))
}
/// execute "adb devices" and return devices list
pub fn cmd_devices(res_dir: &PathBuf) -> Result<Vec<Device>> {
let mut adb_command = Adb::cmd_base(res_dir);
let output = adb_command
.args(&["devices"])
.output()
.context("Failed to execute 'adb devices'")?;
let mut devices_vec: Vec<Device> = Vec::new();
let mut lines = output.stdout.lines();
// skip first line
lines.next();
// parse string to Device
for line in lines {
if let std::result::Result::Ok(s) = line {
let device_info: Vec<&str> = s.split('\t').collect();
if device_info.len() == 2 {
devices_vec.push(Device {
id: device_info[0].to_string(),
status: device_info[1].to_string(),
});
}
}
}
Ok(devices_vec)
}
/// execute "adb kill-server"
pub fn cmd_kill_server(res_dir: &PathBuf) -> Result<()> {
let mut adb_command = Adb::cmd_base(res_dir);
adb_command
.args(&["kill-server"])
.output()
.context("Failed to execute 'adb kill-server'")?;
Ok(())
}
/// execute "adb reverse --remove-all"
pub fn cmd_reverse_remove(res_dir: &PathBuf) -> Result<()> {
let mut adb_command = Adb::cmd_base(res_dir);
adb_command
.args(&["reverse", " --remove-all"])
.output()
.context("Failed to execute 'adb reverse --remove-all'")?;
Ok(())
}
/// execute "adb forward --remove-all"
pub fn cmd_forward_remove(res_dir: &PathBuf) -> Result<()> {
let mut adb_command = Adb::cmd_base(res_dir);
adb_command
.args(&["forward", " --remove-all"])
.output()
.context("Failed to execute 'adb forward --remove-all'")?;
Ok(())
}
/// execute "adb start-server"
pub fn cmd_start_server(res_dir: &PathBuf) -> Result<()> {
let mut adb_command = Adb::cmd_base(res_dir);
adb_command
.args(&["start-server"])
.output()
.context("Failed to execute 'adb start-server'")?;
Ok(())
}
}

73
src-tauri/src/binary.rs Normal file
View File

@ -0,0 +1,73 @@
pub fn write_16be(buf: &mut [u8], val: u16) {
buf[0] = (val >> 8) as u8;
buf[1] = val as u8;
}
pub fn write_32be(buf: &mut [u8], val: u32) {
buf[0] = (val >> 24) as u8;
buf[1] = (val >> 16) as u8;
buf[2] = (val >> 8) as u8;
buf[3] = val as u8;
}
pub fn write_64be(buf: &mut [u8], val: u64) {
buf[0] = (val >> 56) as u8;
buf[1] = (val >> 48) as u8;
buf[2] = (val >> 40) as u8;
buf[3] = (val >> 32) as u8;
buf[4] = (val >> 24) as u8;
buf[5] = (val >> 16) as u8;
buf[6] = (val >> 8) as u8;
buf[7] = val as u8;
}
pub fn float_to_u16fp(mut f: f32) -> u16 {
if f < 0.0 || f > 1.0 {
f = 1.0;
}
let mut u: u32 = (f * (1 << 16) as f32) as u32;
if u >= 0xffff {
u = 0xffff;
}
u as u16
}
pub fn float_to_i16fp(f: f32) -> i16 {
assert!(f >= -1.0 && f <= 1.0);
let mut i: i32 = (f * (1 << 15) as f32) as i32;
assert!(i >= -0x8000);
if i >= 0x7fff {
assert_eq!(i, 0x8000); // for f == 1.0
i = 0x7fff;
}
i as i16
}
pub fn write_posion(buf: &mut [u8], x: i32, y: i32, w: u16, h: u16) {
write_32be(buf, x as u32);
write_32be(&mut buf[4..8], y as u32);
write_16be(&mut buf[8..10], w);
write_16be(&mut buf[10..12], h);
}
pub fn write_string(utf8: &str, max_len: usize, buf: &mut Vec<u8>) {
let len = str_utf8_truncation_index(utf8, max_len) as u32;
// first 4 bytes for length
let len_bytes = len.to_be_bytes();
buf.extend_from_slice(&len_bytes);
// then [len] bytes for the string
buf.extend_from_slice(utf8.as_bytes())
}
// truncate utf8 string to max_len bytes
fn str_utf8_truncation_index(utf8: &str, max_len: usize) -> usize {
let len = utf8.len();
if len <= max_len {
return len;
}
let mut len = max_len;
while utf8.is_char_boundary(len) {
len -= 1;
}
len
}

125
src-tauri/src/client.rs Normal file
View File

@ -0,0 +1,125 @@
use anyhow::{Ok, Result};
use std::{io::BufRead, path::PathBuf};
use crate::{
adb::{Adb, Device},
resource::{ResHelper, ResourceName},
};
/**
* the client of scrcpy
*/
#[derive(Debug)]
pub struct ScrcpyClient {
pub device: Device,
pub version: String,
pub scid: String,
pub port: u16,
}
impl ScrcpyClient {
pub fn get_scrcpy_version() -> String {
ResHelper::get_scrcpy_version()
}
pub fn adb_devices(res_dir: &PathBuf) -> Result<Vec<Device>> {
Adb::cmd_devices(res_dir)
}
pub fn adb_restart_server(res_dir: &PathBuf) -> Result<()> {
Adb::cmd_kill_server(res_dir)?;
Adb::cmd_start_server(res_dir)?;
Ok(())
}
pub fn adb_reverse_remove(res_dir: &PathBuf) -> Result<()> {
Adb::cmd_reverse_remove(res_dir)
}
pub fn adb_forward_remove(res_dir: &PathBuf) -> Result<()> {
Adb::cmd_forward_remove(res_dir)
}
/// get the screen size of the device
pub fn get_screen_size(res_dir: &PathBuf, id: &str) -> Result<(u16, u16)> {
Device::cmd_screen_size(res_dir, id)
}
/// push server file to current device
pub fn push_server_file(res_dir: &PathBuf, id: &str) -> Result<()> {
let info = Device::cmd_push(
res_dir,
id,
&ResHelper::get_file_path(res_dir, ResourceName::ScrcpyServer).to_string_lossy(),
"/data/local/tmp/scrcpy-server.jar",
)?;
println!("{}\nSuccessfully push server files", info);
Ok(())
}
/// forward the local port to the device
pub fn forward_server_port(res_dir: &PathBuf, id: &str, scid: &str, port: u16) -> Result<()> {
Device::cmd_forward(
res_dir,
id,
&format!("tcp:{}", port),
&format!("localabstract:scrcpy_{}", scid),
)?;
println!("Successfully forward port");
Ok(())
}
/// reverse the device port to the local port
pub fn reverse_server_port(res_dir: &PathBuf, id: &str, scid: &str, port: u16) -> Result<()> {
Device::cmd_reverse(
res_dir,
id,
&format!("localabstract:scrcpy_{}", scid),
&format!("tcp:{}", port),
)?;
println!("Successfully reverse port");
Ok(())
}
/// spawn a new thread to start scrcpy server
pub fn shell_start_server(
res_dir: &PathBuf,
id: &str,
scid: &str,
version: &str,
) -> Result<()> {
let mut child = Device::cmd_shell(
res_dir,
id,
&[
"CLASSPATH=/data/local/tmp/scrcpy-server.jar",
"app_process",
"/",
"com.genymobile.scrcpy.Server",
version,
&format!("scid={}", scid),
"tunnel_forward=true",
"video=false",
"audio=false",
],
)?;
println!("Starting scrcpy server...");
let out = child.stdout.take().unwrap();
let mut out = std::io::BufReader::new(out);
let mut s = String::new();
while let core::result::Result::Ok(_) = out.read_line(&mut s) {
// break at the end of program
if let core::result::Result::Ok(Some(_)) = child.try_wait() {
break;
}
print!("{}", s);
// clear string to store new line only
s.clear();
}
println!("Scrcpy server closed");
Ok(())
}
}

View File

@ -0,0 +1,203 @@
use crate::binary;
use tokio::{io::AsyncWriteExt, net::tcp::OwnedWriteHalf};
pub const SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH: usize = 300;
pub const SC_CONTROL_MSG_MAX_SIZE: usize = 1 << 18; // 256k
pub const SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH: usize = SC_CONTROL_MSG_MAX_SIZE - 14;
pub fn gen_ctrl_msg(ctrl_msg_type: ControlMsgType, payload: &serde_json::Value) -> Vec<u8> {
match ctrl_msg_type {
ControlMsgType::ControlMsgTypeInjectKeycode => gen_inject_key_ctrl_msg(
ctrl_msg_type as u8,
payload["action"].as_u64().unwrap() as u8,
payload["keycode"].as_u64().unwrap() as u32,
payload["repeat"].as_u64().unwrap() as u32,
payload["metastate"].as_u64().unwrap() as u32,
),
ControlMsgType::ControlMsgTypeInjectText => {
let mut buf: Vec<u8> = vec![ctrl_msg_type as u8];
let text = payload["text"].as_str().unwrap();
binary::write_string(text, SC_CONTROL_MSG_INJECT_TEXT_MAX_LENGTH, &mut buf);
buf
}
ControlMsgType::ControlMsgTypeInjectTouchEvent => gen_inject_touch_ctrl_msg(
ctrl_msg_type as u8,
payload["action"].as_u64().unwrap() as u8,
payload["pointerId"].as_u64().unwrap(),
payload["position"]["x"].as_i64().unwrap() as i32,
payload["position"]["y"].as_i64().unwrap() as i32,
payload["position"]["w"].as_i64().unwrap() as u16,
payload["position"]["h"].as_i64().unwrap() as u16,
binary::float_to_u16fp(payload["pressure"].as_f64().unwrap() as f32),
payload["actionButton"].as_u64().unwrap() as u32,
payload["buttons"].as_u64().unwrap() as u32,
),
ControlMsgType::ControlMsgTypeInjectScrollEvent => {
let mut buf = vec![0; 21];
buf[0] = ctrl_msg_type as u8;
binary::write_posion(
&mut buf[1..13],
payload["position"]["x"].as_i64().unwrap() as i32,
payload["position"]["y"].as_i64().unwrap() as i32,
payload["position"]["w"].as_i64().unwrap() as u16,
payload["position"]["h"].as_i64().unwrap() as u16,
);
binary::write_16be(
&mut buf[13..15],
binary::float_to_i16fp(payload["hscroll"].as_f64().unwrap() as f32) as u16,
);
binary::write_16be(
&mut buf[15..17],
binary::float_to_i16fp(payload["vscroll"].as_f64().unwrap() as f32) as u16,
);
binary::write_32be(
&mut buf[17..21],
payload["buttons"].as_u64().unwrap() as u32,
);
buf
}
ControlMsgType::ControlMsgTypeBackOrScreenOn => {
vec![
ctrl_msg_type as u8,
payload["action"].as_u64().unwrap() as u8,
]
}
ControlMsgType::ControlMsgTypeGetClipboard => {
vec![
ctrl_msg_type as u8,
payload["copyKey"].as_u64().unwrap() as u8,
]
}
ControlMsgType::ControlMsgTypeSetClipboard => {
let mut buf: Vec<u8> = vec![0; 10];
buf[0] = ctrl_msg_type as u8;
binary::write_64be(&mut buf[1..9], payload["sequence"].as_u64().unwrap());
buf[9] = payload["paste"].as_bool().unwrap_or(false) as u8;
let text = payload["text"].as_str().unwrap();
binary::write_string(text, SC_CONTROL_MSG_CLIPBOARD_TEXT_MAX_LENGTH, &mut buf);
buf
}
ControlMsgType::ControlMsgTypeSetScreenPowerMode => {
vec![ctrl_msg_type as u8, payload["mode"].as_u64().unwrap() as u8]
}
ControlMsgType::ControlMsgTypeUhidCreate => {
let size = payload["reportDescSize"].as_u64().unwrap() as u16;
let mut buf: Vec<u8> = vec![0; 5];
buf[0] = ctrl_msg_type as u8;
binary::write_16be(&mut buf[1..3], payload["id"].as_u64().unwrap() as u16);
binary::write_16be(&mut buf[3..5], size);
let report_desc = payload["reportDesc"].as_array().unwrap();
let report_desc_u8: Vec<u8> = report_desc
.iter()
.map(|x| x.as_u64().unwrap() as u8)
.collect();
buf.extend_from_slice(&report_desc_u8);
buf
}
ControlMsgType::ControlMsgTypeUhidInput => {
let size = payload["size"].as_u64().unwrap() as u16;
let mut buf: Vec<u8> = vec![0; 5];
buf[0] = ctrl_msg_type as u8;
binary::write_16be(&mut buf[1..3], payload["id"].as_u64().unwrap() as u16);
binary::write_16be(&mut buf[3..5], size);
let data = payload["data"].as_array().unwrap();
let data_u8: Vec<u8> = data.iter().map(|x| x.as_u64().unwrap() as u8).collect();
buf.extend_from_slice(&data_u8);
buf
}
// other control message types do not have a payload
_ => {
vec![ctrl_msg_type as u8]
}
}
}
pub fn gen_inject_key_ctrl_msg(
ctrl_msg_type: u8,
action: u8,
keycode: u32,
repeat: u32,
metastate: u32,
) -> Vec<u8> {
let mut buf = vec![0; 14];
buf[0] = ctrl_msg_type;
buf[1] = action;
binary::write_32be(&mut buf[2..6], keycode);
binary::write_32be(&mut buf[6..10], repeat);
binary::write_32be(&mut buf[10..14], metastate);
buf
}
pub fn gen_inject_touch_ctrl_msg(
ctrl_msg_type: u8,
action: u8,
pointer_id: u64,
x: i32,
y: i32,
w: u16,
h: u16,
pressure: u16,
action_button: u32,
buttons: u32,
) -> Vec<u8> {
let mut buf = vec![0; 32];
buf[0] = ctrl_msg_type;
buf[1] = action;
binary::write_64be(&mut buf[2..10], pointer_id);
binary::write_posion(&mut buf[10..22], x, y, w, h);
binary::write_16be(&mut buf[22..24], pressure);
binary::write_32be(&mut buf[24..28], action_button);
binary::write_32be(&mut buf[28..32], buttons);
buf
}
pub async fn send_ctrl_msg(
ctrl_msg_type: ControlMsgType,
payload: &serde_json::Value,
writer: &mut OwnedWriteHalf,
) {
let buf = gen_ctrl_msg(ctrl_msg_type, payload);
writer.write_all(&buf).await.unwrap();
writer.flush().await.unwrap();
}
pub enum ControlMsgType {
ControlMsgTypeInjectKeycode, //发送原始按键
ControlMsgTypeInjectText, //发送文本不知道是否能输入中文估计只是把文本转为keycode的输入效果
ControlMsgTypeInjectTouchEvent, //发送触摸事件
ControlMsgTypeInjectScrollEvent, //发送滚动事件(类似接入鼠标后滚动滚轮的效果,不是通过触摸实现的)
ControlMsgTypeBackOrScreenOn, //应该就是发送返回键
ControlMsgTypeExpandNotificationPanel, //打开消息面板
ControlMsgTypeExpandSettingsPanel, //打开设置面板(就是消息面板右侧的)
ControlMsgTypeCollapsePanels, //折叠上述面板
ControlMsgTypeGetClipboard, //获取剪切板
ControlMsgTypeSetClipboard, //设置剪切板
ControlMsgTypeSetScreenPowerMode, //设置屏幕电源模式是关闭设备屏幕的SC_SCREEN_POWER_MODE_OFF 和 SC_SCREEN_POWER_MODE_NORMAL
ControlMsgTypeRotateDevice, //旋转设备屏幕
ControlMsgTypeUhidCreate, //创建虚拟设备?从而模拟真实的键盘、鼠标用的,目前没用
ControlMsgTypeUhidInput, //同上转发键盘、鼠标的输入,目前没用
ControlMsgTypeOpenHardKeyboardSettings, //打开设备的硬件键盘设置,目前没用
}
impl ControlMsgType {
pub fn from_i64(value: i64) -> Option<Self> {
match value {
0 => Some(Self::ControlMsgTypeInjectKeycode),
1 => Some(Self::ControlMsgTypeInjectText),
2 => Some(Self::ControlMsgTypeInjectTouchEvent),
3 => Some(Self::ControlMsgTypeInjectScrollEvent),
4 => Some(Self::ControlMsgTypeBackOrScreenOn),
5 => Some(Self::ControlMsgTypeExpandNotificationPanel),
6 => Some(Self::ControlMsgTypeExpandSettingsPanel),
7 => Some(Self::ControlMsgTypeCollapsePanels),
8 => Some(Self::ControlMsgTypeGetClipboard),
9 => Some(Self::ControlMsgTypeSetClipboard),
10 => Some(Self::ControlMsgTypeSetScreenPowerMode),
11 => Some(Self::ControlMsgTypeRotateDevice),
12 => Some(Self::ControlMsgTypeUhidCreate),
13 => Some(Self::ControlMsgTypeUhidInput),
14 => Some(Self::ControlMsgTypeOpenHardKeyboardSettings),
_ => None,
}
}
}

7
src-tauri/src/lib.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod adb;
pub mod resource;
pub mod client;
pub mod socket;
pub mod binary;
pub mod control_msg;
pub mod scrcpy_mask_cmd;

View File

@ -1,16 +1,145 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
use scrcpy_mask::{
adb::{Adb, Device},
client::ScrcpyClient,
resource::ResHelper,
socket::connect_socket,
};
use std::sync::Arc;
use tauri::Manager;
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
/// get devices info list
fn adb_devices(app: tauri::AppHandle) -> Result<Vec<Device>, String> {
let dir = app.path().resource_dir().unwrap().join("resource");
match Adb::cmd_devices(&dir) {
Ok(devices) => Ok(devices),
Err(e) => Err(e.to_string()),
}
}
fn main() {
#[tauri::command]
/// get screen size of the device
fn get_screen_size(id: String, app: tauri::AppHandle) -> Result<(u16, u16), String> {
let dir = app.path().resource_dir().unwrap().join("resource");
match ScrcpyClient::get_screen_size(&dir, &id) {
Ok(size) => Ok(size),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
/// forward local port to the device port
fn forward_server_port(
app: tauri::AppHandle,
id: String,
scid: String,
port: u16,
) -> Result<(), String> {
let dir = app.path().resource_dir().unwrap().join("resource");
match ScrcpyClient::forward_server_port(&dir, &id, &scid, port) {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
/// push scrcpy-server file to the device
fn push_server_file(id: String, app: tauri::AppHandle) -> Result<(), String> {
let dir = app.path().resource_dir().unwrap().join("resource");
match ScrcpyClient::push_server_file(&dir, &id) {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
#[tauri::command]
/// start scrcpy server and connect to it
fn start_scrcpy_server(
id: String,
scid: String,
address: String,
app: tauri::AppHandle,
) -> Result<(), String> {
let dir = app.path().resource_dir().unwrap().join("resource");
let version = ScrcpyClient::get_scrcpy_version();
// start scrcpy server
tokio::spawn(async move {
ScrcpyClient::shell_start_server(&dir, &id, &scid, &version).unwrap();
});
// connect to scrcpy server
tokio::spawn(async move {
// wait 1 second for scrcpy-server to start
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
let app = Arc::new(app);
// create channel to transmit device reply to front
let share_app = app.clone();
let (device_reply_sender, mut device_reply_receiver) =
tokio::sync::mpsc::channel::<String>(16);
println!("device reply channel created");
tokio::spawn(async move {
while let Some(reply) = device_reply_receiver.recv().await {
share_app.emit("device-reply", reply).unwrap();
}
println!("device reply channel closed");
});
// create channel to transmit front msg to TcpStream handler
let (front_msg_sender, front_msg_receiver) = tokio::sync::mpsc::channel::<String>(16);
let share_app = app.clone();
let listen_handler = share_app.listen("front-command", move |event| {
let sender = front_msg_sender.clone();
println!("收到front-command: {}", event.payload());
tokio::spawn(async move {
if let Err(e) = sender.send(event.payload().into()).await {
println!("front-command转发失败: {}", e);
};
});
});
// connect
let share_app = app.clone();
tokio::spawn(connect_socket(
address,
front_msg_receiver,
device_reply_sender,
listen_handler,
share_app,
));
});
Ok(())
}
#[tokio::main]
async fn main() {
tauri::Builder::default()
.setup(|app| {
// check resource files
ResHelper::res_init(
&app.path()
.resource_dir()
.expect("failed to find resource")
.join("resource"),
)
.unwrap();
Ok(())
})
.plugin(tauri_plugin_shell::init())
.invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri::generate_handler![
adb_devices,
get_screen_size,
forward_server_port,
push_server_file,
start_scrcpy_server
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

41
src-tauri/src/resource.rs Normal file
View File

@ -0,0 +1,41 @@
use anyhow::{anyhow, Ok, Result};
use std::path::PathBuf;
pub enum ResourceName {
Adb,
ScrcpyServer,
}
pub struct ResHelper {
pub res_dir: PathBuf,
}
impl ResHelper {
pub fn res_init(res_dir: &PathBuf) -> Result<()> {
for name in [ResourceName::Adb, ResourceName::ScrcpyServer] {
let file_path = ResHelper::get_file_path(res_dir, name);
if !file_path.exists() {
return Err(anyhow!(format!(
"Resource missing! {}",
file_path.to_str().unwrap()
)));
}
}
Ok(())
}
pub fn get_file_path(dir: &PathBuf, file_name: ResourceName) -> PathBuf {
match file_name {
#[cfg(target_os = "windows")]
ResourceName::Adb => dir.join("adb.exe"),
#[cfg(not(target_os = "windows"))]
ResourceName::Adb => dir.join("adb"),
ResourceName::ScrcpyServer => dir.join("scrcpy-server-v2.4"),
}
}
pub fn get_scrcpy_version() -> String {
String::from("2.4")
}
}

View File

@ -0,0 +1,296 @@
use tokio::{io::AsyncWriteExt, net::tcp::OwnedWriteHalf};
use crate::{
binary,
control_msg::{gen_inject_key_ctrl_msg, gen_inject_touch_ctrl_msg, ControlMsgType},
};
pub async fn handle_sm_cmd(
cmd_type: ScrcpyMaskCmdType,
payload: &serde_json::Value,
writer: &mut OwnedWriteHalf,
) {
match cmd_type {
ScrcpyMaskCmdType::SendKey => {
let ctrl_msg_type = ControlMsgType::ControlMsgTypeInjectKeycode as u8;
let keycode = payload["keycode"].as_u64().unwrap() as u32;
let metastate = match payload.get("metastate") {
Some(metastate) => metastate.as_u64().unwrap() as u32,
None => 0, // AMETA_NONE
};
match payload["action"].as_u64().unwrap() {
// default
0 => {
// down
let buf = gen_inject_key_ctrl_msg(
ctrl_msg_type,
0, // AKEY_EVENT_ACTION_DOWN
keycode,
0,
metastate,
);
writer.write_all(&buf).await.unwrap();
writer.flush().await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// up
let buf = gen_inject_key_ctrl_msg(
ctrl_msg_type,
0, // AKEY_EVENT_ACTION_DOWN
keycode,
0,
metastate,
);
writer.write_all(&buf).await.unwrap();
writer.flush().await.unwrap();
}
// down
1 => {
let buf = gen_inject_key_ctrl_msg(
ctrl_msg_type,
1, // AKEY_EVENT_ACTION_UP
keycode,
0,
metastate,
);
writer.write_all(&buf).await.unwrap();
writer.flush().await.unwrap();
}
// up
2 => {
let buf = gen_inject_key_ctrl_msg(
ctrl_msg_type,
1, // AKEY_EVENT_ACTION_UP
keycode,
0,
metastate,
);
writer.write_all(&buf).await.unwrap();
writer.flush().await.unwrap();
}
_ => {}
};
}
ScrcpyMaskCmdType::Touch => {
let ctrl_msg_type = ControlMsgType::ControlMsgTypeInjectTouchEvent as u8;
let pointer_id = payload["pointerId"].as_u64().unwrap();
let w = payload["screen"]["w"].as_u64().unwrap() as u16;
let h = payload["screen"]["h"].as_u64().unwrap() as u16;
let x = payload["pos"]["x"].as_i64().unwrap() as i32;
let y = payload["pos"]["y"].as_i64().unwrap() as i32;
match payload["action"].as_u64().unwrap() {
// default
0 => {
// down
touch(ctrl_msg_type, pointer_id, x, y, w, h, 0, writer).await;
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// up
touch(ctrl_msg_type, pointer_id, x, y, w, h, 1, writer).await;
}
// down
1 => {
touch(ctrl_msg_type, pointer_id, x, y, w, h, 0, writer).await;
}
// up
2 => {
touch(ctrl_msg_type, pointer_id, x, y, w, h, 1, writer).await;
}
// move
3 => {
touch(ctrl_msg_type, pointer_id, x, y, w, h, 2, writer).await;
}
_ => {}
}
}
ScrcpyMaskCmdType::Swipe => {
let ctrl_msg_type = ControlMsgType::ControlMsgTypeInjectTouchEvent as u8;
let pointer_id = payload["pointerId"].as_u64().unwrap();
let w = payload["screen"]["w"].as_u64().unwrap() as u16;
let h = payload["screen"]["h"].as_u64().unwrap() as u16;
let pos_arr = payload["pos"].as_array().unwrap();
let pos_arr: Vec<(i32, i32)> = pos_arr
.iter()
.map(|pos| {
(
pos["x"].as_i64().unwrap() as i32,
pos["y"].as_i64().unwrap() as i32,
)
})
.collect();
let interval_between_pos = payload["intervalBetweenPos"].as_u64().unwrap();
match payload["action"].as_u64().unwrap() {
// default
0 => {
swipe(
ctrl_msg_type,
pointer_id,
w,
h,
pos_arr,
interval_between_pos,
writer,
true,
true,
)
.await;
}
// no up
1 => {
swipe(
ctrl_msg_type,
pointer_id,
w,
h,
pos_arr,
interval_between_pos,
writer,
true,
false,
)
.await;
}
// no down
2 => {
swipe(
ctrl_msg_type,
pointer_id,
w,
h,
pos_arr,
interval_between_pos,
writer,
false,
true,
)
.await;
}
_ => {}
};
}
ScrcpyMaskCmdType::Shutdown => {}
}
}
pub async fn touch(
ctrl_msg_type: u8,
pointer_id: u64,
x: i32,
y: i32,
w: u16,
h: u16,
action: u8, // 0: down, 1: up, 2: move
writer: &mut OwnedWriteHalf,
) {
let pressure = binary::float_to_u16fp(0.8);
let action_button: u32 = 1;
let buttons: u32 = 1;
let buf = gen_inject_touch_ctrl_msg(
ctrl_msg_type,
action,
pointer_id,
x,
y,
w,
h,
pressure,
action_button,
buttons,
);
writer.write_all(&buf).await.unwrap();
writer.flush().await.unwrap();
}
/// Determine the number of segments based on the distance between two points
fn get_divide_num(x1: i32, y1: i32, x2: i32, y2: i32, segment_length: i32) -> i32 {
let dx = (x2 - x1).abs();
let dy = (y2 - y1).abs();
let d = (dx.pow(2) + dy.pow(2)) as f64;
let d = d.sqrt();
let divide_num = (d / segment_length as f64).ceil() as i32;
divide_num
}
pub async fn swipe(
ctrl_msg_type: u8,
pointer_id: u64,
w: u16,
h: u16,
pos_arr: Vec<(i32, i32)>,
interval_between_pos: u64,
writer: &mut OwnedWriteHalf,
down_flag: bool,
up_flag: bool,
) {
// down
if down_flag {
touch(
ctrl_msg_type,
pointer_id,
pos_arr[0].0,
pos_arr[0].1,
w,
h,
0,
writer,
)
.await;
}
// move
let mut cur_index = 1;
while cur_index < pos_arr.len() {
let (x, y) = pos_arr[cur_index];
let (prev_x, prev_y) = pos_arr[cur_index - 1];
// divide it into several segments
let segment_length = 100;
let divide_num = get_divide_num(prev_x, prev_y, x, y, segment_length);
let dx = (x - prev_x) / divide_num;
let dy = (y - prev_y) / divide_num;
let d_interval = interval_between_pos / (divide_num as u64);
for i in 1..divide_num + 1 {
let nx = prev_x + dx * i;
let ny = prev_y + dy * i;
touch(ctrl_msg_type, pointer_id, nx, ny, w, h, 2, writer).await;
if d_interval > 0 {
tokio::time::sleep(tokio::time::Duration::from_millis(d_interval)).await;
}
}
cur_index += 1;
}
// up
if up_flag {
touch(
ctrl_msg_type,
pointer_id,
pos_arr[pos_arr.len() - 1].0,
pos_arr[pos_arr.len() - 1].1,
w,
h,
1,
writer,
)
.await;
}
}
#[derive(Debug)]
pub enum ScrcpyMaskCmdType {
SendKey,
Touch,
Swipe,
Shutdown,
}
impl ScrcpyMaskCmdType {
pub fn from_i64(value: i64) -> Option<Self> {
match value {
15 => Some(Self::SendKey),
16 => Some(Self::Touch),
17 => Some(Self::Swipe),
18 => Some(Self::Shutdown),
_ => None,
}
}
}

225
src-tauri/src/socket.rs Normal file
View File

@ -0,0 +1,225 @@
use std::sync::Arc;
use anyhow::Context;
use serde_json::json;
use tokio::{
io::AsyncReadExt,
net::{
tcp::{OwnedReadHalf, OwnedWriteHalf},
TcpStream,
},
};
use crate::{
control_msg::{self, ControlMsgType},
scrcpy_mask_cmd::{self, ScrcpyMaskCmdType},
};
pub async fn connect_socket(
address: String,
front_msg_receiver: tokio::sync::mpsc::Receiver<String>,
device_reply_sender: tokio::sync::mpsc::Sender<String>,
listen_handler: u32,
app: Arc<tauri::AppHandle>,
) -> anyhow::Result<()> {
let client = TcpStream::connect(address)
.await
.context("Socket connect failed")?;
println!("成功连接scrcpy-server:{:?}", client.local_addr());
let (read_half, write_half) = client.into_split();
// 开启线程读取设备发送的信息,并通过通道传递到与前端通信的线程,最后与前端通信的线程发送全局事件,告知前端设备发送的信息
tokio::spawn(async move {
read_socket(read_half, device_reply_sender).await;
});
// 开启线程接收通道消息,其中通道消息来自前端发送的事件
tokio::spawn(async move {
recv_front_msg(write_half, front_msg_receiver, listen_handler, app).await;
});
anyhow::Ok(())
}
// 从客户端读取
async fn read_socket(
mut reader: OwnedReadHalf,
device_reply_sender: tokio::sync::mpsc::Sender<String>,
) {
// read dummy byte
let mut buf: [u8; 1] = [0; 1];
if let Err(_e) = reader.read_exact(&mut buf).await {
eprintln!("failed to read dummy byte");
return;
}
// read metadata (device name)
let mut buf: [u8; 64] = [0; 64];
match reader.read(&mut buf).await {
Err(_e) => {
eprintln!("failed to read metadata");
return;
}
Ok(0) => {
eprintln!("failed to read metadata");
return;
}
Ok(n) => {
let mut end = n;
while buf[end - 1] == 0 {
end -= 1;
}
let device_name = std::str::from_utf8(&buf[..end]).unwrap();
let msg = json!({
"type": "MetaData",
"deviceName": device_name,
})
.to_string();
device_reply_sender.send(msg).await.unwrap();
}
};
loop {
match reader.read_u8().await {
Err(e) => {
eprintln!(
"Failed to read from scrcpy server, maybe it was closed. Error:{}",
e
);
println!("Drop TcpStream reader");
drop(reader);
return;
}
Ok(message_type) => {
let message_type = match DeviceMsgType::from_u8(message_type) {
Some(t) => t,
None => {
println!("Ignore unkonw message type: {}", message_type);
continue;
}
};
if let Err(e) =
handle_device_message(message_type, &mut reader, &device_reply_sender).await
{
eprintln!("Failed to handle device message: {}", e);
}
}
}
}
}
async fn handle_device_message(
message_type: DeviceMsgType,
reader: &mut OwnedReadHalf,
device_reply_sender: &tokio::sync::mpsc::Sender<String>,
) -> anyhow::Result<()> {
match message_type {
// 设备剪切板变动
DeviceMsgType::DeviceMsgTypeClipboard => {
let text_length = reader.read_u32().await?;
let mut buf: Vec<u8> = vec![0; text_length as usize];
reader.read_exact(&mut buf).await?;
let cb = String::from_utf8(buf)?;
let msg = json!({
"type": "ClipboardChanged",
"clipboard": cb
})
.to_string();
device_reply_sender.send(msg).await?;
}
// 设备剪切板设置成功的回复
DeviceMsgType::DeviceMsgTypeAckClipboard => {
let sequence = reader.read_u64().await?;
let msg = json!({
"type": "ClipboardSetAck",
"sequence": sequence
})
.to_string();
device_reply_sender.send(msg).await?;
}
// 虚拟设备输出,仅读取但不做进一步处理
DeviceMsgType::DeviceMsgTypeUhidOutput => {
let _id = reader.read_u16().await?;
let size = reader.read_u16().await?;
let mut buf: Vec<u8> = vec![0; size as usize];
reader.read_exact(&mut buf).await?;
}
};
anyhow::Ok(())
}
// 接收前端发送的消息,执行相关操作
async fn recv_front_msg(
mut write_half: OwnedWriteHalf,
mut front_msg_receiver: tokio::sync::mpsc::Receiver<String>,
listen_handler: u32,
app: Arc<tauri::AppHandle>,
) {
while let Some(msg) = front_msg_receiver.recv().await {
match serde_json::from_str::<serde_json::Value>(&msg) {
Err(_e) => {
println!("无法解析的Json数据: {}", msg);
}
Ok(payload) => {
if let Some(front_msg_type) = payload["msgType"].as_i64() {
// 发送原始控制信息
if front_msg_type >= 0 && front_msg_type <= 14 {
let ctrl_msg_type = ControlMsgType::from_i64(front_msg_type).unwrap();
control_msg::send_ctrl_msg(
ctrl_msg_type,
&payload["msgData"],
&mut write_half,
)
.await;
println!("控制信息发送完成!");
continue;
} else {
// 处理Scrcpy Mask命令
if let Some(cmd_type) = ScrcpyMaskCmdType::from_i64(front_msg_type) {
if let ScrcpyMaskCmdType::Shutdown = cmd_type {
drop(write_half);
println!("Drop TcpStream writer");
app.unlisten(listen_handler);
println!("front msg channel closed");
return;
}
scrcpy_mask_cmd::handle_sm_cmd(
cmd_type,
&payload["msgData"],
&mut write_half,
)
.await
}
}
}
else{
eprintln!("fc-command非法");
eprintln!("{:?}", payload);
}
}
};
}
println!("font msg channel closed");
}
#[derive(Debug)]
enum DeviceMsgType {
DeviceMsgTypeClipboard,
DeviceMsgTypeAckClipboard,
DeviceMsgTypeUhidOutput,
}
impl DeviceMsgType {
fn from_u8(value: u8) -> Option<Self> {
match value {
0 => Some(Self::DeviceMsgTypeClipboard),
1 => Some(Self::DeviceMsgTypeAckClipboard),
2 => Some(Self::DeviceMsgTypeUhidOutput),
_ => None,
}
}
}

View File

@ -25,7 +25,10 @@
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png"
"icons/icon.icns"
],
"resources":[
"resource/*"
]
}
}

View File

@ -1,52 +1,40 @@
<script setup lang="ts">
// This starter template is using Vue 3 <script setup> SFCs
// Check out https://vuejs.org/api/sfc-script-setup.html#script-setup
import Greet from "./components/Greet.vue";
import Sidebar from "./components/Sidebar.vue";
import Header from "./components/Header.vue";
import {
darkTheme,
NConfigProvider,
NMessageProvider,
NDialogProvider,
} from "naive-ui";
</script>
<template>
<div class="container">
<h1>Welcome to Tauri!</h1>
<div class="row">
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo vite" alt="Vite logo" />
</a>
<a href="https://tauri.app" target="_blank">
<img src="/tauri.svg" class="logo tauri" alt="Tauri logo" />
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo" />
</a>
</div>
<p>Click on the Tauri, Vite, and Vue logos to learn more.</p>
<p>
Recommended IDE setup:
<a href="https://code.visualstudio.com/" target="_blank">VS Code</a>
+
<a href="https://github.com/johnsoncodehk/volar" target="_blank">Volar</a>
+
<a href="https://github.com/tauri-apps/tauri-vscode" target="_blank"
>Tauri</a
>
+
<a href="https://github.com/rust-lang/rust-analyzer" target="_blank"
>rust-analyzer</a
>
</p>
<Greet />
</div>
<NConfigProvider :theme="darkTheme" class="container">
<NMessageProvider>
<Header />
<NDialogProvider>
<RouterView v-slot="{ Component }">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>
</NDialogProvider>
<Sidebar />
</NMessageProvider>
</NConfigProvider>
</template>
<style scoped>
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.vue:hover {
filter: drop-shadow(0 0 2em #249b73);
<style>
.container {
background-color: transparent;
height: 100%;
overflow: auto;
display: grid;
grid-template-columns: 70px 1fr;
grid-template-rows: 30px 1fr;
grid-template-areas:
"sidebar header"
"sidebar content";
}
</style>

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

323
src/components/Device.vue Normal file
View File

@ -0,0 +1,323 @@
<script setup lang="ts">
import {
Ref,
computed,
h,
nextTick,
onActivated,
onMounted,
onUnmounted,
ref,
} from "vue";
import {
Device,
adbDevices,
pushServerFile,
forwardServerPort,
startScrcpyServer,
} from "../invoke";
import {
NH4,
NInputNumber,
NButton,
NDataTable,
NDropdown,
NEmpty,
NTooltip,
NFlex,
NIcon,
NSpin,
DataTableColumns,
DropdownOption,
useDialog,
} from "naive-ui";
import { CloseCircle, InformationCircle } from "@vicons/ionicons5";
import { Refresh } from "@vicons/ionicons5";
import { UnlistenFn, listen } from "@tauri-apps/api/event";
import { shutdown } from "../frontcommand/scrcpyMaskCmd";
import { useGlobalStore } from "../store/global";
const dialog = useDialog();
const store = useGlobalStore();
const port = ref(27183);
//#region listener
const deviceWaitForMetadataTask: ((
deviceName: string
) => void)[] = [];
let unlisten: UnlistenFn | undefined;
onMounted(async () => {
unlisten = await listen("device-reply", (event) => {
try {
let payload = JSON.parse(event.payload as string);
switch (payload.type) {
case "MetaData":
let task = deviceWaitForMetadataTask.shift();
task?.(payload.deviceName);
break;
case "ClipboardChanged":
console.log("剪切板变动", payload.clipboard);
break;
case "ClipboardSetAck":
console.log("剪切板设置成功", payload.sequence);
break;
default:
console.log("Unknown reply", payload);
break;
}
} catch (e) {
console.error(e);
}
});
});
onActivated(async () => {
await refreshDevices();
});
onUnmounted(() => {
if (unlisten !== undefined) unlisten();
});
//#endregion
//#region table
const devices: Ref<Device[]> = ref([]);
const availableDevice = computed(() => {
return devices.value.filter((d) => {
return store.controledDevice?.device.id !== d.id;
});
});
const tableCols: DataTableColumns = [
{
title: "ID",
key: "id",
},
{
title: "Status",
key: "status",
},
];
// record last operated row index
let rowIndex = -1;
// table row contextmenu and click event handler
const tableRowProps = (_: any, index: number) => {
return {
onContextmenu: (e: MouseEvent) => {
e.preventDefault();
showMenu.value = false;
rowIndex = index;
nextTick().then(() => {
showMenu.value = true;
menuX.value = e.clientX;
menuY.value = e.clientY;
});
},
onclick: (e: MouseEvent) => {
e.preventDefault();
showMenu.value = false;
rowIndex = index;
nextTick().then(() => {
showMenu.value = true;
menuX.value = e.clientX;
menuY.value = e.clientY;
});
},
};
};
//#endregion
//#region controled device
async function shutdownSC() {
dialog.warning({
title: "Warning",
content: "确定关闭Scrcpy控制服务?",
positiveText: "确定",
negativeText: "取消",
onPositiveClick: async () => {
await shutdown();
store.controledDevice = null;
},
});
}
//#endregion
//#region menu
const menuX = ref(0);
const menuY = ref(0);
const showMenu = ref(false);
const menuOptions: DropdownOption[] = [
{
label: () => h("span", "控制此设备"),
key: "control",
},
];
function onMenuClickoutside() {
showMenu.value = false;
}
async function onMenuSelect(key: string) {
showMenu.value = false;
store.showLoading();
switch (key) {
case "control":
if (!port.value) {
port.value = 27183;
}
let device = devices.value[rowIndex];
let scid = (
"00000000" + Math.floor(Math.random() * 100000).toString(16)
).slice(-8);
await pushServerFile(device.id);
await forwardServerPort(device.id, scid, port.value);
await startScrcpyServer(device.id, scid, `127.0.0.1:${port.value}`);
// add cb for metadata
deviceWaitForMetadataTask.push((deviceName: string) => {
store.controledDevice = {
scid,
deviceName,
device,
}
nextTick(() => {
store.hideLoading();
});
});
break;
}
}
//#endregion
async function refreshDevices() {
store.showLoading();
devices.value = await adbDevices();
store.hideLoading();
}
</script>
<template>
<div class="device">
<NSpin :show="store.showLoadingRef">
<NH4 prefix="bar">本地端口</NH4>
<NInputNumber
v-model:value="port"
:show-button="false"
:min="16384"
:max="49151"
style="max-width: 300px"
/>
<NH4 prefix="bar">受控设备</NH4>
<div class="controled-device-list">
<NEmpty
size="small"
description="No Controled Device"
v-if="!store.controledDevice"
/>
<div class="controled-device" v-if="store.controledDevice">
<div>{{ store.controledDevice.deviceName }} ({{ store.controledDevice.device.id }})</div>
<div class="device-op">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary circle type="info">
<template #icon>
<NIcon><InformationCircle /></NIcon>
</template>
</NButton>
</template>
scid: {{ store.controledDevice.scid }}
<br />status: {{ store.controledDevice.device.status }}
</NTooltip>
<NButton
quaternary
circle
type="error"
@click="shutdownSC()"
>
<template #icon>
<NIcon><CloseCircle /></NIcon>
</template>
</NButton>
</div>
</div>
</div>
<NFlex justify="space-between" align="center">
<NH4 prefix="bar">可用设备</NH4>
<NButton
tertiary
circle
type="primary"
@click="refreshDevices"
style="margin-right: 20px"
>
<template #icon>
<NIcon><Refresh /></NIcon>
</template>
</NButton>
</NFlex>
<NDataTable
max-height="120"
:columns="tableCols"
:data="availableDevice"
:row-props="tableRowProps"
:pagination="false"
:bordered="false"
/>
<NDropdown
placement="bottom-start"
trigger="manual"
:x="menuX"
:y="menuY"
:options="menuOptions"
:show="showMenu"
:on-clickoutside="onMenuClickoutside"
@select="onMenuSelect"
/>
</NSpin>
</div>
</template>
<style scoped lang="scss">
.device {
color: var(--light-color);
background-color: var(--bg-color);
padding: 0 25px;
}
.n-h4 {
margin-top: 20px;
}
.controled-device-list {
.controled-device {
padding: 10px 20px;
background-color: var(--content-bg-color);
border: 2px solid var(--content-hl-color);
border-bottom: none;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.3s;
&:last-child {
border-bottom: 2px solid var(--content-hl-color);
}
&:hover {
background-color: var(--content-hl-color);
}
.device-op {
display: flex;
align-items: center;
gap: 10px;
}
}
}
</style>

View File

@ -1,21 +0,0 @@
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
const greetMsg = ref("");
const name = ref("");
async function greet() {
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
greetMsg.value = await invoke("greet", { name: name.value });
}
</script>
<template>
<form class="row" @submit.prevent="greet">
<input id="greet-input" v-model="name" placeholder="Enter a name..." />
<button type="submit">Greet</button>
</form>
<p>{{ greetMsg }}</p>
</template>

62
src/components/Header.vue Normal file
View File

@ -0,0 +1,62 @@
<script setup lang="ts">
import { NButtonGroup, NButton, NIcon } from "naive-ui";
import { Close } from "@vicons/ionicons5";
import { Maximize16Regular, Subtract16Regular } from "@vicons/fluent";
import { getCurrent } from "@tauri-apps/api/window";
async function maximizeOrRestore() {
const appWindow = getCurrent();
appWindow.isMaximized().then((maximized) => {
maximized ? appWindow.unmaximize() : appWindow.maximize();
});
}
</script>
<template>
<div data-tauri-drag-region class="header">
<NButtonGroup>
<NButton quaternary :focusable="false" @click="appWindow.minimize()">
<template #icon>
<NIcon><Subtract16Regular /></NIcon>
</template>
</NButton>
<NButton quaternary :focusable="false" @click="maximizeOrRestore">
<template #icon>
<NIcon><Maximize16Regular /></NIcon>
</template>
</NButton>
<NButton
quaternary
:focusable="false"
class="close"
@click="appWindow.close()"
>
<template #icon>
<NIcon><Close /></NIcon>
</template>
</NButton>
</NButtonGroup>
</div>
</template>
<style scoped lang="scss">
.header {
background-color: var(--bg-color);
color: var(--light-color);
grid-area: header;
display: flex;
justify-content: end;
align-items: center;
border-radius: 0 10px 0 0;
.close {
border-radius: 0 10px 0 0;
&:hover {
background-color: var(--red-color);
}
&:active {
background-color: var(--red-pressed-color);
}
}
}
</style>

85
src/components/Mask.vue Normal file
View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import { onActivated, ref } from "vue";
import { NDialog } from "naive-ui";
import { useGlobalStore } from "../store/global";
import { useRouter } from "vue-router";
import { getCurrent } from "@tauri-apps/api/window";
import { initShortcuts } from "../hotkey";
import { getScreenSize } from "../invoke";
const maskRef = ref<HTMLElement | null>(null);
const store = useGlobalStore();
const router = useRouter();
let isShortcutInited = false;
onActivated(async () => {
if (isShortcutInited) {
maskRef.value?.focus();
return;
}
if (store.controledDevice) {
let screenSize = await getScreenSize(store.controledDevice.device.id);
if (maskRef.value) {
const appWindow = getCurrent();
let posFactor = await appWindow.scaleFactor();
initShortcuts(maskRef.value, posFactor, screenSize);
isShortcutInited = true;
maskRef.value.focus();
console.log("热键已载入");
}
}
});
function toStartServer() {
router.replace({ name: "device" });
}
// TODO
// TODO
// TODO
</script>
<template>
<div v-show="!store.controledDevice" class="notice">
<div class="content">
<NDialog
:closable="false"
title="未找到受控设备"
content="请启动服务端并控制任意设备"
positive-text="去启动"
type="warning"
@positive-click="toStartServer"
/>
</div>
</div>
<div
v-show="store.controledDevice"
tabindex="-1"
class="mask"
ref="maskRef"
></div>
</template>
<style scoped lang="scss">
.mask {
background-color: rgba(255, 255, 255, 0.2);
overflow: hidden;
&:focus {
outline: none;
box-shadow: 0 0 5px var(--primary-color);
}
}
.notice {
background-color: rgba(255, 255, 255, 0.2);
display: flex;
justify-content: center;
align-items: center;
.content {
width: 80%;
}
}
</style>

157
src/components/Sidebar.vue Normal file
View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { useRouter, useRoute } from "vue-router";
import {
GameControllerOutline,
LogoAndroid,
SettingsOutline,
ReturnDownBackOutline,
StopOutline,
ListOutline,
} from "@vicons/ionicons5";
import { Keyboard24Regular } from "@vicons/fluent";
import { NIcon } from "naive-ui";
const router = useRouter();
const route = useRoute();
function nav(name: string) {
router.replace({ name });
}
</script>
<template>
<div data-tauri-drag-region class="sidebar">
<div data-tauri-drag-region class="logo">S M</div>
<div class="module">
<div :class="{ active: route.name == 'mask' }" @click="nav('mask')">
<NIcon>
<GameControllerOutline />
</NIcon>
</div>
<div :class="{ active: route.name == 'device' }" @click="nav('device')">
<NIcon>
<LogoAndroid />
</NIcon>
</div>
<div
:class="{ active: route.name == 'keyboard' }"
@click="nav('keyboard')"
>
<NIcon>
<Keyboard24Regular />
</NIcon>
</div>
<div :class="{ active: route.name == 'setting' }" @click="nav('setting')">
<NIcon>
<SettingsOutline />
</NIcon>
</div>
</div>
<div class="nav">
<div>
<NIcon>
<ReturnDownBackOutline />
</NIcon>
</div>
<div>
<NIcon>
<StopOutline />
</NIcon>
</div>
<div>
<NIcon>
<ListOutline />
</NIcon>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.sidebar {
background-color: var(--bg-color);
border-right: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 10px 0 0 10px;
grid-area: sidebar;
display: flex;
flex-direction: column;
justify-content: space-between;
user-select: none;
.logo {
height: 30px;
font-size: 18px;
font-weight: bold;
display: flex;
justify-content: center;
align-items: center;
color: var(--light-color);
}
.module {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
& > div {
flex-shrink: 0;
height: 50px;
color: var(--gray-color);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
box-sizing: border-box;
font-size: 28px;
cursor: pointer;
&:hover {
color: var(--primary-hover-color);
transform: scale(1.05);
}
&:active {
color: var(--primary-pressed-color);
transform: scale(0.9);
}
}
& > div.active {
color: var(--primary-color);
border-left: 3px solid var(--primary-color);
border-radius: 3px;
}
}
.nav {
color: var(--light-color);
font-size: 20px;
display: flex;
flex-direction: column;
flex-shrink: 0;
& > div {
height: 40px;
display: flex;
justify-content: center;
align-items: center;
.NIcon {
cursor: pointer;
transition: transform 0.3s ease;
}
.NIcon:hover {
color: var(--primary-hover-color);
transform: scale(1.1);
}
.NIcon:active {
color: var(--primary-pressed-color);
transform: scale(0.9);
}
}
}
}
</style>

View File

@ -0,0 +1,56 @@
<script setup lang="ts">
import { getCurrent } from "@tauri-apps/api/window";
import { onActivated, onMounted, ref } from "vue";
import { onBeforeRouteLeave } from "vue-router";
// TODO
// TODO
const keyboardElement = ref<HTMLElement | null>(null);
const mouseX = ref(0);
const mouseY = ref(0);
let posFactor = 1;
function clientxToPosx(clientx: number) {
return clientx < 70 ? 0 : Math.floor((clientx - 70) * posFactor);
}
function clientyToPosy(clienty: number) {
return clienty < 30 ? 0 : Math.floor((clienty - 30) * posFactor);
}
let ignoreMousemove = true;
function mousemoveHandler(event: MouseEvent) {
ignoreMousemove = !ignoreMousemove;
if (ignoreMousemove) return;
mouseX.value = clientxToPosx(event.clientX);
mouseY.value = clientyToPosy(event.clientY);
}
onMounted(async () => {
const appWindow = getCurrent();
posFactor = await appWindow.scaleFactor();
});
onActivated(() => {
keyboardElement.value?.addEventListener("mousemove", mousemoveHandler);
});
onBeforeRouteLeave(() => {
keyboardElement.value?.removeEventListener("mousemove", mousemoveHandler);
});
</script>
<template>
<div ref="keyboardElement" class="keyboard">
此处最好用其他颜色的蒙版和右侧的按键列表区同色
<div>{{ mouseX }}, {{ mouseY }}</div>
</div>
</template>
<style scoped>
.keyboard {
background-color: rgba(255, 255, 255, 0.5);
overflow: hidden;
}
</style>

View File

@ -0,0 +1,17 @@
<script setup lang="ts">
import { NH4, NForm, FormInst } from "naive-ui";
import { ref } from "vue";
const formRef = ref<FormInst | null>(null);
</script>
<template>
<div class="setting-page">
<NForm ref="formRef" label-placement="left">
<NH4 prefix="bar">客户端相关</NH4>
</NForm>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,201 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import {
NH4,
NForm,
NGrid,
NFormItemGi,
NInputNumber,
FormRules,
NButton,
NFlex,
NIcon,
FormInst,
useMessage,
} from "naive-ui";
import {
PhysicalPosition,
PhysicalSize,
getCurrent,
} from "@tauri-apps/api/window";
import { SettingsOutline } from "@vicons/ionicons5";
import { UnlistenFn } from "@tauri-apps/api/event";
let unlistenResize: UnlistenFn = () => {};
let unlistenMove: UnlistenFn = () => {};
async function refreshAreaModel(size?: PhysicalSize, pos?: PhysicalPosition) {
const appWindow = getCurrent();
const factor = await appWindow.scaleFactor();
// header size and sidebar size
const mt = 30 * factor;
const ml = 70 * factor;
if (pos !== undefined) {
areaModel.value.posX = Math.floor(pos.x + ml);
areaModel.value.posY = Math.floor(pos.y + mt);
}
if (size !== undefined) {
areaModel.value.sizeW = Math.floor(size.width - ml);
areaModel.value.sizeH = Math.floor(size.height - mt);
}
}
const message = useMessage();
const formRef = ref<FormInst | null>(null);
const areaModel = ref({
posX: 0,
posY: 0,
sizeW: 0,
sizeH: 0,
});
const areaFormRules: FormRules = {
posX: {
type: "number",
required: true,
trigger: ["blur", "input"],
message: "请输入左上角X坐标",
},
posY: {
type: "number",
required: true,
trigger: ["blur", "input"],
message: "请输入左上角Y坐标",
},
sizeW: {
type: "number",
required: true,
trigger: ["blur", "input"],
message: "请输入蒙版宽度",
},
sizeH: {
type: "number",
required: true,
trigger: ["blur", "input"],
message: "请输入蒙版高度",
},
};
function handleAdjustClick(e: MouseEvent) {
e.preventDefault();
formRef.value?.validate((errors) => {
if (!errors) {
adjustMaskArea().then(() => {
message.success("调整完成");
});
} else {
message.error("请正确输入蒙版的坐标和尺寸");
}
});
}
// move and resize window to the selected window (control) area
async function adjustMaskArea() {
// header size and sidebar size
const mt = 30;
const ml = 70;
const appWindow = getCurrent();
const factor = await appWindow.scaleFactor();
const pos = new PhysicalPosition(
areaModel.value.posX,
areaModel.value.posY
).toLogical(factor);
pos.y -= mt;
pos.x -= ml;
if (pos.x <= 0 || pos.y <= 0) {
message.warning("蒙版区域坐标过小,可能导致其他部分不可见");
}
const size = new PhysicalSize(
areaModel.value.sizeW,
areaModel.value.sizeH
).toLogical(factor);
size.width += ml;
size.height += mt;
await appWindow.setPosition(pos);
await appWindow.setSize(size);
}
onMounted(async () => {
const appWindow = getCurrent();
unlistenResize = await appWindow.onResized(({ payload: size }) => {
refreshAreaModel(size, undefined);
});
unlistenMove = await appWindow.onMoved(({ payload: position }) => {
refreshAreaModel(undefined, position);
});
refreshAreaModel(
await appWindow.outerSize(),
await appWindow.outerPosition()
);
});
onUnmounted(() => {
unlistenResize();
unlistenMove();
});
</script>
<template>
<div class="setting-page">
<NFlex justify="space-between" align="center">
<NH4 prefix="bar">手动调整</NH4>
<NButton
tertiary
circle
type="primary"
@click="handleAdjustClick"
style="margin-right: 20px"
>
<template #icon>
<NIcon><SettingsOutline /></NIcon>
</template>
</NButton>
</NFlex>
<NForm
ref="formRef"
:model="areaModel"
:rules="areaFormRules"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
>
<NGrid :cols="2" :x-gap="24">
<NFormItemGi label="X" path="posX">
<NInputNumber
v-model:value="areaModel.posX"
placeholder="左上角X坐标"
/>
</NFormItemGi>
<NFormItemGi label="Y" path="posY">
<NInputNumber
v-model:value="areaModel.posY"
placeholder="左上角Y坐标"
/>
</NFormItemGi>
<NFormItemGi label="W" path="sizeW">
<NInputNumber
v-model:value="areaModel.sizeW"
placeholder="蒙版宽度"
/>
</NFormItemGi>
<NFormItemGi label="H" path="sizeH">
<NInputNumber
v-model:value="areaModel.sizeH"
placeholder="蒙版高度"
/>
</NFormItemGi>
</NGrid>
</NForm>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
</script>
<template>
<div class="setting-page">
脚本设置
</div>
</template>
<style scoped>
</style>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import Basic from "./Basic.vue";
import Script from "./Script.vue";
import Mask from "./Mask.vue";
import { NTabs, NTabPane, NScrollbar } from "naive-ui";
</script>
<template>
<div class="setting">
<NTabs type="line" animated placement="left" default-value="basic">
<NTabPane tab="基本设置" name="basic">
<NScrollbar>
<Basic />
</NScrollbar>
</NTabPane>
<NTabPane tab="蒙版设置" name="mask">
<NScrollbar>
<Mask />
</NScrollbar>
</NTabPane>
<NTabPane tab="脚本设置" name="script">
<NScrollbar>
<Script />
</NScrollbar>
</NTabPane>
</NTabs>
</div>
</template>
<style scoped lang="scss">
.setting {
background-color: var(--content-bg-color);
color: var(--light-color);
overflow: hidden;
display: flex;
.NTabPane {
padding: 0;
}
.setting-page {
padding: 10px 25px;
}
}
</style>

1538
src/frontcommand/android.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,227 @@
import { emit } from "@tauri-apps/api/event";
import {
AndroidKeyEventAction,
AndroidKeycode,
AndroidMetastate,
AndroidMotionEventAction,
AndroidMotionEventButtons,
} from "./android";
interface ControlMsgPayload {
msgType: ControlMsgType;
msgData?: ControlMsgData;
}
async function sendControlMsg(payload: ControlMsgPayload) {
await emit("front-command", payload);
}
export async function sendInjectKeycode(payload: InjectKeycode) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeInjectKeycode,
msgData: payload,
});
}
export async function sendInjectText(payload: InjectText) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeInjectText,
msgData: payload,
});
}
export async function sendInjectTouchEvent(payload: InjectTouchEvent) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeInjectTouchEvent,
msgData: payload,
});
}
export async function sendInjectScrollEvent(payload: InjectScrollEvent) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeInjectScrollEvent,
msgData: payload,
});
}
export async function sendBackOrScreenOn(payload: BackOrScreenOn) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeBackOrScreenOn,
msgData: payload,
});
}
export async function sendExpandNotificationPanel() {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeExpandNotificationPanel,
});
}
export async function sendExpandSettingsPanel() {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeExpandSettingsPanel,
});
}
export async function sendCollapsePanels() {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeCollapsePanels,
});
}
export async function sendGetClipboard(payload: GetClipboard) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeGetClipboard,
msgData: payload,
});
}
export async function sendSetClipboard(payload: SetClipboard) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeSetClipboard,
msgData: payload,
});
}
export async function sendSetScreenPowerMode(payload: SetScreenPowerMode) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeSetScreenPowerMode,
msgData: payload,
});
}
export async function sendRotateDevice() {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeRotateDevice,
});
}
export async function sendUhidCreate(payload: UhidCreate) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeUhidCreate,
msgData: payload,
});
}
export async function sendUhidInput(payload: UhidInput) {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeUhidInput,
msgData: payload,
});
}
export async function sendOpenHardKeyboardSettings() {
await sendControlMsg({
msgType: ControlMsgType.ControlMsgTypeOpenHardKeyboardSettings,
});
}
export enum ControlMsgType {
ControlMsgTypeInjectKeycode, //发送原始按键
ControlMsgTypeInjectText, //发送文本不知道是否能输入中文估计只是把文本转为keycode的输入效果
ControlMsgTypeInjectTouchEvent, //发送触摸事件
ControlMsgTypeInjectScrollEvent, //发送滚动事件(类似接入鼠标后滚动滚轮的效果,不是通过触摸实现的)
ControlMsgTypeBackOrScreenOn, //应该就是发送返回键
ControlMsgTypeExpandNotificationPanel, //打开消息面板
ControlMsgTypeExpandSettingsPanel, //打开设置面板(就是消息面板右侧的)
ControlMsgTypeCollapsePanels, //折叠上述面板
ControlMsgTypeGetClipboard, //获取剪切板
ControlMsgTypeSetClipboard, //设置剪切板
ControlMsgTypeSetScreenPowerMode, //设置屏幕电源模式是关闭设备屏幕的SC_SCREEN_POWER_MODE_OFF 和 SC_SCREEN_POWER_MODE_NORMAL
ControlMsgTypeRotateDevice, //旋转设备屏幕
ControlMsgTypeUhidCreate, //创建虚拟设备?从而模拟真实的键盘、鼠标用的,目前没用
ControlMsgTypeUhidInput, //同上转发键盘、鼠标的输入,目前没用
ControlMsgTypeOpenHardKeyboardSettings, //打开设备的硬件键盘设置,目前没用
}
type ControlMsgData =
| InjectKeycode
| InjectText
| InjectTouchEvent
| InjectScrollEvent
| BackOrScreenOn
| GetClipboard
| SetClipboard
| SetScreenPowerMode
| UhidCreate
| UhidInput;
interface ScPosition {
x: number;
y: number;
// screen width
w: number;
// screen height
h: number;
}
interface InjectKeycode {
action: AndroidKeyEventAction;
keycode: AndroidKeycode;
// https://developer.android.com/reference/android/view/KeyEvent#getRepeatCount()
repeat: number;
metastate: AndroidMetastate;
}
enum ScCopyKey {
SC_COPY_KEY_NONE,
SC_COPY_KEY_COPY,
SC_COPY_KEY_CUT,
}
enum ScScreenPowerMode {
// see <https://android.googlesource.com/platform/frameworks/base.git/+/pie-release-2/core/java/android/view/SurfaceControl.java#305>
SC_SCREEN_POWER_MODE_OFF = 0,
SC_SCREEN_POWER_MODE_NORMAL = 2,
}
interface InjectText {
text: string;
}
interface InjectTouchEvent {
action: AndroidMotionEventAction;
actionButton: AndroidMotionEventButtons;
buttons: AndroidMotionEventButtons;
pointerId: number;
position: ScPosition;
pressure: number;
}
interface InjectScrollEvent {
position: ScPosition;
hscroll: number;
vscroll: number;
buttons: AndroidMotionEventButtons;
}
interface BackOrScreenOn {
action: AndroidKeyEventAction; // action for the BACK key
// screen may only be turned on on ACTION_DOWN
}
interface GetClipboard {
copyKey: ScCopyKey;
}
interface SetClipboard {
sequence: number;
text: string;
paste: boolean;
}
interface SetScreenPowerMode {
mode: ScScreenPowerMode;
}
interface UhidCreate {
id: number;
reportDescSize: number;
reportDesc: Uint8Array;
}
interface UhidInput {
id: number;
size: number;
data: Uint8Array;
}

View File

@ -0,0 +1,85 @@
import { emit } from "@tauri-apps/api/event";
import { AndroidKeycode, AndroidMetastate } from "./android";
async function sendScrcpyMaskCmd(
commandType: ScrcpyMaskCmdType,
msgData: ScrcpyMaskCmdData
) {
const payload: ScrcpyMaskCmdPayload = { msgType: commandType, msgData };
await emit("front-command", payload);
}
export async function sendKey(payload: CmdDataSendKey) {
await sendScrcpyMaskCmd(ScrcpyMaskCmdType.SendKey, payload);
}
export async function touch(payload: CmdDataTouch) {
await sendScrcpyMaskCmd(ScrcpyMaskCmdType.Touch, payload);
}
export async function swipe(payload: CmdDataSwipe) {
await sendScrcpyMaskCmd(ScrcpyMaskCmdType.Swipe, payload);
}
export async function shutdown() {
await sendScrcpyMaskCmd(ScrcpyMaskCmdType.Shutdown, "");
}
export enum ScrcpyMaskCmdType {
SendKey = 15,
Touch = 16,
Swipe = 17,
Shutdown = 18,
}
type ScrcpyMaskCmdData =
| CmdDataSendKey
| CmdDataTouch
| CmdDataSwipe
| String;
enum SendKeyAction {
Default = 0,
Down = 1,
Up = 2,
}
interface CmdDataSendKey {
action: SendKeyAction;
keycode: AndroidKeycode;
metastate?: AndroidMetastate;
}
export enum TouchAction {
Default = 0,
Down = 1,
Up = 2,
Move = 3,
}
interface CmdDataTouch {
action: TouchAction;
pointerId: number;
screen: { w: number; h: number };
pos: { x: number; y: number };
}
export enum SwipeAction {
Default = 0,
// cooperate with touch action
NoUp = 1,
NoDown = 2,
}
interface CmdDataSwipe {
action: SwipeAction;
pointerId: number;
screen: { w: number; h: number };
pos: { x: number; y: number }[];
intervalBetweenPos: number;
}
interface ScrcpyMaskCmdPayload {
msgType: ScrcpyMaskCmdType;
msgData: ScrcpyMaskCmdData;
}

539
src/hotkey.ts Normal file
View File

@ -0,0 +1,539 @@
// https://github.com/jamiebuilds/tinykeys/pull/193/commits/2598ecb3db6b3948c7acbf0e7bd8b0674961ad61
import {
SwipeAction,
TouchAction,
swipe,
touch,
} from "./frontcommand/scrcpyMaskCmd";
let posFactor = 1; // it will be replaced in initMouseShortcuts
function clientxToPosx(clientx: number) {
return clientx < 70 ? 0 : Math.floor((clientx - 70) * posFactor);
}
function clientyToPosy(clienty: number) {
return clienty < 30 ? 0 : Math.floor((clienty - 30) * posFactor);
}
function clientxToSkillOffsetx(clientx: number, range: number, scale = 0.5) {
// Get the offset relative to the center of the screen
let offsetX = clientxToPosx(clientx) - screenSizeW / 2;
return Math.max(-range, Math.min(range, Math.round(offsetX * scale)));
}
function clientyToSkillOffsety(clienty: number, range: number, scale = 0.5) {
// Get the offset relative to the center of the screen
let offsetY = clientyToPosy(clienty) - screenSizeH / 2;
return Math.max(-range, Math.min(range, Math.round(offsetY * scale)));
}
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// TODO 取消技能
// 仅仅针对技能快捷键要求传入pointer_id所有技能键的都一样, 取消位置
// 在down时就执行
// 执行时直接删除对应的loopDownKeyCBMap, upKeyCBMap键值对恢复cursor然后将触点使用touch-move到取消位置然后up
// TODO 普通点击
// 直接用default的touch按下时长是写死的100ms
// TODO 视野移动
// 关键在于中心位置应该是传入的坐标,要重新计算相对偏移
// TODO 宏
// 宏也是分为downloopup三个阶段
// 目前只需要支持sleep, touch, swipe三个指令即可
// add shortcuts for trigger when pressed skill
function addTriggerWhenPressedSkillShortcuts(
key: string,
// pos relative to the device
posX: number,
posY: number,
directional: boolean,
// range is needed when directional is true
range: number,
pointerId: number
) {
skillKeyList.push(key);
if (directional) {
addShortcut(
key,
// down
async () => {
await swipe({
action: SwipeAction.Default,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: [
{ x: posX, y: posY },
{
x: posX + clientxToSkillOffsetx(mouseX, range),
y: posY + clientyToSkillOffsety(mouseY, range),
},
],
intervalBetweenPos: 0,
});
},
undefined,
undefined
);
} else {
addShortcut(
key,
// down
async () => {
await touch({
action: TouchAction.Down,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: posX,
y: posY,
},
});
},
undefined,
undefined
);
}
}
// add shortcuts for directionless skill
function addDirectionlessSkillShortcuts(
key: string,
// pos relative to the device
posX: number,
posY: number,
pointerId: number
) {
skillKeyList.push(key);
addShortcut(
key,
// down
async () => {
document.body.style.cursor = "pointer";
await touch({
action: TouchAction.Down,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: posX,
y: posY,
},
});
},
// loop
undefined,
// up
async () => {
document.body.style.cursor = "";
await touch({
action: TouchAction.Up,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: posX,
y: posY,
},
});
}
);
}
// add shortcuts for directional skill
function addDirectionalSkillShortcuts(
key: string,
// pos relative to the device
posX: number,
posY: number,
range: number,
pointerId: number
) {
skillKeyList.push(key);
addShortcut(
key,
// down
async () => {
document.body.style.cursor = "pointer";
await swipe({
action: SwipeAction.NoUp,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: [
{ x: posX, y: posY },
{
x: posX + clientxToSkillOffsetx(mouseX, range),
y: posY + clientyToSkillOffsety(mouseY, range),
},
],
intervalBetweenPos: 0,
});
},
// loop
async () => {
await touch({
action: TouchAction.Move,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: posX + clientxToSkillOffsetx(mouseX, range),
y: posY + clientyToSkillOffsety(mouseY, range),
},
});
},
// up
async () => {
document.body.style.cursor = "";
await touch({
action: TouchAction.Up,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: posX + clientxToSkillOffsetx(mouseX, range),
y: posY + clientyToSkillOffsety(mouseY, range),
},
});
}
);
}
// add shortcuts for steering wheel
function addSteeringWheelKeyboardShortcuts(
key: wheelKey,
// pos relative to the device
posX: number,
posY: number,
offset: number,
pointerId: number
) {
for (const k of ["left", "right", "up", "down"]) {
if (key[k])
addShortcut(
key[k],
async () => {
_keyDownFlag[k] = true;
await _wheelKeyLoop(pointerId, posX, posY, offset);
},
undefined,
async () => {
_keyDownFlag[k] = false;
}
);
}
}
let _keyDownFlag: stringKeyFlag = {
left: false,
right: false,
up: false,
down: false,
};
let _isWheelKeyLoopRunning = false;
// single loop for the steering wheel
async function _wheelKeyLoop(
pointerId: number,
// pos relative to the device
posX: number,
posY: number,
offset: number
) {
if (_isWheelKeyLoopRunning) return;
_isWheelKeyLoopRunning = true;
// calculate the end coordinates of the eight directions of the direction wheel
let offsetHalf = Math.round(offset / 1.414);
const pos = [
{ x: posX - offset, y: posY }, // left
{ x: posX + offset, y: posY }, // right
{ x: posX, y: posY - offset }, // up
{ x: posX, y: posY + offset }, // down
{ x: posX - offsetHalf, y: posY - offsetHalf }, // left up
{ x: posX + offsetHalf, y: posY - offsetHalf }, // right up
{ x: posX - offsetHalf, y: posY + offsetHalf }, // left down
{ x: posX + offsetHalf, y: posY + offsetHalf }, // right down
];
// touch down on the center position
await touch({
action: TouchAction.Down,
pointerId: pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: posX,
y: posY,
},
});
// move to the direction
let curPos;
let _lastKeyDownFlag: stringKeyFlag = {
left: true,
right: true,
up: true,
down: true,
};
while (
_keyDownFlag.left ||
_keyDownFlag.right ||
_keyDownFlag.up ||
_keyDownFlag.down
) {
// if key down not changed
if (
_keyDownFlag.left === _lastKeyDownFlag.left &&
_keyDownFlag.right === _lastKeyDownFlag.right &&
_keyDownFlag.up === _lastKeyDownFlag.up &&
_keyDownFlag.down === _lastKeyDownFlag.down
) {
await sleep(50);
continue;
}
// record the last key down flag
_lastKeyDownFlag = { ..._keyDownFlag };
// key down changed
if (_keyDownFlag.left) {
curPos = _keyDownFlag.up ? pos[4] : _keyDownFlag.down ? pos[6] : pos[0];
} else if (_keyDownFlag.right) {
curPos = _keyDownFlag.up ? pos[5] : _keyDownFlag.down ? pos[7] : pos[1];
} else if (_keyDownFlag.up) {
curPos = pos[2];
} else if (_keyDownFlag.down) {
curPos = pos[3];
} else {
curPos = { x: posX, y: posY };
}
await touch({
action: TouchAction.Move,
pointerId: pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: curPos,
});
await sleep(100);
}
// touch up
await touch({
action: TouchAction.Up,
pointerId: pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: curPos ? curPos : { x: posX, y: posY },
});
_isWheelKeyLoopRunning = false;
}
interface wheelKey {
left: string;
right: string;
up: string;
down: string;
[key: string]: string;
}
type stringKeyFlag = Record<string, boolean>;
// add baisc left click shortcuts
function addLeftClickShortcuts(pointerId: number) {
addShortcut(
"M0",
async () => {
await touch({
action: TouchAction.Down,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: clientxToPosx(mouseX),
y: clientyToPosy(mouseY),
},
});
},
async () => {
await touch({
action: TouchAction.Move,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: clientxToPosx(mouseX),
y: clientyToPosy(mouseY),
},
});
},
async () => {
await touch({
action: TouchAction.Up,
pointerId,
screen: {
w: screenSizeW,
h: screenSizeH,
},
pos: {
x: clientxToPosx(mouseX),
y: clientyToPosy(mouseY),
},
});
}
);
}
let screenSizeW: number;
let screenSizeH: number;
let mouseX = 0;
let mouseY = 0;
let oldMouseX = 0;
let oldMouseY = 0;
const downKeyMap: Map<string, boolean> = new Map();
const downKeyCBMap: Map<string, () => Promise<void>> = new Map();
const loopDownKeyCBMap: Map<string, () => Promise<void>> = new Map();
const upKeyCBMap: Map<string, () => Promise<void>> = new Map();
const skillKeyList: string[] = [];
function keydownHandler(event: KeyboardEvent) {
if (event.repeat) return;
if (downKeyMap.has(event.key)) {
event.preventDefault();
// execute the down callback (if there is) asyncily
let cb = downKeyCBMap.get(event.key);
if (cb) cb();
downKeyMap.set(event.key, true);
}
}
function keyupHandler(event: KeyboardEvent) {
if (downKeyMap.has(event.key)) {
event.preventDefault();
// execute the up callback (if there is) asyncily
let cb = upKeyCBMap.get(event.key);
if (cb) cb();
downKeyMap.set(event.key, false);
}
}
function handleMouseDown(event: MouseEvent) {
let key = "M" + event.button.toString();
if (downKeyMap.has(key)) {
event.preventDefault();
// execute the down callback asyncily
let cb = downKeyCBMap.get(key);
if (cb) cb();
downKeyMap.set(key, true);
}
}
function handleMouseUp(event: MouseEvent) {
let key = "M" + event.button.toString();
if (downKeyMap.has(key)) {
event.preventDefault();
// execute the up callback asyncily
let cb = upKeyCBMap.get(key);
if (cb) cb();
downKeyMap.set(key, false);
}
}
function handleMouseMove(event: MouseEvent) {
oldMouseX = mouseX;
oldMouseY = mouseY;
mouseX = event.clientX;
mouseY = event.clientY;
}
function addShortcut(
key: string,
downCB: () => Promise<void>,
loopCB?: () => Promise<void>,
upCB?: () => Promise<void>
) {
downKeyMap.set(key, false);
if (downCB && loopCB)
downKeyCBMap.set(key, async () => {
downCB();
loopDownKeyCBMap.set(key, loopCB);
});
else if (downCB) {
downKeyCBMap.set(key, downCB);
}
if (upCB) {
if (loopCB)
upKeyCBMap.set(key, async () => {
loopDownKeyCBMap.delete(key);
upCB();
});
else upKeyCBMap.set(key, upCB);
}
}
export function initShortcuts(
element: HTMLElement,
factor: number,
screenSize: [number, number]
) {
posFactor = factor;
screenSizeW = screenSize[0];
screenSizeH = screenSize[1];
element.addEventListener("mousedown", handleMouseDown);
element.addEventListener("mousemove", handleMouseMove);
element.addEventListener("mouseup", handleMouseUp);
element.addEventListener("keydown", keydownHandler);
element.addEventListener("keyup", keyupHandler);
addLeftClickShortcuts(0);
addSteeringWheelKeyboardShortcuts(
{
left: "a",
right: "d",
up: "w",
down: "s",
},
180,
560,
100,
1
);
addDirectionalSkillShortcuts("Alt", 1025, 500, 200, 2);
addDirectionalSkillShortcuts("q", 950, 610, 200, 2);
addDirectionalSkillShortcuts("e", 1160, 420, 200, 2);
addTriggerWhenPressedSkillShortcuts("M4", 1160, 420, false, 0, 2);
setInterval(() => {
loopDownKeyCBMap.forEach((cb) => {
if (oldMouseX !== mouseX && oldMouseY !== mouseY) {
cb();
}
});
}, 50);
}

36
src/invoke.ts Normal file
View File

@ -0,0 +1,36 @@
import { invoke } from '@tauri-apps/api/core';
interface Device {
id: string;
status: string;
}
export async function adbDevices(): Promise<Device[]> {
return await invoke("adb_devices");
}
export async function getScreenSize(id: string): Promise<[number, number]> {
return await invoke("get_screen_size", { id });
}
export async function forwardServerPort(
id: string,
scid: string,
port: number
): Promise<void> {
return await invoke("forward_server_port", { id, scid, port });
}
export async function pushServerFile(id: string): Promise<void> {
return await invoke("push_server_file", { id });
}
export async function startScrcpyServer(
id: string,
scid: string,
address: string
): Promise<void> {
return await invoke("start_scrcpy_server", { id, scid, address });
}
export type { Device };

View File

@ -1,5 +1,12 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import "./styles.css";
import App from "./App.vue";
import router from "./router";
createApp(App).mount("#app");
const pinia = createPinia();
const app = createApp(App);
app.use(router);
app.use(pinia);
app.mount("#app");

19
src/router.ts Normal file
View File

@ -0,0 +1,19 @@
import { createRouter, createWebHashHistory } from "vue-router";
import Mask from "./components/Mask.vue";
import Setting from "./components/setting/Setting.vue";
import KeyBoard from "./components/keyboard/KeyBoard.vue";
import Device from "./components/Device.vue";
const routes = [
{ path: "/", name: "mask", component: Mask },
{ path: "/device", name: "device", component: Device },
{ path: "/setting", name: "setting", component: Setting },
{ path: "/keyboard", name: "keyboard", component: KeyBoard },
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;

28
src/store/global.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineStore } from "pinia";
import { Ref, ref } from "vue";
import { Device } from "../invoke";
export const useGlobalStore = defineStore("counter", () => {
const showLoadingRef = ref(false);
function showLoading() {
showLoadingRef.value = true;
}
function hideLoading() {
showLoadingRef.value = false;
}
interface ControledDevice {
scid: string;
deviceName: string;
device: Device;
}
const controledDevice: Ref<ControledDevice|null> = ref(null);
return {
showLoading,
hideLoading,
showLoadingRef,
controledDevice,
};
});

View File

@ -1,109 +1,27 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
--primary-color: #63E2B7;
--primary-hover-color: #7FE7C4;
--primary-pressed-color: #5ACEA7;
--bg-color: #101014;
--content-bg-color: #18181C;
--content-hl-color: #26262A;
--light-color: rgba(255, 255, 255, 0.9);
--gray-color: #6b6e76;
--red-color: #fc5185;
--red-pressed-color: #f4336d;
}
.container {
html,
body {
background-color: transparent;
height: 100vh;
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
overflow: hidden;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
div#app {
height: 100%;
box-sizing: border-box;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
#greet-input {
margin-right: 5px;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}

View File

@ -21,5 +21,4 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}