From eb64f7026907529b70caca35980a48b8a3f22b7f Mon Sep 17 00:00:00 2001 From: rednakse Date: Thu, 2 Apr 2026 04:07:54 +0300 Subject: [PATCH] Fix proxy copy on plain HTTP --- docs/PLAN.md | 1 + docs/PROJECT_INDEX.md | 4 ++-- src/App.test.tsx | 36 ++++++++++++++++++++++++++++++++++- src/App.tsx | 44 ++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/docs/PLAN.md b/docs/PLAN.md index c05ee9a..f074795 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -46,3 +46,4 @@ Updated: 2026-04-02 29. Added backend user-update support plus runtime stop control, then verified both in Docker by updating `u-1` and stopping the real bundled 3proxy process through the API. 30. Added a websocket heartbeat so time-based status transitions such as `live -> idle/warn` are recalculated predictably even when no new proxy events arrive. 31. Moved proxy-copy into the Users actions column, added a last-seen/online column from parsed 3proxy logs, and introduced bounded websocket/API reconnect attempts with a visible connection banner and forced logout after full recovery failure. +32. Restored proxy-link copying for plain-`http` deployments by falling back from the Clipboard API to `execCommand('copy')`, and added regression coverage for both clipboard paths. diff --git a/docs/PROJECT_INDEX.md b/docs/PROJECT_INDEX.md index 7f226fb..0f9a6ac 100644 --- a/docs/PROJECT_INDEX.md +++ b/docs/PROJECT_INDEX.md @@ -23,9 +23,9 @@ Updated: 2026-04-02 ## Frontend - `src/main.tsx`: application bootstrap -- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations +- `src/App.tsx`: authenticated panel shell with API-backed login, `sessionStorage` token persistence, hash-based tab history, websocket snapshot patch sync, bounded reconnect/API fallback policy with connection notices, log-derived last-seen user labels, icon-based user actions, HTTP-safe proxy-link copying fallback, create/edit user modal flows, runtime stop control, localized labels, early theme application, and protected panel mutations - `src/SystemTab.tsx`: Settings tab with separate panel-settings and services cards, editable proxy endpoint, dirty-draft protection against incoming live sync, unified service type editing, remove confirmation, and generated config preview -- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests +- `src/App.test.tsx`: login-gate, preferences persistence, hash-tab restoration, websocket-sync safety, reconnect/logout fallback handling, clipboard fallback coverage, generated-credential/create-edit modal flows, runtime stop, pause/resume, delete-confirm, and settings-save UI tests - `src/app.css`: full panel styling including fixed-width icon action buttons, busy-state treatment, and connection banner styling - `src/data/mockDashboard.ts`: default panel state and frontend fallback snapshot - `src/lib/3proxy.ts`: formatting and status helpers diff --git a/src/App.test.tsx b/src/App.test.tsx index 4ad10c1..7349189 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,7 +1,7 @@ import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import App from './App'; +import App, { copyTextToClipboard } from './App'; import { fallbackDashboardSnapshot } from './data/mockDashboard'; import { MockWebSocket } from './test/setup'; @@ -11,6 +11,15 @@ async function loginIntoPanel(user: ReturnType) { await user.click(screen.getByRole('button', { name: /open panel/i })); } +function mockClipboardWriteText(writeText: ReturnType) { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { + writeText, + }, + }); +} + beforeEach(() => { document.documentElement.dataset.theme = ''; window.sessionStorage.clear(); @@ -128,6 +137,31 @@ describe('App login gate', () => { expect(screen.getAllByText(/^now$/i).length).toBeGreaterThan(0); }); + it('copies the proxy link via the Clipboard API when available', async () => { + const clipboardWriteText = vi.fn().mockResolvedValue(undefined); + mockClipboardWriteText(clipboardWriteText); + + await copyTextToClipboard('socks5://night-shift:kettle!23@edge.example.net:1080'); + + expect(clipboardWriteText).toHaveBeenCalledWith('socks5://night-shift:kettle!23@edge.example.net:1080'); + }); + + it('falls back to execCommand copy when the Clipboard API is unavailable', async () => { + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: undefined, + }); + const execCommand = vi.fn(() => true); + Object.defineProperty(document, 'execCommand', { + configurable: true, + value: execCommand, + }); + + await copyTextToClipboard('socks5://night-shift:kettle!23@edge.example.net:1080'); + + expect(execCommand).toHaveBeenCalledWith('copy'); + }); + it('opens add-user flow in a modal and closes it on escape', async () => { const user = userEvent.setup(); render(); diff --git a/src/App.tsx b/src/App.tsx index 651dbf7..c26d8ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,40 @@ interface StoredSession { expiresAt: string; } +export async function copyTextToClipboard(value: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(value); + return; + } catch { + // Fall back for plain-http deployments where Clipboard API may reject the request. + } + } + + if (typeof document.execCommand !== 'function') { + throw new Error('Clipboard copy is unavailable.'); + } + + const input = document.createElement('textarea'); + input.value = value; + input.setAttribute('readonly', ''); + input.style.position = 'fixed'; + input.style.opacity = '0'; + input.style.pointerEvents = 'none'; + document.body.appendChild(input); + input.focus(); + input.select(); + input.setSelectionRange(0, input.value.length); + + try { + if (!document.execCommand('copy')) { + throw new Error('Clipboard copy was rejected.'); + } + } finally { + document.body.removeChild(input); + } +} + function LoginGate({ onUnlock, preferences, @@ -484,9 +518,13 @@ function UsersTab({ const editTarget = snapshot.userRecords.find((user) => user.id === editTargetId) ?? null; const handleCopy = async (userId: string, proxyLink: string) => { - await navigator.clipboard.writeText(proxyLink); - setCopiedId(userId); - window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200); + try { + await copyTextToClipboard(proxyLink); + setCopiedId(userId); + window.setTimeout(() => setCopiedId((value) => (value === userId ? null : value)), 1200); + } catch { + // Keep the current UI stable when clipboard access is blocked entirely. + } }; const handlePauseToggle = async (userId: string) => {