From c76b8b4a8176b426b40d89597508fb4d01f11611 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 8 May 2026 22:23:06 +0200 Subject: [PATCH] fix(frontend): inbound Advanced tab live mirror + QR exact-fit sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advanced tab in the inbound modal showed stale state. The watch only refreshed advancedJson.stream, so toggling the Sniffing switch in the Sniffing tab left the Advanced JSON showing the prior value. And encryption — stored on inbound.settings.encryption, not on stream — never appeared at all because Advanced only exposed stream + sniffing. Split the watch into three (stream / sniffing / settings) and add a settings textarea so encryption / clients / fallbacks live alongside the existing two views. The submit() path now reads settings from the JSON tab too (falling back to inbound.settings.toString()) so power-user edits in Advanced override the structured form on save. QR canvas: when a longer share-URL bumps the QR matrix size, QRious falls back to floor(canvasSize / matrixWidth) and centers the pattern, leaving a white margin (e.g. matrix=41, size=180 → 8px gap). Pre-pick the QR version from the URL byte length and set canvas size to a multiple of matrixWidth × pixelSize so the pattern always fills it edge-to-edge — no white margin even after toggling encryption on. Co-Authored-By: Claude Opus 4.7 --- .../src/pages/inbounds/InboundFormModal.vue | 59 +++++++++++++++---- frontend/src/pages/inbounds/QrPanel.vue | 31 +++++++++- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index 49e9bb84..a3145f6a 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -59,7 +59,7 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL); const inbound = ref(null); const dbForm = ref(null); const saving = ref(false); -const advancedJson = ref({ stream: '', sniffing: '' }); +const advancedJson = ref({ stream: '', sniffing: '', settings: '' }); // Cached default cert/key paths from /panel/setting/defaultSettings — // powers the "Set default cert" button on the TLS form. const defaultCert = ref(''); @@ -211,8 +211,15 @@ function freshDbForm() { function primeAdvancedJson() { if (!inbound.value) return; - advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2); - advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2); + try { + advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2); + } catch (_e) { /* keep prior text */ } + try { + advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2); + } catch (_e) { /* keep prior text */ } + try { + advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2); + } catch (_e) { /* keep prior text */ } } watch(() => props.open, (next) => { @@ -431,6 +438,7 @@ async function submit() { // transports — both go to wire as serialized JSON. let streamSettings; let sniffing; + let settings; try { streamSettings = canEnableStream.value ? JSON.stringify(JSON.parse(advancedJson.value.stream)) @@ -441,6 +449,9 @@ async function submit() { try { sniffing = JSON.stringify(JSON.parse(advancedJson.value.sniffing || inbound.value.sniffing.toString())); } catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; } + try { + settings = JSON.stringify(JSON.parse(advancedJson.value.settings || inbound.value.settings.toString())); + } catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; } // The structured form mutates `inbound.stream` directly when the // user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched @@ -459,7 +470,7 @@ async function submit() { listen: inbound.value.listen, port: inbound.value.port, protocol: inbound.value.protocol, - settings: inbound.value.settings.toString(), + settings: settings, streamSettings: streamSettings, sniffing: sniffing, }; @@ -486,17 +497,35 @@ const okText = computed(() => props.mode === 'edit' ? t('pages.client.submitEdit') : t('create'), ); -// Whenever the structured stream form mutates the model, refresh the -// Advanced JSON tab so it reflects the latest state. Use a deep watch -// on the parsed JSON of the stream. +// Whenever the structured form mutates stream / sniffing / settings, +// refresh the matching slice of the Advanced JSON tab so the user +// always sees the live state — flipping a switch in Sniffing or +// editing encryption in Protocol now reflects in Advanced. watch( () => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}), - (next) => { - if (next) { - try { - advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2); - } catch (_e) { /* leave as is */ } - } + () => { + if (!inbound.value?.stream) return; + try { + advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2); + } catch (_e) { /* leave as is */ } + }, +); +watch( + () => inbound.value && JSON.stringify(inbound.value.sniffing?.toJson?.() || {}), + () => { + if (!inbound.value?.sniffing) return; + try { + advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2); + } catch (_e) { /* leave as is */ } + }, +); +watch( + () => inbound.value && JSON.stringify(inbound.value.settings?.toJson?.() || {}), + () => { + if (!inbound.value?.settings) return; + try { + advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2); + } catch (_e) { /* leave as is */ } }, ); @@ -1631,6 +1660,10 @@ watch( message="Edit raw stream JSON to access advanced fields we don't yet expose through the form." class="mb-12" /> + + + diff --git a/frontend/src/pages/inbounds/QrPanel.vue b/frontend/src/pages/inbounds/QrPanel.vue index 07a3b960..6ac160f8 100644 --- a/frontend/src/pages/inbounds/QrPanel.vue +++ b/frontend/src/pages/inbounds/QrPanel.vue @@ -29,17 +29,42 @@ const props = defineProps({ const canvas = ref(null); +// Byte-mode capacities (level M) for QR versions 1..40 — used to pick +// the matrix width up front so we can size the canvas as a multiple +// of pixelSize. Without this, QRious renders at floor(size/matrix) +// and centers, leaving a white margin whenever size isn't divisible. +const QR_M_BYTE_CAPACITY = [ + 14, 26, 42, 62, 84, 106, 122, 152, 180, 213, + 251, 287, 331, 362, 412, 450, 504, 560, 624, 666, + 711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370, + 1452, 1538, 1628, 1722, 1809, 1911, 1989, 2099, 2213, 2331, +]; + +function pickQrMatrixWidth(value) { + const byteLen = new TextEncoder().encode(value).length; + for (let i = 0; i < QR_M_BYTE_CAPACITY.length; i++) { + if (byteLen <= QR_M_BYTE_CAPACITY[i]) return 17 + 4 * (i + 1); + } + return 17 + 4 * 40; // version 40 (177 modules) +} + function paint() { if (!props.showQr || !canvas.value || !props.value) return; + // Canvas size = matrixWidth × pixelSize, so the QR fills it edge-to- + // edge. pixelSize is floored against the requested size so the QR + // never grows past the host's expected box. + const matrixWidth = pickQrMatrixWidth(props.value); + const pixelSize = Math.max(1, Math.floor(props.size / matrixWidth)); + const exactSize = matrixWidth * pixelSize; // eslint-disable-next-line no-new new QRious({ element: canvas.value, - size: props.size, + size: exactSize, value: props.value, background: 'white', backgroundAlpha: 1, foreground: 'black', - padding: 2, + padding: 0, level: 'M', }); } @@ -115,7 +140,7 @@ function download() { .qr-panel-canvas canvas { cursor: pointer; - background: #fff; + display: block; border-radius: 4px; }