From 142f2f03b118c7ef39e7ad9d60ef8fab1a2be228 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 16 Jul 2026 00:09:43 +0800 Subject: [PATCH] fix(web): support image clipboard copy --- src/services/platform/web.test.ts | 80 +++++++++++++++++++++++++++++++ src/services/platform/web.ts | 32 ++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/src/services/platform/web.test.ts b/src/services/platform/web.test.ts index 04839d74..ff470201 100644 --- a/src/services/platform/web.test.ts +++ b/src/services/platform/web.test.ts @@ -2,6 +2,15 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { WebPlatformAdapter } from './web' +function replaceGlobal(name: string, value: unknown): () => void { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, name) + Object.defineProperty(globalThis, name, { configurable: true, value }) + return () => { + if (descriptor) Object.defineProperty(globalThis, name, descriptor) + else delete (globalThis as Record)[name] + } +} + describe('WebPlatformAdapter', () => { it('uses the bundled web version for display without querying the CLI server', async () => { const originalFetch = globalThis.fetch @@ -72,6 +81,77 @@ describe('WebPlatformAdapter', () => { } }) + it('writes a PNG data URL to the browser image clipboard', async () => { + const originalFetch = globalThis.fetch + let clipboardItems: unknown[] = [] + + class MockClipboardItem { + constructor(readonly data: Record>) {} + } + + const restoreNavigator = replaceGlobal('navigator', { + clipboard: { + write: async (items: unknown[]) => { + clipboardItems = items + }, + }, + }) + const restoreClipboardItem = replaceGlobal('ClipboardItem', MockClipboardItem) + const restoreSecureContext = replaceGlobal('isSecureContext', true) + globalThis.fetch = ((input: RequestInfo | URL) => { + assert.equal(String(input), 'data:image/png;base64,aGVsbG8=') + return Promise.resolve(new Response(new Blob(['hello'], { type: 'image/png' }))) + }) as typeof fetch + + try { + const result = await new WebPlatformAdapter().copyImageToClipboard('data:image/png;base64,aGVsbG8=') + + assert.deepEqual(result, { success: true }) + assert.equal(clipboardItems.length, 1) + const item = clipboardItems[0] as MockClipboardItem + const png = await item.data['image/png'] + assert.equal(png.type, 'image/png') + assert.equal(await png.text(), 'hello') + } finally { + globalThis.fetch = originalFetch + restoreSecureContext() + restoreClipboardItem() + restoreNavigator() + } + }) + + it('reports unsupported image clipboard APIs without attempting a write', async () => { + const restoreNavigator = replaceGlobal('navigator', { clipboard: {} }) + const restoreClipboardItem = replaceGlobal('ClipboardItem', undefined) + const restoreSecureContext = replaceGlobal('isSecureContext', true) + + try { + const result = await new WebPlatformAdapter().copyImageToClipboard('data:image/png;base64,aGVsbG8=') + assert.deepEqual(result, { + success: false, + error: 'Image clipboard is not supported by this browser', + }) + } finally { + restoreSecureContext() + restoreClipboardItem() + restoreNavigator() + } + }) + + it('rejects image clipboard writes outside a secure context', async () => { + const restoreSecureContext = replaceGlobal('isSecureContext', false) + + try { + const result = await new WebPlatformAdapter().copyImageToClipboard('data:image/png;base64,aGVsbG8=') + assert.deepEqual(result, { + success: false, + error: 'Image clipboard requires HTTPS or localhost', + }) + } finally { + restoreSecureContext() + } + }) + it('parses markdown skill bodies as text instead of JSON', async () => { // Regression: importing a skill from the cloud market failed with // "SyntaxError: No number after minus sign in JSON..." because diff --git a/src/services/platform/web.ts b/src/services/platform/web.ts index 46757b44..bd3f7554 100644 --- a/src/services/platform/web.ts +++ b/src/services/platform/web.ts @@ -13,6 +13,7 @@ import type { PerformUpdateResult, } from './types' import { fetchWithAuth } from '../utils/http' +import { reportError } from '../log-report' declare const __APP_VERSION__: string @@ -81,8 +82,35 @@ export class WebPlatformAdapter implements PlatformAdapter { return { canceled: true, filePaths: [] } } - async copyImageToClipboard(_dataUrl: string): Promise<{ success: boolean; error?: string }> { - return { success: false, error: 'Not available in web mode' } + async copyImageToClipboard(dataUrl: string): Promise<{ success: boolean; error?: string }> { + if (globalThis.isSecureContext === false) { + return { success: false, error: 'Image clipboard requires HTTPS or localhost' } + } + + if ( + typeof navigator === 'undefined' || + typeof navigator.clipboard?.write !== 'function' || + typeof ClipboardItem === 'undefined' + ) { + return { success: false, error: 'Image clipboard is not supported by this browser' } + } + + try { + // Keep the Blob conversion inside ClipboardItem so the write starts within the user's click activation. + const pngBlob = fetch(dataUrl).then(async (response) => { + if (!response.ok) throw new Error(`Failed to read screenshot data: HTTP ${response.status}`) + const blob = await response.blob() + if (blob.type !== 'image/png') throw new Error(`Unsupported screenshot MIME type: ${blob.type || 'unknown'}`) + return blob + }) + + await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]) + return { success: true } + } catch (error) { + const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error) + reportError(`Image clipboard write failed: ${detail}`, error instanceof Error ? error.stack : undefined) + return { success: false, error: detail } + } } async checkUpdate(): Promise {