Skip to content

bridge: 复制 server config 时输出单行 JSON(Android 端粘贴报「非法 JSON」) - #3

Open
std-external wants to merge 1 commit into
std-microblock:mainfrom
std-external:fix/server-config-single-line
Open

std-external wants to merge 1 commit into
std-microblock:mainfrom
std-external:fix/server-config-single-line

Conversation

@std-external

@std-external std-external commented Oct 9, 2026 •

Copy link
Copy Markdown

问题

账号列表页(Platform accounts)的 Copy server configuration 按钮把
JSON.stringify(serverConfig, null, 2) 直接写进剪贴板,复制出来是 26 行、带缩进的 pretty JSON。
在 Android 端把它粘贴进客户端,会被判为非法 JSON;把换行全部删掉变成一行后就能正常导入。

报告来源:QQ 群「Koishi On Topic」的群管理员。复现步骤:打开 /platform-accounts →
点「Copy server configuration」→ 粘贴到 Android 客户端。

根因:解析方在我们自己的客户端,但解析器本身不挑换行

先确认了各环节谁在解析:

环节 谁解析 实现
crossgram 服务端(本仓库) 不解析 只生成文本:makeCrossGramServerConfig(packages/bridge/src/account-dashboard.ts)、WebUI 复制、deploy/generate-client-config.mjs
crossgram-android Crossgram 自己的代码 ServerSwitchConfig.Server.parseUserInput → new JSONObject(value)(Android org.json)
crossgram-telegram-x Crossgram 自己的代码 CrossgramServerConfiguration.parse → new JSONObject(json)
crossgram-mithka Crossgram 自己的代码 CrossgramServerConfiguration.parse → jsonDecode
打过补丁的 TDLib Crossgram 自己的代码 CrossgramServerConfig::parse → json_decode(Parser::skip_whitespaces() 是 skip_till_not(" \t\r\n"))

也就是说:服务端只是产出文本,解析全在 Crossgram 自己的客户端里,本来不存在「第三方客户端我们改不了」的问题。

但实测表明这些解析器本来就吃换行:把 crossgram-android 里那份真实的 ServerSwitchConfig.java
(未改动)配 Android libcore 的 org.json 源码编译起来,直接调 Server.parseUserInput:

real crossgram-android parseUserInput(pretty 26-line JSON) -> OK name=CrossGram host=203.0.113.10 port=4430 special=false dcs=5 rsaKeyRealNewlines=true
real crossgram-android parseUserInput(compact 1-line JSON) -> OK ...
real crossgram-android parseUserInput(pretty + CRLF)        -> OK ...
real crossgram-android parseUserInput(pretty + BOM)         -> OK ...
real crossgram-android parseUserInput(pretty + trailing \n) -> OK ...
real crossgram-android parseUserInput(indent replaced by NBSP) -> JSONException: Unterminated array at character 260

复现脚本与全部源码桩在 reports/crossgram-62-android-parse-probe/(公钥用占位符)。

结论分两层:

  1. 「多行必然非法 JSON」不成立——换行、CRLF、BOM、尾随换行都能过;客户端那侧没有「把换行吃掉」这个 bug,
    所以不需要(也不应该)为它改解析器,改了是空转。
  2. 真正会坏的是文本进入解析器之前的那段搬运:粘贴链路把文本截断,或把空白改写(实测唯一能复现的失败变形是
    缩进空格被换成 U+00A0/NBSP 这类非 JSON 空白)。报告人「回车全部删掉就正常了」正说明坏点在换行/空白,
    而不在字段内容。

因此修在产出侧:剪贴板只写紧凑单行 JSON——它既没有换行、也没有缩进空白,任何「按行读 / 单行输入框 /
空白归一化」的搬运都不会再破坏它,而字段名、字段顺序、值(含 PEM 的 \n 转义)与可读版本逐字等价。
页面上的展示(<details><pre>)仍保持格式化排版,没有改展示层。

