我是如何制作一个远程剪切板的【流水账】

服务器选用了 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 的时候,遇到了两个问题:

然后我都提了 issues 并修复了。

zhaozg/lua-openssl#179

zhaozg/lua-openssl#180

接下来就是遇到 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,主要遇到三个问题:

最后又遇到了一个小问题 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);
    }
}

更新:

创建快捷方式的方法打算采用 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

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

在这里下载:

https://github.com/hanxi/oclip-client/releases/tag/v0.0.1

更新0903:

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 设置快捷方式的时候出现黑窗口的问题。

https://stackoverflow.com/questions/12554237/hiding-command-prompt-called-by-system/57798301#57798301

用下面的函数替换 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);
}

更新完毕。

点击进入评论 ...