越狱 Kindle 装上 KOReader 之后,体验上最怀念的其实是亚马逊的 Send to Kindle:手机上看到一本书,点一下分享,过一会儿它就躺在 Kindle 里了。KOReader 本身没有这个能力,而且书、笔记、进度也只存在本机,换台设备就全没了。
折腾了几天,最后搭出了这样一套东西:
- Send to KOReader:手机上分享电子书,一两分钟后 KOReader 自动下载好并弹出"已收到新书"
- 云图书馆:所有书放在自己的 WebDAV 网盘里,Kindle 上可以浏览整个云端书库,按需下载
- 进度和笔记同步:高亮、笔记、阅读进度在多台设备之间同步,按"页面最后一个词"对齐,不同字号也能跳到同一位置
整体结构:
手机 ──分享──▶ Send to KOReader ──PUT──▶ WebDAV/书库 ◀──扫描/下载── RemoteLibrary + 自动刷新补丁
WebDAV/同步 ◀──推送/拉取── AnnotationSync
(都在 Kindle 上的 KOReader 里)
手机端 App 和 KOReader 补丁我开源在 Tsukumi233/send-to-koreader(MIT)。下面把完整配置流程和踩过的坑都记下来。
准备
| 组件 | 作用 | 备注 |
|---|---|---|
| WebDAV 服务器 | 存书和同步数据 | 我用 OpenList(AList 的分支)挂百度网盘;Nginx、Apache 等也行,必须支持 PROPFIND Depth: infinity |
| 越狱 Kindle + KOReader | 阅读端 | 进度同步需要 开发版(nightly) KOReader,见下文 |
| RemoteLibrary.koplugin | 把云端文件夹映射成书库 | v0.2.0,有两个 bug 要手动修 |
| AnnotationSync.koplugin | 笔记 / 进度同步 | v2.0.0,有一个翻页卡顿的 bug 要手动修 |
| Send to KOReader | 手机分享上传 + KOReader 自动刷新补丁 | Android App / iOS 快捷指令 |
服务器端
在 WebDAV 上建两个文件夹,书和同步数据分开放:
/dav/xxx/Kindle书库/ ← 书,RemoteLibrary 和手机上传都指向这里
/dav/xxx/Kindle同步/ ← AnnotationSync 的同步文件
建议单独开一个账号给 Kindle 和手机用,给它上传(PUT)和建目录(MKCOL)权限就够了,不需要删除权限。
KOReader 端
配置 Cloud storage+
开发版 KOReader 自带 Cloud storage+ 插件,统一管理云端账号。在 工具 → Cloud storage+ 里添加你的 WebDAV 服务器,后面两个插件都从这里选服务器。
AnnotationSync 的进度同步依赖 Cloud storage+,稳定版 KOReader 里这些选项全是灰的,只能同步笔记。想要进度同步就得用 nightly。
安装 RemoteLibrary
- 把
RemoteLibrary.koplugin复制到koreader/plugins/。 - 先修两个 bug(见下一节),再重启 KOReader。
- 在 Remote Library 设置里选上面添加的服务器,云端文件夹选
Kindle书库/,本地文件夹随意,我映射到了/mnt/us/documents/Books。
之后在文件管理器里就能看到云端的所有书,没下载的书显示为"仅云端",点开会下载。
RemoteLibrary 的两个 bug
这两个我都提了 PR,写这篇文章时还没合并,每次更新插件后都要重新修。
bug 1:provider.lua 和 KOReader 核心模块重名
插件目录里有一个 provider.lua,Lua 的 require("provider") 会被它劫持,导致 KOReader 自带的导出等插件报错:
attempt to call method 'size'
修法:把它改名为 remotelibrary_provider.lua,再把 main.lua 里的 require("provider") 改成新名字。见 PR #5。
bug 2:WebDAV 地址带路径时手动下载 404
如果服务器地址是 https://example.com/dav 这种带路径的形式,快速扫描存下来的是服务器绝对路径(dav/xxx/…),下载时又拼了一遍地址,请求变成 /dav/dav/…,结果 404。修法是在 scanner.lua 里把地址里的路径部分剥掉,见 PR #7。
自动刷新补丁
RemoteLibrary 本身只会在你手动刷新时扫描云端,新书不会自动下载。这就是 Send to KOReader 体验的关键缺口,所以我写了一个用户补丁 2-remotelibrary-autorefresh.lua:
- 从仓库的
koreader/目录下载补丁。 - 放到
/mnt/us/koreader/patches/,重启 KOReader。
补丁行为:
- 设备醒着且在线时每 2 分钟扫描一次;Wi-Fi 连上、从休眠唤醒、刚启动后也各扫一次;两次扫描至少间隔 60 秒。
- 扫描和下载都在子进程里跑,不卡界面。
- 只自动下载上次扫描之后新出现的书(单本上限 300 MB)。云端原有的书保持"仅云端",不会第一次扫描就把整个书库拖下来。
- 下载完成后弹通知"已收到新书"。
补丁文件名的前缀必须是 2-。1- 前缀的补丁加载得太早,那时 G_reader_settings 还不存在。另外补丁是 hook PluginLoader:createPluginInstance 来拿 RemoteLibrary 实例的,这里的 plugin.name 是文件夹名 RemoteLibrary,不是类名。
安装 AnnotationSync
- 把插件复制到
koreader/plugins/,文件夹名必须是AnnotationSync.koplugin。 - 先打翻页卡顿的补丁(见下一节),再重启 KOReader。
- 工具 → Annotation Sync → 设置 → 云设置,选 Cloud storage+ 里的服务器,文件夹选
Kindle同步/。 - 我用的设置:
- 给每台设备起一个好认的名字(比如
Kindle PW6),同步菜单里好区分。 - 同步文件名用默认的路径 MD5 哈希。如果书是 Calibre 管理的,可以改用真实文件名。
- 打开进度同步,并打开按页面最后一个词对齐。不同屏幕、字号、边距下比按页码准得多。
- 给每台设备起一个好认的名字(比如
AnnotationSync 的翻页卡顿
这是整套方案里最难查的一个问题。
症状
开了进度同步之后,翻页有时立刻响应,有时要等 1~2 秒,非常随机。一开始以为是 Kindle 性能问题,但看了一下负载只有 0.17,内存也很空。翻日志才发现每次进度同步都是这两行:
AnnotationSync: starting progress sync
AnnotationSync: background progress sync failed/unsupported, falling back to in-process sync
后台同步每一次都失败了,然后退回到主线程同步。
原因
插件的设计是在子进程里推送进度,但装了 Cloud storage+ 之后,Cloud:sync 并不在当前调用里同步完成,而是把下载、合并、上传整个包进 UIManager:nextTick:
function Cloud:sync(server, file_path, sync_cb, is_silent, caller_pre_callback)
-- ...
provider.run(function()
UIManager:nextTick(function()
-- download, sync_cb, upload
end)
end)
end
fork 出来的子进程没有 UI 事件循环,这个 tick 永远不会执行,回调不会被调用,子进程返回失败,插件就退回主线程推送:一次 GET 加一次 PUT。我在 Kindle 上用 KOReader 自己的 WebDavApi 测了一下:
GET 200 1.25s PUT 201 1.02s total 2.28s
GET 200 0.82s PUT 201 1.33s total 2.15s
GET 200 0.92s PUT 201 1.43s total 2.35s
每次推送卡住界面 2 秒多。推送是在最后一次翻页 3 秒后触发的,正好落在读者要翻下一页的时候,所以体感就是"随机卡顿"。
另外还有个小问题:原实现在等子进程时用 TrapWidget 盖住阅读界面,这时点一下翻页会把正在进行的推送取消掉,正常阅读节奏下推送经常被打断。
修复
- 子进程里改用 KOReader 核心的
SyncService.sync。它是同步执行的,支持 WebDAV 和 Dropbox,.temp/.sync握手协议和Cloud:sync相同。 - 用
ffiutil.runInSubProcessfork,主进程用UIManager:scheduleIn每 0.25 秒轮询一次结果,60 秒超时就杀掉子进程。不再遮挡界面,翻页也不会取消推送。 - 只有遇到
SyncService不支持的后端(比如通过 Cloud storage+ 配的 FTP)才退回主线程推送,这和原来这些情况下的实际行为一致。
修完后推送进行中翻页,每页 0.3~0.4 秒,和没有同步时一样,也没有僵尸进程。
问题报告见 #107,修复是 PR #108,同样还没合并。在合并之前可以从我的 fork Tsukumi233/AnnotationSync.koplugin 的 fix/progress-sync-nonblocking 分支拿 remote.lua,覆盖插件里的同名文件。如果不想动代码,临时办法是把"每 N 页同步一次"调大,或者关掉进度同步。
子进程继承端口
上面两个地方都用了子进程,修完卡顿之后又冒出一个新问题:KOReader 的 HTTP Inspector 在从文件管理器切到阅读界面时报
Failed to start server: address already in use
原因是 fork 出来的子进程会继承父进程所有的文件描述符,包括 HTTP Inspector 监听 8080 端口的 socket。父进程关掉了自己那一份,但只要子进程还活着,端口就还被占着,父进程重新绑定就失败了。我抓到过自动刷新补丁的扫描子进程就占着这个端口。
修法是在子进程一开始,从 /proc/net/tcp 和 /proc/net/tcp6 里找出处于 LISTEN 状态的 socket,把继承来的对应 fd 用 dup2 指向 /dev/null。用 dup2 而不是 close,是为了让这个 fd 编号继续被占着,防止子进程里旧的 LuaSocket 对象被垃圾回收时误关一个已经被复用的 fd。其他继承来的 socket(比如 lipc 的)不动。
PR #108 和自动刷新补丁里都已经加了这一步。凡是在 KOReader 里 fork 子进程的代码,都应该做这个处理。
手机端
Android
- 从 Releases 下载 APK 安装(Android 10+,约 25 KB,无第三方依赖)。
- 打开 App,填写 WebDAV 文件夹的完整地址(比如
https://example.com/dav/xxx/Kindle书库)、用户名、密码,点"保存并测试"。文件夹不存在时会提示创建。 - 之后在任意 App 里分享电子书,选"发送到 KOReader"。微信、QQ 里用"用其他应用打开"。支持多文件、进度显示、失败重试。
支持 epub、pdf、mobi/azw、fb2、cbz/cbr、djvu、txt、docx 等格式。凭据只存在 App 的私有存储里,并关闭了系统备份。
Android 的 HttpURLConnection 不支持 PROPFIND 和 MKCOL 这类 WebDAV 方法,所以"测试连接"是靠上传一个探测文件再删掉来实现的。如果账号没有删除权限,云端会留下一个 .send-to-koreader-test,忽略即可。
iOS
iOS 不需要装 App,用快捷指令就能实现,同样出现在分享菜单里。核心就是对每个分享进来的文件发一个 PUT:
从 共享表单 接收 文件
文本 [文件夹地址]
文本 [用户名:密码]
Base64 编码 [文本](换行:无)
对 [快捷指令输入] 中的每个项目重复
文本 [重复项目.名称].[重复项目.文件扩展名]
URL 编码 [文本]
文本 [文件夹]/[URL 编码后的文本]
获取 URL 内容 PUT Authorization: Basic [Base64 编码结果] 请求体: 重复项目
结束重复
显示通知 "已发送到 KOReader"几个容易出错的地方:
- Base64 编码一定要把换行设成无,否则长密码会被折行,认证失败。
- 地址里有中文时先转成百分号编码。
- 快捷指令遇到 401、404 不一定报错,第一次请去云盘确认文件真的上传了。
- 文件夹不存在时不会自动创建,要先在云盘里建好。
逐步搭建的详细说明见仓库的 docs/ios-shortcut.md。
调试技巧和其他坑
别用 kill 杀 KOReader
改完插件想远程重启 KOReader,最直觉的做法是 SSH 上去 kill 掉再启动。千万别这么做:
- Kindle 上端口 2222 的 SSH 是 KOReader 自己的 SSH 插件(dropbear)起的。杀掉 KOReader,SSH 会话跟着断,后面的重启命令根本不会执行,Kindle 掉回亚马逊原生界面,还可能断网。
- SIGTERM 不会让 KOReader 保存设置,内存里没写盘的改动全丢。我就因此丢过一次刚改好的手势设置。
正确做法是在 KOReader 菜单里重启,或者开启 HTTP Inspector 插件后:
curl --noproxy "*" http://<kindle-ip>:8080/koreader/event/Restart
这样会正常保存设置,5 秒左右就回来了。如果要直接改 settings.reader.lua 之类的文件,要在 KOReader 停止运行时改,否则它下次保存设置时会把你的修改覆盖掉。
HTTP Inspector 的注意事项
- 电脑开了代理时 curl 要加
--noproxy "*",否则代理会返回 502。开了 TUN 模式时端口探测永远显示"开放",判断 Kindle 在不在线要用ping。 - 不要随便浏览
/koreader/ui/menu/这类 UI 对象,我遇到过 HTTP Inspector 抛异常后 KOReader 直接崩回亚马逊界面。 - 不要远程触发
/koreader/event/AnnotationSyncSyncAllIncludingUnread(全部同步)。它会弹一个确认框,在有人在设备上点掉之前,后续所有 HTTP 事件(包括Restart)都会被挡住。
Windows 上 SSH 连不上 Kindle
Git Bash 自带的 OpenSSH 可能读不了某些格式的私钥,报 invalid format。换用 Windows 自带的 C:\Windows\System32\OpenSSH\ssh.exe 就可以。
给插件写测试
如果你也打算给这些插件提 PR:
- 插件测试要在 KOReader 的 CI 镜像里跑(
koreader/kobase:0.5.3-22.04,用./run_tests.sh)。我第一次没在本地跑就推了,CI 直接挂。 ffiutil.sleep只接受整数秒,sleep(0.3)一点都不等,亚秒级等待用ffiutil.usleep。- 单元测试里
fastforward_ui_events()会因为没有显示中的 widget 而调用UIManager:quit(),把任务队列清空。需要轮询的测试要自己捕获scheduleIn,然后手动执行轮询。
维护清单
在上游合并 PR 之前,每次更新插件后检查一遍:
| 插件 | 要重新打的补丁 | 上游 PR |
|---|---|---|
| RemoteLibrary | provider.lua 改名 | #5 |
| RemoteLibrary | scanner.lua 路径修复 | #7 |
| AnnotationSync | remote.lua 非阻塞推送 + 释放继承的监听 socket | #108 |
自动刷新补丁放在 patches/ 里,不受插件更新影响。
搭完之后的日常就是:手机上刷到一本书,分享,等一两分钟,Kindle 上"已收到新书"。换一台装了 KOReader 的设备打开同一本书,进度和高亮都在。总算找回了当年用 Send to Kindle 的感觉。
気に入ったならばコメントを残してくださいね~