改动

  • packages/bridge/client/bridge-model.ts:新增两个纯函数
    • serializeServerConfig(config) → JSON.stringify(config):剪贴板用,单行
    • formatServerConfig(config) → JSON.stringify(config, null, 2):页面展示用,行为不变
  • packages/bridge/client/accounts.tsx:复制按钮改用 serializeServerConfig();<pre> 展示改用
    formatServerConfig(),排版未动
  • packages/bridge/client/bridge-model.test.ts:3 个新用例(单行、无首尾空白、round-trip 深相等、
    PEM 转义、展示仍是多行、undefined 处理)
  • packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts:手机视口下真实剪贴板断言,
    并覆盖手机实际走的那条回退路径(明文 http 下没有 navigator.clipboard,走隐藏 textarea +
    execCommand('copy')),确认两条路径写出的都是同一份单行文本

复制文本对照(host/公钥均为占位符)

改动前(594 字符、26 行):

{
  "name": "CrossGram",
  "enable_special_config": false,
  "host": "203.0.113.10",
  "port": 4430,
  "rsa_key": "-----BEGIN RSA PUBLIC KEY-----\n<PLACEHOLDER_PUBLIC_KEY>\n-----END RSA PUBLIC KEY-----",
  "dcs": [
    { "id": 1, "ip": "203.0.113.10", "port": 4430 },
    { "id": 2, "ip": "203.0.113.10", "port": 4430 },
    { "id": 3, "ip": "203.0.113.10", "port": 4430 },
    { "id": 4, "ip": "203.0.113.10", "port": 4430 },
    { "id": 5, "ip": "203.0.113.10", "port": 4430 }
  ]
}

(上面 dcs 为了可读性写成一行;实际是 5 个各占 5 行的对象,共 26 行。)

改动后(396 字符、0 换行):

{"name":"CrossGram","enable_special_config":false,"host":"203.0.113.10","port":4430,"rsa_key":"-----BEGIN RSA PUBLIC KEY-----\n<PLACEHOLDER_PUBLIC_KEY>\n-----END RSA PUBLIC KEY-----","dcs":[{"id":1,"ip":"203.0.113.10","port":4430},{"id":2,"ip":"203.0.113.10","port":4430},{"id":3,"ip":"203.0.113.10","port":4430},{"id":4,"ip":"203.0.113.10","port":4430},{"id":5,"ip":"203.0.113.10","port":4430}]}

验证

yarn build:webui
yarn vitest run --config vitest.webui-solid.config.mts \
  packages/bridge/client/bridge-model.test.ts \
  packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts
# ✓ client 7 passed / ✓ server 1 passed

e2e(390×844 手机视口、真实剪贴板)断言:剪贴板文本不含 \r\n\u2028\u2029、无首尾空白、
JSON.parse 后与 serverConfig 深相等(fixture 的 rsa_key 改成多行 PEM 以覆盖转义)、
回退复制路径写出的文本与主路径逐字相同,并且页面 <pre> 仍含换行、与剪贴板内容解析结果等价。

回归验证:把按钮改回 copyText(configuration()!) 并重新 build 后,该 e2e 失败并打印出多行的旧文本 ——
说明断言确实卡住这个 bug。

yarn typecheck:webui:solid 通过。全量 yarn test:webui:solid:79 passed / 1 failed,失败的是
packages/cordis-webui-solidjs/src/loader.browser.e2e.test.ts:131(loader 页横向溢出),已在未修改的
main 上复现同样失败,与本次改动无关。

未纳入本次改动

  • deploy/generate-client-config.mjs 生成的是文件(Android/Desktop 走文件导入),保留格式化 JSON。
  • packages/mtproto-debug/client/page.tsx:412 复制的是调试事件 payload,与客户端导入无关。
  • 管理 Bot 的「服务器配置」视图(packages/platform-admin-bot/src/index.ts:311)仍是 pretty JSON 消息,
    从 Telegram 里长按复制会走同一条搬运路径。要给它在保留可读展示的同时加「单行复制」入口,需要给
    IMInlineKeyboardButton(packages/bridge/src/platform.ts:440,目前只有 url/callback)新增
    copy_text 按钮类型并改 platform-crossgram 渲染,属于跨包 feature,可以另开 PR。
  • 客户端侧不动:解析器已经吃换行(见上),需要先拿到具体的客户端名/版本与报错原文,才能判断是哪一段搬运
    出了问题;只凭「多行失败」去改解析器会改错地方。

…ients

The accounts page copied JSON.stringify(config, null, 2), so the clipboard held a
pretty-printed document. On phones that value is pasted into the client's
single-line import path and rejected as invalid JSON; the compact document
imports fine.

