From 46dd0a1a728428525ae101b22521a4366ab13160 Mon Sep 17 00:00:00 2001 From: orange Date: Fri, 9 Oct 2026 23:54:46 +0800 Subject: [PATCH] bridge: copy the server configuration on a single line for Android clients 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. --- packages/bridge/client/accounts.tsx | 11 ++++- packages/bridge/client/bridge-model.test.ts | 40 +++++++++++++++++++ packages/bridge/client/bridge-model.ts | 18 +++++++++ .../src/bridge.browser.e2e.test.ts | 37 +++++++++++++++-- 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/packages/bridge/client/accounts.tsx b/packages/bridge/client/accounts.tsx index 609e11f9..f684cee2 100644 --- a/packages/bridge/client/accounts.tsx +++ b/packages/bridge/client/accounts.tsx @@ -25,10 +25,12 @@ import { sessionToken } from 'cordis-webui-solidjs/session' import { copyText, formatPhone, + formatServerConfig, parseTelegramLoginUrl, remainingSeconds, safeImageURL, sameOriginPath, + serializeServerConfig, } from './bridge-model.js' export default function AccountsPage(props: PageProps) { const rpc = useRpc(props.entryId), @@ -55,8 +57,13 @@ export default function AccountsPage(props: PageProps) { ), ) const ids = createMemo(() => [...accounts().keys()]) + // The page shows the readable document, but the clipboard has to stay on a single + // line: Android clients reject a pasted pretty-printed configuration as invalid JSON. const configuration = createMemo(() => - JSON.stringify(rpc.data.serverConfig, null, 2), + formatServerConfig(rpc.data.serverConfig), + ) + const clipboardConfiguration = createMemo(() => + serializeServerConfig(rpc.data.serverConfig), ) const [search, setSearch] = createSignal(''), [limit, setLimit] = createSignal(24) @@ -107,7 +114,7 @@ export default function AccountsPage(props: PageProps) { disabled={!rpc.ready || !rpc.data.serverConfig} onClick={() => void action.run(async () => { - await copyText(configuration()!) + await copyText(clipboardConfiguration()!) setCopied(true) }) } diff --git a/packages/bridge/client/bridge-model.test.ts b/packages/bridge/client/bridge-model.test.ts index 805acf26..ffc769ca 100644 --- a/packages/bridge/client/bridge-model.test.ts +++ b/packages/bridge/client/bridge-model.test.ts @@ -1,12 +1,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CrossGramServerConfig } from '../src/dashboard-types.js' import { botLink, copyText, formatPhone, + formatServerConfig, parseTelegramLoginUrl, remainingSeconds, safeImageURL, sameOriginPath, + serializeServerConfig, } from './bridge-model.js' afterEach(() => vi.restoreAllMocks()) describe('bridge dashboard input boundaries', () => { @@ -53,3 +56,40 @@ describe('bridge dashboard input boundaries', () => { await expect(copyText('credential')).rejects.toThrow('permission denied') }) }) +describe('server configuration copying', () => { + const config: CrossGramServerConfig = { + name: 'CrossGram', + enable_special_config: false, + host: 'relay.example.test', + port: 4430, + rsa_key: + '-----BEGIN RSA PUBLIC KEY-----\nPLACEHOLDER_KEY\n-----END RSA PUBLIC KEY-----', + dcs: Array.from({ length: 5 }, (_, index) => ({ + id: index + 1, + ip: 'relay.example.test', + port: 4430, + })), + } + // Android clients reject the pasted configuration as invalid JSON while it contains line + // breaks, and import the identical document once it is a single line. + it('copies a single-line document without losing fields or escaping PEM newlines', () => { + const copied = serializeServerConfig(config)! + expect(copied).not.toMatch(/[\r\n\u2028\u2029]/) + expect(copied.trim()).toBe(copied) + expect(copied).toBe(JSON.stringify(config)) + expect(copied).toContain('\\n') + expect(JSON.parse(copied)).toEqual(config) + expect(JSON.parse(copied).rsa_key).toContain('\n') + expect(JSON.parse(copied).dcs).toHaveLength(5) + }) + it('keeps the readable multi-line form for on-page display only', () => { + const displayed = formatServerConfig(config)! + expect(displayed.split('\n').length).toBeGreaterThan(1) + expect(displayed.length).toBeGreaterThan(serializeServerConfig(config)!.length) + expect(JSON.parse(displayed)).toEqual(JSON.parse(serializeServerConfig(config)!)) + }) + it('treats a missing configuration as nothing to copy or display', () => { + expect(serializeServerConfig(undefined)).toBeUndefined() + expect(formatServerConfig(undefined)).toBeUndefined() + }) +}) diff --git a/packages/bridge/client/bridge-model.ts b/packages/bridge/client/bridge-model.ts index 56f4af5d..48d0868a 100644 --- a/packages/bridge/client/bridge-model.ts +++ b/packages/bridge/client/bridge-model.ts @@ -1,3 +1,4 @@ +import type { CrossGramServerConfig } from '../src/dashboard-types.js' export function parseTelegramLoginUrl(value: string): string | undefined { try { const url = new URL(value), @@ -60,5 +61,22 @@ export function formatPhone(value?: string): string { return '+888 ' + digits.slice(3).replace(/(\d)(?=(\d{3})+$)/g, '$1 ') return '+' + digits.replace(/(\d)(?=(\d{3})+$)/g, '$1 ') } +/** + * Clipboard form of the server configuration. Android clients report "invalid JSON" when + * the configuration is pasted with line breaks and import the identical document once the + * line breaks are removed, so the copy button emits exactly one line, with the same fields + * and values as the readable form rendered on the page. + */ +export function serializeServerConfig( + config: CrossGramServerConfig | undefined, +): string | undefined { + return config ? JSON.stringify(config) : undefined +} +/** Readable form of the server configuration, used for on-page display only. */ +export function formatServerConfig( + config: CrossGramServerConfig | undefined, +): string | undefined { + return config ? JSON.stringify(config, null, 2) : undefined +} export { sameOriginPath, copyText } from "cordis-webui-solidjs/utils" diff --git a/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts b/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts index f107a81d..1d502a38 100644 --- a/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts +++ b/packages/cordis-webui-solidjs/src/bridge.browser.e2e.test.ts @@ -82,7 +82,8 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => { enable_special_config: false, host: 'example.test', port: 4430, - rsa_key: 'PUBLIC_TEST_KEY', + rsa_key: + '-----BEGIN RSA PUBLIC KEY-----\nPUBLIC_TEST_KEY\n-----END RSA PUBLIC KEY-----', dcs: [], }, loginTokenApprovalUrl: '/bridge/login-tokens', @@ -237,9 +238,37 @@ describe('Crossgram accounts, stickers and bots in the Solid shell', () => { await page .getByRole('button', { name: 'Copy server configuration', exact: true }) .click() - expect( - JSON.parse(await page.evaluate(() => navigator.clipboard.readText())), - ).toMatchObject({ host: 'example.test', rsa_key: 'PUBLIC_TEST_KEY' }) + const copiedConfiguration = await page.evaluate(() => + navigator.clipboard.readText(), + ) + // The pasted value reaches the client's single-line import path, which answers + // "invalid JSON" once the document carries line breaks, so the copy must stay on one line. + expect(copiedConfiguration).not.toMatch(/[\r\n\u2028\u2029]/) + expect(copiedConfiguration.trim()).toBe(copiedConfiguration) + expect(JSON.parse(copiedConfiguration)).toEqual(data.serverConfig) + expect(copiedConfiguration).toContain('PUBLIC_TEST_KEY') + // Phones open the console over plain http, where navigator.clipboard is missing and + // copyText falls back to a hidden textarea; that path must produce the same single line. + await page.evaluate(() => + Object.defineProperty(navigator.clipboard, 'writeText', { + value: undefined, + configurable: true, + }), + ) + await page + .getByRole('button', { name: 'Configuration copied', exact: true }) + .click() + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + copiedConfiguration, + ) + // The readable, indented document is still what the page displays. + const displayedConfiguration = await page + .getByLabel('Server configuration') + .textContent() + expect(displayedConfiguration).toContain('\n') + expect(JSON.parse(displayedConfiguration!)).toEqual( + JSON.parse(copiedConfiguration), + ) await page .getByRole('button', { name: 'Approve QR login', exact: true }) .click()