服务器选用了 openresty。 选用服务器考虑了性能和生态,网络协议需要 HTTP 和 Websocket。也想过用 skynet, 虽然 skynet 也已实现了 websocket,但还是选择了在 web 领域应用更广泛的框架。
初版的客户端只是一个 bash 脚本,调用 openssl 本地加密解密数据,并用 curl 推送或者拉取数据到服务器。正式的客户端还是选用了 Lua 语言做主要的开发语言,C 语言作为 Lua 的扩展库开发语言。
服务器最初是先实现了 copy 和 paste 两个协议,copy 采用 POST (把本地数据推送到服务器), paste 采用 GET (从服务器拉取数据)。数据采用文件的形式存盘在服务器。
然后做一个账号系统来验证登录,本来打算自己实现一套账号系统的,但是担心用户注册麻烦,最终还是选用了 GitHub OAuth 的方式来登录。这个实现主要是接入 GitHub 的 API,拿到 token 和用户信息之后保存到服务器,再给用户生成一个 JWT . 之后就客户端使用 JWT 跟服务器交互。
关于使用 GitHub 登录和 JWT 是啥玩意参考这里列出的文章: https://blog.hanxi.info/?p=34
网站的主页页面也就两个,一个主页,一个登录后的个人页面。后端采用的模板库是 bungle/lua-resty-template , 前端采用了 Bootstrap 库。登录成功后就能显示 JWT ,还弄了个自动安装客户端的命令。然后实现点击按钮复制命令的实现可看这里: https://blog.hanxi.info/?p=35
初版的 Linux 客户端的配置文件在 $HOME/.oclip
,安装位置在 $HOME/.local/bin
。实现过程中用到的一些 Bash 脚本关键点记录在这里: https://blog.hanxi.info/?p=33
到这里就已经实现了一个初步可用的远程剪切板了,使用流程为 进入到 https://oclip.hanxi.info ,然后用 GitHub 登录,再复制安装命令到系统终端执行(不用 root 账户),然后就能使用 oclip 命令了。 oclip 的参数格式参考了 xclip。
# 复制
echo hello | oclip
# 粘贴
oclip -o
然后在本地测试速度还可以 0.0xx 秒:
real 0m0.046s
user 0m0.015s
sys 0m0.002s
远程测试速度为,可能慢在 HTTPS 连接上。
real 0m0.392s
user 0m0.129s
sys 0m0.097s
最初想用 websocket 不是为了解决速度的问题,而是需要一个长连接的机制来实现监控和修改 Windows 的系统粘贴板。比如我在 Linux 上执行复制命令 echo hello|oclip
,然后数据可以自动推送到 Windows 上并修改系统粘贴板。
确定好 Windows 客户端使用 Lua 开发后,需要使用的库大致分为操作系统剪切板,系统图标,websocket。
系统剪切板的操作有现成的 Lua 库 jaslatrix/clipboard,但是好多功能是我不需要的,而且没有监听剪切板的功能,因此我就参考它和 MSDN 自己实现了一个 hanxi/lclipboard
系统图标也找到了一个 C 库 zserge/tray ,对它进行修改调整实现了一个 Lua 库 hanxi/ltray
剩下最后一个大问题了,websocket 库如何选择?服务端用 openresty 的官方自带了 websocket 库,客户端找了好多,最后还是觉得下面两个比较合适:
但是 lua-http
依赖的 cqueues
不支持 Windows,自己去适配的话太耗时间了,最终选了 lua-websockets
这个库,然后参考 RamiLego4Game/Love-Discord 改成异步的接口,放在这里了 hanxi/lua-websockets
为了支持 https 和 wss。 编译 luasec 花了点时间,网上找的 openssl 二进制包都有点问题,最后自己安装 perl 编译了一个 openssl 就没问题了。
还差一个交互协议的选择,最后的决定是参考 JSON-RPC,用 msgpack 格式打包类似的结构进行交互。msgpack 库打算用 Redis 的 antirez/lua-cmsgpack,最后发现 Openresty 安装时由于没装编译环境导致安装失败。所以选用了纯 Lua 实现的 fperrad/lua-MessagePack。
Windows 客户端的零件都准备好了,可以开始组装了。
出现一个本地加密数据的问题, luasec 没有提供 openssl enc 接口,然后找到 luacrypto 和 lua-openssl。最后发现 lua-openssl 兼容了 luacrypto 和 luasec。 这样就可以只用 lua-openssl 替代 luacrypto 和 luasec 了。
在 windows 下编译 lua-openssl 比较顺畅, 它提供了 makefile.win, 在编译 luasec 的基础上参考它就编译成功了。 最后使用 lua-openssl 的时候,遇到了两个问题:
send
和receive
接口的实现跟 luasocket 实现的不一致,不能调用select
函数。crypto.digest
函数漏了raw
参数没有往下面传递。
然后我都提了 issues 并修复了。
接下来就是遇到 openssl enc
加密的数据, lua-openssl 解密不了的问题。这里需要去理解 -K -iv
参数的生成规则才行。
参考这里: https://www.jianshu.com/p/813e184b56bd
AES-128-CBC 的 Key 和 iv 生成规则
hash1_256 = SHA256(Passphrase)
Key = First128bit(hash1_256)
IV = Second128bit(hash1_256)
等处理完数据加密解密,这个 Windows 客户端基本就基本完工了。
更新: 本地数据加密解密函数封装如下
local function get_key_iv(passwd)
hash_256 = digest.digest('sha256', passwd)
local key = hash_256:sub(1, 32)
local iv = hash_256:sub(33, 64)
key = openssl.hex(key, false)
iv = openssl.hex(iv, false)
return key, iv
end
function _M.decrypt(data)
local passwd = 'passwd'
local key, iv = get_key_iv(passwd)
return cipher.decrypt('aes-128-cbc', data, key, iv)
end
function _M.encrypt(data)
local passwd = 'passwd'
local key, iv = get_key_iv(passwd)
return cipher.encrypt('aes-128-cbc', data, key, iv)
end
对应 openssl 命令为:
if [ $oclip_type == "copy" ]; then
cat -- $input_file | openssl enc -e -aes-128-cbc -nosalt -pass pass:"$passwd" | curl -H "Authorization: token $token" -X POST --data-binary @- https://oclip.hanxi.info/clip
else
curl -s -H "Authorization: token $token" https://oclip.hanxi.info/clip | openssl enc -d -aes-128-cbc -nosalt -pass pass:"$passwd"
fi
更新:
遇到 openssl 版本不一致的情况,导致生成的 key 和 iv 的结果不一致,于是改成手动采用 sha256 计算 key 和 iv。加密解密指令如下:
sha256code=$(echo -n "$passwd" | openssl sha256 -hex -r | cut -b -64)
key=$(echo -n "$sha256code" | cut -b -32)
iv=$(echo -n "$sha256code" | cut -b 33-)
if [ $oclip_type == "copy" ]; then
cat -- $input_file | openssl enc -e -aes-128-cbc -nosalt -K $key -iv $iv | curl -H "Authorization: token $token" -X POST --data-binary @- https://oclip.hanxi.info/clip
else
curl -s -H "Authorization: token $token" https://oclip.hanxi.info/clip | openssl enc -d -aes-128-cbc -nosalt -K $key -iv $iv
fi
再补充下服务器是如何同步最新的数据到客户端的。
每个账号最新记录的版本号,采用 openresty 的 shared.DICT 存在内存中。每次有数据变化后,把版本加一。
用心跳去检查各个客户端的数据版本号是否已过期,过期则更新版本号再推送数据。
-- update data version id
local clip_change = ngx.shared.clip_change
local vid = clip_change:incr(self.uid, 1, 0)
self.vid = vi
使用 shared.DICT 而不是使用 lua-resty-lru 的原因是:这个数据需要在各个工作进程中共享, 因为不同的客户端连接的可能不是同一个进程。
遇到 ssl.bio
发送很长数据的问题,send
时返回需要重试,还没想到比较好的解决办法,直接写了个死循环一直发送。这个得再想办法改下。
还要处理 Lua 读取配置文件的问题, 预计采用类似 ini 文件格式, 跟 Linux 系统一致, 放到用户目录下, 并命名为 .oclip
token=eyJhbG.eyJ.rWe...
passwd=passwd
读取每行配置,用 gmatch 提取 key 和 value
local line = f:read("l")
while line do
local k,v = line:gmatch("(%w+)[^=]*=%s*(.*)")()
if k and v then
config[k] = v
print(k, "=", v)
end
line = f:read("l")
end
Windows 客户端逻辑写完之后就是剩下打包的工作了,打算用 lua-static 来打包。 打包前还要处理下系统图标的打包工作。
luastatic 在 windows 下打包默认只支持 mingw, 可是我编译用的是 MSVC,主要遇到三个问题:
- nm.exe 读取 windows 里的 DLL 文件无效,改成
set NM="dumpbin /EXPORTS"
后解决 is_binary_library
需要加入后缀dll
的判断- 使用 cl.exe 生成的 dll 或者 lib 无法链接到生成的 exe 文件,可能是某些参数问题。最后试了一个复杂的方法,把所有相关的 obj 文件放在一起 link,最后生成了可以运行的单个 exe 文件。
最后又遇到了一个小问题 cacert.pem文件没有打包到 exe 文件,这个比较简单,写个 lua 函数把这个文件从字符串输出到临时文件就行。
local cacert_content = require 'oclip.cacert'
local fname = 'cacert.pem'
-- 判断文件是否存在
function _M.file_exists(name)
local f = io.open(name, 'r')
if f ~= nil then
io.close(f)
return true
else
return false
end
end
function _M.get()
if _M.file_exists(fname) then
return fname
end
fname = os.tmpname()
print('tmpfile: ', fname)
local f = io.open(fname, 'w+')
if f then
f:write(cacert_content)
f:close()
end
return fname
end
function _M.exit()
if fname ~= 'cacert.pem' then
os.remove(fname)
end
end
生成 src/cacert.lua 的脚本如下:
: gen src/cacert.lua
cd /d %cur_dir%\..\..\src
echo return [[ > cacert.lua
type cacert.pem >> cacert.lua
echo ]] >> cacert.lua
更新:
文本的编码问题,统一转成 utf8
编码问题进展: 采用下面两个函数对剪切板的内容转换,可以正常处理中文了,但是 emoji 处理不了。还得继续找找原因。
char *asciitoutf8(const char *lpstr)
{
int ulen = MultiByteToWideChar(CP_ACP, 0, lpstr, -1, NULL, 0);
int bytes = (ulen + 1) * sizeof(wchar_t);
wchar_t *utext = malloc(bytes);
MultiByteToWideChar(CP_ACP, 0, lpstr, -1, utext, ulen);
int sz = WideCharToMultiByte(CP_UTF8, 0, utext, -1, NULL, 0, NULL, NULL);
char *utf8text = malloc(sz);
WideCharToMultiByte(CP_UTF8, 0, utext, -1, utf8text, sz, NULL, NULL);
free(utext);
return utf8text;
}
char *utf8toascii(const char *text, int *sz)
{
int ulen = MultiByteToWideChar(CP_UTF8, 0, text, -1, NULL, 0);
int bytes = (ulen + 1) * sizeof(wchar_t);
wchar_t *utext = malloc(bytes);
MultiByteToWideChar(CP_UTF8, 0, text, -1, utext, ulen);
*sz = WideCharToMultiByte(CP_ACP, 0, utext, -1, NULL, 0, NULL, NULL);
char *asciitext = malloc(*sz);
WideCharToMultiByte(CP_ACP, 0, utext, -1, asciitext, *sz, NULL, NULL);
free(utext);
return asciitext;
}
更新:
编码问题终于解决了,我傻逼了,主要原因是剪切板内容读取和设置的格式采用了 ASCII 格式 CF_TEXT
. 所以还是相当于没有使用 define UNICODE
. 今天试过 iconv 来转都是一样的效果,最后找到这个才发现需要改为 CF_UNICODETEXT
.
https://github.com/ocornut/imgui/blob/f5243712ce9fb070370cdc847ccc1b8e655fb1ce/imgui.cpp#L9616-L9658
所以 utf8 和 utf16 互转的代码其实只要这样就行了
char * malloc_utf16_to_utf8(const char *utf16text)
{
int sz = WideCharToMultiByte(CP_UTF8, 0, utf16text, -1, NULL, 0, NULL, NULL);
char *utf8text = malloc(sz);
WideCharToMultiByte(CP_UTF8, 0, utf16text, -1, utf8text, sz, NULL, NULL);
return utf8text;
}
wchar_t * malloc_utf8_to_utf16(const char *utf8text)
{
int utf16len = MultiByteToWideChar(CP_UTF8, 0, utf8text, -1, NULL, 0);
int bytes = (utf16len + 1) * sizeof(wchar_t);
wchar_t *utf16text = malloc(bytes);
MultiByteToWideChar(CP_UTF8, 0, utf8text, -1, utf16text, utf16len);
return utf16text;
}
当然调用上面的函数后记得 free.
iconv 测试代码找的这个: https://gist.github.com/duedal/1221865
iconv windows 版本找的这个: https://github.com/kiyolee/libiconv-win-build
编码问题已经搞定. 客户端代码在这里 hanxi/oclip-client
打包成 exe 文件:
Windows 客户端打独立 exe 包的工作算完成一个段落了,用的 lua-static 来打包。还需要处理下系统图标的打包工作。
系统图标在 C 代码里实现了把系统图标导出到 icon.h 文件再编译,但是我想在 Lua 里在实现一次,这样修改图标不用再次编译 tray.dll。实现的方法是从 getlantern/systray 里面借鉴来的,先把 icon 文件打包成 16 进制的 bytes 放到源码里,然后在启动的时候加载就行,不过我的实现有点不一样,我把 bytes 存在了临时文件,然后在加载,直接加载直接没成功,得抽个时间把这个改成直接加载 bytes 更合适。
更正:
getlantern/systray 使用的方法也是先把 icon 写入文件,然后再加载的。 https://github.com/getlantern/systray/blob/master/systray_windows.go#L658-L674 并且写入的文件名是根据 bytes 计算 MD5 拼接的。
然后我就把 ltray 的文件名也改成不是固定的文件名了,用临时的文件名。程序结束时删除就行。
static void _set_default_icon(wchar_t *icon_path)
{
memset(icon_path, 0, MAX_PATH);
_wtmpnam(icon_path);
default_icon_path = icon_path;
FILE *fp = _wfopen(icon_path, L"wb");
if (fp)
{
fwrite(icon_bytes, sizeof((icon_bytes)[0]), sizeof(icon_bytes), fp);
fclose(fp);
}
}
static void _remove_default_icon_file()
{
if (default_icon_path != NULL)
{
_wremove(default_icon_path);
}
}
更新:
-
- 在 Lua 实现图标文件从内存中加载,实现过程同 C 里的步骤。 [已经实现]
-
- 开机启动 [已经实现]
创建快捷方式的方法打算采用 os.execute() 来执行 mklink 命令。 参考这里: https://stackoverflow.com/questions/30028709/how-do-i-create-a-shortcut-via-command-line-in-windows
mklink "%userprofile%\Start Menu\Programs\Startup\%~nx0" "%~f0"
创建快捷方式遇到个权限问题,win10 需要管理员权限才能 mklink. 于是网上找了个 VBS 脚本调用 UAC 执行。代码有点绕,但是可以实现功能就行。
function _M.set_auto_startup()
local create_dir_cmd = string.format('setlocal EnableExtensions & mkdir %q', startup_dir)
print('create_dir_cmd:', create_dir_cmd)
os.execute(create_dir_cmd)
local mklink_cmd = string.format('mklink %q %q', link_file_name, arg[0])
print('mklink_cmd:', mklink_cmd)
local fname = os.tmpname()..".bat"
local f = io.open(fname, 'w+')
-- Need use uac create link.
f:write(string.format('echo %s > "%%temp%%\\run.bat"\n', mklink_cmd))
f:write([[
>nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system"
if '%errorlevel%' NEQ '0' ( echo Requesting administrative privileges... goto UACPrompt) else ( goto gotAdmin )
:UACPrompt
echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\getadmin.vbs"
echo UAC.ShellExecute "%temp%\run.bat", "", "", "runas", 0 >> "%temp%\getadmin.vbs"
"%temp%\getadmin.vbs"
exit /B
:gotAdmin
"%temp%\run.bat"
]])
f:write(mklink_cmd)
f:close()
local cmd = string.format("call %q", fname)
print("cmd:", cmd)
os.execute(cmd)
os.remove(fname)
end
再更新:
既然已经用了 vbs 来创建快捷方式了,那干脆就不用 mklink 命令,所以也就不需要管理员权限了,直接改成下面的就可以了:
local userprofile = os.getenv('USERPROFILE')
print('userprofile:', userprofile)
local startup_dir =
string.format('%s\\AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup', userprofile)
local link_file_name = string.format("%s\\oclip.lnk", startup_dir)
function _M.is_auto_startup()
if _M.file_exists(link_file_name) then
return true
end
return false
end
function _M.set_auto_startup()
local vbs_str = string.format([[
set WshShell=WScript.CreateObject("WScript.Shell")
set oShellLink=WshShell.CreateShortcut("%s")
oShellLink.TargetPath="%s"
oShellLink.WindowStyle=1
oShellLink.Description="oclip shortcut"
oShellLink.Save
]], link_file_name, arg[0])
local fname = os.tmpname()..".vbs"
local f = io.open(fname, "w+")
f:write(vbs_str)
f:close()
print(vbs_str)
local cmd = string.format("call %q", fname)
print("cmd:", cmd)
os.execute(cmd)
os.remove(fname)
end
接着又处理了下 exe 的图标问题,修改 makefile 即可。生成 icon.rc,再用 rc.exe 生成 icon.res。最后 link 的时候加上 icon.res 就行了。
tmp\icon.rc:
echo IDI_ICON1 ICON DISCARDABLE "icon.ico" > tmp\icon.rc
tmp\icon.ico:
copy ..\..\src\icon.ico tmp\icon.ico
tmp\icon.res: tmp\icon.rc tmp\icon.ico
rc.exe /l 0x404 /Fo"tmp\icon.res" tmp\icon.rc
处理了下打开配置文件的菜单,直接调用 notepad.exe 来编辑。
function _M.open_config()
local fpath = cfg.get_config_file_path()
local cmd = string.format("call notepad.exe %s", fpath)
os.execute(cmd)
end
还加了个重启菜单,应该不用重启,等补上断线重连的逻辑时,走重连就行。
更新08/23:
今天把经常断线的问题找到了, 粘贴板有新数据的回调函数不能直接把数据发出去,需要缓存起来,让主线程发送。
local function on_cliboard_change(text, from)
print('on_cliboard_change', from, #text)
-- do not send text in here. need in main thread
if not from and handler then
_send_text = text
end
end
local function clipboard_init()
clipboard.init(on_cliboard_change)
end
断线重连也搞定了,起一个任务循环判断网络状态,断线则走连接逻辑,连接失败则等 5 秒。
更新 08/31
- 处理 CRLF 行尾字符的问题
Windows end of line sequence: \r\n
Unix end of line sequence: \n
Mac end of line sequence: \r
现在只支持从 '\r\n' 转 '\n' 和从 '\n' 转 '\r\n' . 单纯的 '\r' 不好转。
if crlf == 'lf' then
text = text:gsub('\r', '')
elseif crlf == 'crlf' then
text = text:gsub('\r', '')
text = text:gsub('\n', '\r\n')
end
- 打个 windows 包
在这里下载:
https://github.com/hanxi/oclip-client/releases/tag/v0.0.1
更新0903:
- 重写 Linux 客户端
Linux 上的客户端打算做成一个包但是分为客户端和服务端,服务端常驻后台跟远程服务器保持连接来更新本地剪切版数据,客户端 copy 数据时则先把数据发送到本地服务端, 本地服务端再推送到远程服务器a,远程服务器 paste 数据回来时则写到本地文件,同时写到 xclip或者xsel。本地客户端和服务端的通信打算采用 unix domain 或者信号。 本地客户端需要拿数据时只需要是本地临时文件取即可。经过几次尝试,最后还是采用了 UDP 的通信方式,使用 lua-signal 出现了一直发送信号的问题没能解决,而且使用 UDP 不需要额外引入第三方库。
目前已经实现完 Linux 客户端逻辑,剩下打一个 Linux 包的事情。新的 Linux 端速度真的很快了,因为只有第一次使用的时候才启动 master 会发起HTTPS连接请求,后续操作都是通过 websocket 发送数据。
更新0905:
可以去这里下载最新的版本了:
https://github.com/oclip/oclip-client/releases
今天解决了 Windows 设置快捷方式的时候出现黑窗口的问题。
用下面的函数替换 system 函数:
#define MAX_SYSTEM_PROGRAM (4096)
static int windows_system(const wchar_t *cmd)
{
PROCESS_INFORMATION p_info;
STARTUPINFO s_info;
DWORD ReturnValue;
memset(&s_info, 0, sizeof(s_info));
memset(&p_info, 0, sizeof(p_info));
s_info.cb = sizeof(s_info);
wchar_t utf16cmd[MAX_SYSTEM_PROGRAM] = {0};
MultiByteToWideChar(CP_UTF8, 0, cmd, -1, utf16cmd, MAX_SYSTEM_PROGRAM);
if (CreateProcessW(NULL, utf16cmd, NULL, NULL, 0, 0, NULL, NULL, &s_info, &p_info))
{
WaitForSingleObject(p_info.hProcess, INFINITE);
GetExitCodeProcess(p_info.hProcess, &ReturnValue);
CloseHandle(p_info.hProcess);
CloseHandle(p_info.hThread);
}
return ReturnValue;
}
static int os_execute(lua_State *L)
{
const char *cmd = luaL_optstring(L, 1, NULL);
int stat = windows_system(cmd);
if (cmd != NULL)
{
return luaL_execresult(L, stat);
}
else
{
lua_pushboolean(L, stat); /* true if there is a shell */
return 1;
}
}
// 替换 os.execute
static void patch_os_system(L)
{
lua_getglobal(L, "os");
lua_pushcfunction(L, os_execute);
lua_setfield(L, -2, "execute");
lua_pop(L, 1);
}
更新完毕。