Crossgram's own client parsers already tolerate whitespace (crossgram-android
ServerSwitchConfig.Server.parseUserInput, telegram-x CrossgramServerConfiguration.parse
and the Dart/TDLib equivalents all skip whitespace), so no parser change is
needed: the failure is in the text transport, and a compact document carries
neither line breaks nor indentation whitespace to be mangled or truncated.

The page still renders the readable, indented document; only the clipboard text
changes. Also covers the hidden-textarea fallback copy that phones use when
navigator.clipboard is unavailable over plain http.
@std-external
std-external force-pushed the fix/server-config-single-line branch from 5dec6f6 to 46dd0a1 Compare October 9, 2026 16:11
std-external pushed a commit to std-external/crossgram that referenced this pull request Oct 9, 2026
…r any endpoint

Account list management:
- Every account card gets a select box and a `Delete account` button; selected
  entries can be deleted together. Deletion always goes through a confirmation
  dialog that lists the exact entries, how many Telegram clients are signed in
  through them (they are signed out), and refuses entries the configuration
  file does not own.
- `findDuplicateAccounts()` reports entries that describe the same platform
  account: the adapter resolved both to the same platform user
  (platformKind + userId), or the entry lost its deterministic virtual phone to
  another entry (`VirtualPhoneClaimedError`). The entry clients are already
  signed in through keeps the account, with the stable id order as a
  tie-breaker. The page offers `Select duplicates`, which only selects those
  entries; nothing is deleted without the confirmation above.
- `deleteAccounts()` validates every target first, then stops the platform
  subscription, removes the platform session, virtual phone, TOTP secret,
  two-step password, auth bindings and client authorizations, revokes the
  matching auth keys, and finally deletes the Cordis plugin entry through the
  tree that owns it, so app.yml is written back and the account cannot return
  on the next start. Message and conversation history is kept: it is scoped by
  platform session and stays reusable if the entry id is configured again.
- `VirtualPhoneClaimedError` replaces the anonymous phone-collision error so the
  dashboard can name the entry that already serves the account.

Copying the server configuration:
- The dashboard publishes `serverEndpoints`: the configured main endpoint first,
  then every `altEndpoints` entry in configuration order, deduplicated.
- An `Endpoint` picker next to `Copy server configuration` (only shown when a
  backup endpoint exists, main selected by default) rewrites host/port and the
  `dcs` entries of the document. The clipboard form stays the single-line JSON
  from std-microblock#3, and the mobile fallback path without the Clipboard API is covered by
  the same end-to-end test.

Tests: unit tests for duplicate grouping, account removal against SQLite, entry
removal against the real Cordis loader (qualified entry keys, app.yml write-back)
and the endpoint document rewrite; the browser end-to-end test now covers the
endpoint picker with and without the Clipboard API, the duplicate banner, the
unmanaged and signed-in confirmations, and deleting the selected duplicate.
@std-external

std-external commented Oct 9, 2026 •

Copy link
Copy Markdown
Author

这个 PR 还需要吗?

#4 已经独立基于 main 了(不再 stack 在这里),所以 #3 不再是谁的前置。补充一下判断依据,供决定「合掉 / 保留 / 关掉」:

  • 现象没能归因到服务端产物:真实 crossgram-android 的 Server.parseUserInput(Android org.json)解析 pretty JSON
    是通的 —— 换行、CRLF、BOM、尾随换行都能过;唯一能复现的坏变形是缩进空格被换成 NBSP 这类非 JSON 空白。
    也就是说「复制出来是单行」是抗粘贴链路搬运的加固,而不是修一个必然失败的 bug。
  • 因此合掉它的收益是「更不容易被搬运破坏」,代价是复制出来的文档在别处(例如贴进工单、跟别人核对)可读性差一点;
    页面上的 <pre> 仍然是格式化版本,所以影响有限。
  • 如果决定合:请先合 bridge: 复制 server config 时输出单行 JSON(Android 端粘贴报「非法 JSON」) #3,否则 bridge: 账号列表支持删除/清理重复条目,复制 server config 可选 host:port #4 里复制按钮那处会有一次小冲突需要 rebase。
  • 如果决定不合:建议直接关掉,避免它一直挂着让人以为这一页的改动还被它挡着。

我这边没有强倾向,按你的判断走就行。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant