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()