fix(web): support image clipboard copy

This commit is contained in:
digua committed 2026-07-16 00:34:16 +08:00
1 parent 3c8b83d571
commit 142f2f03b1
2 files changed
+110 -2

No files matched your search

+80
View File
@@ -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<string, unknown>)[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<string, Blob | Promise<Blob>>) {}
}
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
+30 -2
View File
@@ -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<CheckUpdateResult> {