Xteink X4 Pro 刷 CrossPoint 的折騰紀錄
想把我的 Xteink X4 Pro 刷成 CrossPoint 1.6.5,本來以為找個教學,照著做應該就好了,結果從刷不進去,到刷進去卻沒辦法好好看中文書,又多處理了好幾個問題。
連上熱點還是刷不進去
我這台是中國版,原本的系統是 XTOS V7.6.11,這次用的工具叫 Xteink Unlocker。大概的做法是讓電腦開 Wi-Fi 熱點,閱讀器連上去,再從原廠系統裡按檢查更新,工具就會把 CrossPoint 韌體送過去。
這裡還有個語系問題,我使用的系統是繁體中文的 Windows,Unlocker 會卡在熱點那個步驟。後來先到 Windows 的系統設定,把顯示語言改成英文,Unlocker 才能繼續往下走。如果也卡在同一個地方,可以先確認這個設定。
聽起來沒有很難,實際上我連上熱點後,卻一直四處碰壁,連線看起來也不是完全沒通,ping 得到,該開的程式也有在跑,但它就是不繼續。後來連 MacBook 都拿出來試了,還是失敗,換台電腦也沒有比較省事耶。
後來找到 CrossPoint 工具的 Issue #51,才慢慢弄清楚這次卡在哪裡。我的閱讀器查詢更新伺服器時,會直接去問寫死的 DNS,也就是 223.5.5.5,沒有照著熱點提供的方式走,所以最後還是找到原廠伺服器,Unlocker 當然就一直等不到它。
後面也試過用虛擬網卡轉送 DNS 的方法,但在我這台電腦上,查詢根本沒進來,這條路也沒成功。
最後有效的方式,是回到 Windows,用 Unlocker 0.2.42,另外搭配一支 Python 程式 divert_dns.py。這支程式是我請 Claude Code 去參考 Issue 裡的 WinDivert 做法寫出來的,先把閱讀器的 DNS 查詢攔下來,告訴它更新伺服器就在電腦這邊,接下來 Unlocker 才終於收到更新請求。
凌晨四點刷好了 中文書卻看不了
一路折騰到凌晨四點,總算刷進去了,開機看到 CrossPoint 的英文介面,原本以為終於可以開本書來看了。
結果中文書根本開不起來,想說應該是缺字型,把中文 TTF 加進去後,又卡到幾乎沒辦法用。
我看的是 EPUB,開書慢、翻頁慢,連介面操作都受到影響,已經不是電子紙本來就比較慢的那種程度。折騰半天刷好了,卻變成沒辦法正常看書,實在有點不知道自己在忙什麼。
但都已經凌晨四點了,也沒力氣繼續查,只好先放著,明天再戰。
隔天再戰 先把中文字型轉好
隔天醒來繼續研究,睡醒之後腦袋清楚多了,果然不該熬夜處理這些東西。
最後解決卡頓的方法,是把中文字型先轉成 .cpfont。
原本以為 X4 Pro 能直接放 TTF 字型,就不用再特別轉檔了。但後來對照原始碼,推測這次的負擔,是閱讀器要一邊讀中文字型,一邊即時計算字形。中文書一頁這麼多字,這段工作就變得很吃力。
.cpfont 的做法,是先在電腦上把各個字級的字形算好,再讓閱讀器讀取。多做了一次轉檔,反而讓閱讀器少做很多事。
我用的是霞鶩文楷,轉好後把整個字型資料夾放進 SD 卡的 fonts,再到設定裡選它。原本的 TTF 也先移走,免得兩個名字很像,又選回原本那個。
換成轉好的字型後,中文書的開啟和翻頁就有改善了,這次才算真的處理到可以往下用
這次用的 DNS 攔截程式
這次用的 divert_dns.py 也放在下面,有需要可以展開看,或直接下載。
先讓 Unlocker 開好熱點、閱讀器連上去,再以系統管理員身分執行 python divert_dns.py,最後才在閱讀器按檢查更新。
我這次電腦熱點的位址是 192.168.137.1,不保證每台電腦都一樣,依真實環境修改。
WinDivert 則使用 Unlocker 安裝目錄裡的檔案,安裝位置不同的話也要確認一下。
divert_dns.py(點此展開/收合)
# WinDivert DNS responder for XTOS readers that hard-code 223.5.5.5 (crosspoint-tools#51).
# Answers A queries for the Xteink update hosts from hotspot clients with the Unlocker's address,
# passes every other packet through untouched. Run as Administrator; Ctrl+C to stop.
import ctypes
import os
import random
import struct
import sys
import threading
import time
from ctypes import wintypes
DLL = r"C:\Program Files\Xteink Unlocker\WinDivert.dll"
SPOOF_HOSTS = {"api-prod.xteink.cn", "api-prod.xteink.cc"}
SPOOF_IP = "192.168.137.1"
FILTER = (b"ip and udp.DstPort == 53 and ip.SrcAddr >= 192.168.137.2"
b" and ip.SrcAddr <= 192.168.137.254")
LAYER_NETWORK, LAYER_NETWORK_FORWARD = 0, 1
FLAG_SEND_ONLY = 0x0008
ADDR_SIZE = 80 # sizeof(WINDIVERT_ADDRESS) in WinDivert 2.x
OUTBOUND_BIT = 1 << 17
INVALID_HANDLE = ctypes.c_void_p(-1).value
wd = ctypes.WinDLL(DLL, use_last_error=True)
wd.WinDivertOpen.argtypes = [ctypes.c_char_p, ctypes.c_int, ctypes.c_int16, ctypes.c_uint64]
wd.WinDivertOpen.restype = ctypes.c_void_p
wd.WinDivertRecv.argtypes = [ctypes.c_void_p, ctypes.c_void_p, wintypes.UINT,
ctypes.POINTER(wintypes.UINT), ctypes.c_void_p]
wd.WinDivertRecv.restype = wintypes.BOOL
wd.WinDivertSend.argtypes = [ctypes.c_void_p, ctypes.c_void_p, wintypes.UINT,
ctypes.POINTER(wintypes.UINT), ctypes.c_void_p]
wd.WinDivertSend.restype = wintypes.BOOL
wd.WinDivertHelperCalcChecksums.argtypes = [ctypes.c_void_p, wintypes.UINT, ctypes.c_void_p, ctypes.c_uint64]
wd.WinDivertHelperCalcChecksums.restype = wintypes.BOOL
def open_handle(layer, filt, flags=0):
h = wd.WinDivertOpen(filt, layer, 0, flags)
if h is None or h == INVALID_HANDLE:
raise ctypes.WinError(ctypes.get_last_error())
return h
def log(msg):
print(time.strftime("%H:%M:%S"), msg, flush=True)
def parse_query(pkt):
"""Return (ihl, qname, qtype, question_end) for an IPv4/UDP DNS query, or None."""
ihl = (pkt[0] & 0x0F) * 4
dns = ihl + 8
if len(pkt) < dns + 12 or pkt[dns + 2] & 0x80: # too short, or a response
return None
labels, i = [], dns + 12
while i < len(pkt) and pkt[i]:
if pkt[i] & 0xC0:
return None
labels.append(pkt[i + 1:i + 1 + pkt[i]].decode(errors="replace"))
i += 1 + pkt[i]
if i + 5 > len(pkt):
return None
qtype = struct.unpack_from("!H", pkt, i + 1)[0]
return ihl, ".".join(labels).lower(), qtype, i + 5
def build_reply(pkt, ihl, qtype, qend):
dns = ihl + 8
qid, qflags = struct.unpack_from("!HH", pkt, dns)
question = pkt[dns + 12:qend]
answers = b""
if qtype == 1: # A -> Unlocker; anything else (AAAA, HTTPS...) -> empty NOERROR
answers = struct.pack("!HHHIH", 0xC00C, 1, 1, 60, 4) + bytes(map(int, SPOOF_IP.split(".")))
flags = 0x8080 | (qflags & 0x0100) # QR + RA, copy RD
payload = struct.pack("!HHHHHH", qid, flags, 1, 1 if answers else 0, 0, 0) + question + answers
src_ip, dst_ip = pkt[12:16], pkt[16:20]
sport, = struct.unpack_from("!H", pkt, ihl)
udp = struct.pack("!HHHH", 53, sport, 8 + len(payload), 0) + payload
ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 20 + len(udp), random.randrange(65536), 0,
64, 17, 0, dst_ip, src_ip)
return ip + udp
def worker(name, handle, sender):
buf = ctypes.create_string_buffer(65535)
addr = ctypes.create_string_buffer(ADDR_SIZE)
n = wintypes.UINT()
while True:
if not wd.WinDivertRecv(handle, buf, len(buf), ctypes.byref(n), addr):
log(f"[{name}] recv error {ctypes.get_last_error()}")
continue
pkt = buf.raw[:n.value]
q = parse_query(pkt)
src = ".".join(map(str, pkt[12:16]))
dst = ".".join(map(str, pkt[16:20]))
if q and q[1] in SPOOF_HOSTS:
ihl, qname, qtype, qend = q
reply = ctypes.create_string_buffer(build_reply(pkt, ihl, qtype, qend))
rlen = len(reply) - 1 # drop create_string_buffer's trailing NUL
raddr = ctypes.create_string_buffer(ADDR_SIZE)
bits = LAYER_NETWORK | OUTBOUND_BIT
struct.pack_into("<I", raddr, 8, bits)
wd.WinDivertHelperCalcChecksums(reply, rlen, raddr, 0)
ok = wd.WinDivertSend(sender, reply, rlen, None, raddr)
log(f"[{name}] SPOOF {src} -> {dst} {qname} type {qtype} -> {SPOOF_IP}"
+ ("" if ok else f" (send error {ctypes.get_last_error()})"))
else:
wd.WinDivertSend(handle, buf, n.value, None, addr)
log(f"[{name}] pass {src} -> {dst} {q[1] if q else '?'}")
def main():
if not ctypes.windll.shell32.IsUserAnAdmin():
sys.exit("Run as Administrator.")
if not os.path.exists(DLL):
sys.exit(f"{DLL} not found.")
sender = open_handle(LAYER_NETWORK, b"false", FLAG_SEND_ONLY)
for name, layer in (("network", LAYER_NETWORK), ("forward", LAYER_NETWORK_FORWARD)):
h = open_handle(layer, FILTER)
threading.Thread(target=worker, args=(name, h, sender), daemon=True).start()
log(f"diverting DNS from hotspot clients; spoofing {', '.join(sorted(SPOOF_HOSTS))} -> {SPOOF_IP}")
log("press 'Check for Updates' on the reader now (Ctrl+C to stop)")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
log("stopped")
if __name__ == "__main__":
main()