Path: blob/main/src/vs/platform/clipboard/browser/clipboardService.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { isSafari, isWebkitWebView } from '../../../base/browser/browser.js';6import { $, addDisposableListener, getActiveDocument, getActiveWindow, isHTMLElement, onDidRegisterWindow } from '../../../base/browser/dom.js';7import { mainWindow } from '../../../base/browser/window.js';8import { DeferredPromise } from '../../../base/common/async.js';9import { Event } from '../../../base/common/event.js';10import { hash } from '../../../base/common/hash.js';11import { Disposable } from '../../../base/common/lifecycle.js';12import { URI } from '../../../base/common/uri.js';13import { IClipboardService } from '../common/clipboardService.js';14import { ILayoutService } from '../../layout/browser/layoutService.js';15import { ILogService } from '../../log/common/log.js';1617/**18* Custom mime type used for storing a list of uris in the clipboard.19*20* Requires support for custom web clipboards https://github.com/w3c/clipboard-apis/pull/17521*/22const vscodeResourcesMime = 'application/vnd.code.resources';2324export class BrowserClipboardService extends Disposable implements IClipboardService {2526declare readonly _serviceBrand: undefined;2728constructor(29@ILayoutService private readonly layoutService: ILayoutService,30@ILogService protected readonly logService: ILogService31) {32super();3334if (isSafari || isWebkitWebView) {35this.installWebKitWriteTextWorkaround();36}3738// Keep track of copy operations to reset our set of39// copied resources: since we keep resources in memory40// and not in the clipboard, we have to invalidate41// that state when the user copies other data.42this._register(Event.runAndSubscribe(onDidRegisterWindow, ({ window, disposables }) => {43disposables.add(addDisposableListener(window.document, 'copy', () => this.clearResourcesState()));44}, { window: mainWindow, disposables: this._store }));45}4647triggerPaste(): Promise<void> | undefined {48this.logService.trace('BrowserClipboardService#triggerPaste');49return undefined;50}5152async readImage(): Promise<Uint8Array> {53try {54const clipboardItems = await navigator.clipboard.read();55const clipboardItem = clipboardItems[0];5657const supportedImageTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/tiff', 'image/bmp'];58const mimeType = supportedImageTypes.find(type => clipboardItem.types.includes(type));5960if (mimeType) {61const blob = await clipboardItem.getType(mimeType);62const buffer = await blob.arrayBuffer();63return new Uint8Array(buffer);64} else {65console.error('No supported image type found in the clipboard');66}67} catch (error) {68console.error('Error reading image from clipboard:', error);69}7071// Return an empty Uint8Array if no image is found or an error occurs72return new Uint8Array(0);73}7475private webKitPendingClipboardWritePromise: DeferredPromise<string> | undefined;7677// In Safari, it has the following note:78//79// "The request to write to the clipboard must be triggered during a user gesture.80// A call to clipboard.write or clipboard.writeText outside the scope of a user81// gesture(such as "click" or "touch" event handlers) will result in the immediate82// rejection of the promise returned by the API call."83// From: https://webkit.org/blog/10855/async-clipboard-api/84//85// Since extensions run in a web worker, and handle gestures in an asynchronous way,86// they are not classified by Safari as "in response to a user gesture" and will reject.87//88// This function sets up some handlers to work around that behavior.89private installWebKitWriteTextWorkaround(): void {90const handler = () => {91const currentWritePromise = new DeferredPromise<string>();9293// Cancel the previous promise since we just created a new one in response to this new event94if (this.webKitPendingClipboardWritePromise && !this.webKitPendingClipboardWritePromise.isSettled) {95this.webKitPendingClipboardWritePromise.cancel();96}97this.webKitPendingClipboardWritePromise = currentWritePromise;9899// The ctor of ClipboardItem allows you to pass in a promise that will resolve to a string.100// This allows us to pass in a Promise that will either be cancelled by another event or101// resolved with the contents of the first call to this.writeText.102// see https://developer.mozilla.org/en-US/docs/Web/API/ClipboardItem/ClipboardItem#parameters103getActiveWindow().navigator.clipboard.write([new ClipboardItem({104'text/plain': currentWritePromise.p,105})]).catch(async err => {106if (!(err instanceof Error) || err.name !== 'NotAllowedError' || !currentWritePromise.isRejected) {107this.logService.error(err);108}109});110};111112113this._register(Event.runAndSubscribe(this.layoutService.onDidAddContainer, ({ container, disposables }) => {114disposables.add(addDisposableListener(container, 'click', handler));115disposables.add(addDisposableListener(container, 'keydown', handler));116}, { container: this.layoutService.mainContainer, disposables: this._store }));117}118119private readonly mapTextToType = new Map<string, string>(); // unsupported in web (only in-memory)120121async writeText(text: string, type?: string): Promise<void> {122this.logService.trace('BrowserClipboardService#writeText called with type:', type, ' text.length:', text.length);123// Clear resources given we are writing text124this.clearResourcesState();125126// With type: only in-memory is supported127if (type) {128this.mapTextToType.set(type, text);129this.logService.trace('BrowserClipboardService#writeText');130return;131}132133if (this.webKitPendingClipboardWritePromise) {134// For Safari, we complete this Promise which allows the call to `navigator.clipboard.write()`135// above to resolve and successfully copy to the clipboard. If we let this continue, Safari136// would throw an error because this call stack doesn't appear to originate from a user gesture.137return this.webKitPendingClipboardWritePromise.complete(text);138}139140// Guard access to navigator.clipboard with try/catch141// as we have seen DOMExceptions in certain browsers142// due to security policies.143try {144this.logService.trace('before navigator.clipboard.writeText');145return await getActiveWindow().navigator.clipboard.writeText(text);146} catch (error) {147console.error(error);148}149150// Fallback to textarea and execCommand solution151this.fallbackWriteText(text);152}153154private fallbackWriteText(text: string): void {155this.logService.trace('BrowserClipboardService#fallbackWriteText');156const activeDocument = getActiveDocument();157const activeElement = activeDocument.activeElement;158159const textArea: HTMLTextAreaElement = activeDocument.body.appendChild($('textarea', { 'aria-hidden': true }));160textArea.style.height = '1px';161textArea.style.width = '1px';162textArea.style.position = 'absolute';163164textArea.value = text;165textArea.focus();166textArea.select();167168activeDocument.execCommand('copy');169170if (isHTMLElement(activeElement)) {171activeElement.focus();172}173174textArea.remove();175}176177async readText(type?: string): Promise<string> {178this.logService.trace('BrowserClipboardService#readText called with type:', type);179// With type: only in-memory is supported180if (type) {181const readText = this.mapTextToType.get(type) || '';182this.logService.trace('BrowserClipboardService#readText text.length:', readText.length);183return readText;184}185186// Guard access to navigator.clipboard with try/catch187// as we have seen DOMExceptions in certain browsers188// due to security policies.189try {190const readText = await getActiveWindow().navigator.clipboard.readText();191this.logService.trace('BrowserClipboardService#readText text.length:', readText.length);192return readText;193} catch (error) {194console.error(error);195}196197return '';198}199200private findText = ''; // unsupported in web (only in-memory)201202async readFindText(): Promise<string> {203return this.findText;204}205206async writeFindText(text: string): Promise<void> {207this.findText = text;208}209210private resources: URI[] = []; // unsupported in web (only in-memory)211private resourcesStateHash: number | undefined = undefined;212213private static readonly MAX_RESOURCE_STATE_SOURCE_LENGTH = 1000;214215async writeResources(resources: URI[]): Promise<void> {216// Guard access to navigator.clipboard with try/catch217// as we have seen DOMExceptions in certain browsers218// due to security policies.219try {220await getActiveWindow().navigator.clipboard.write([221new ClipboardItem({222[`web ${vscodeResourcesMime}`]: new Blob([223JSON.stringify(resources.map(x => x.toJSON()))224], {225type: vscodeResourcesMime226})227})228]);229230// Continue to write to the in-memory clipboard as well.231// This is needed because some browsers allow the paste but then can't read the custom resources.232} catch (error) {233// Noop234}235236if (resources.length === 0) {237this.clearResourcesState();238} else {239this.resources = resources;240this.resourcesStateHash = await this.computeResourcesStateHash();241}242}243244async readResources(): Promise<URI[]> {245// Guard access to navigator.clipboard with try/catch246// as we have seen DOMExceptions in certain browsers247// due to security policies.248try {249const items = await getActiveWindow().navigator.clipboard.read();250for (const item of items) {251if (item.types.includes(`web ${vscodeResourcesMime}`)) {252const blob = await item.getType(`web ${vscodeResourcesMime}`);253const resources = (JSON.parse(await blob.text()) as URI[]).map(x => URI.from(x));254return resources;255}256}257} catch (error) {258// Noop259}260261const resourcesStateHash = await this.computeResourcesStateHash();262if (this.resourcesStateHash !== resourcesStateHash) {263this.clearResourcesState(); // state mismatch, resources no longer valid264}265266return this.resources;267}268269private async computeResourcesStateHash(): Promise<number | undefined> {270if (this.resources.length === 0) {271return undefined; // no resources, no hash needed272}273274// Resources clipboard is managed in-memory only and thus275// fails to invalidate when clipboard data is changing.276// As such, we compute the hash of the current clipboard277// and use that to later validate the resources clipboard.278279const clipboardText = await this.readText();280return hash(clipboardText.substring(0, BrowserClipboardService.MAX_RESOURCE_STATE_SOURCE_LENGTH));281}282283async hasResources(): Promise<boolean> {284// Guard access to navigator.clipboard with try/catch285// as we have seen DOMExceptions in certain browsers286// due to security policies.287try {288const items = await getActiveWindow().navigator.clipboard.read();289for (const item of items) {290if (item.types.includes(`web ${vscodeResourcesMime}`)) {291return true;292}293}294} catch (error) {295// Noop296}297298return this.resources.length > 0;299}300301public clearInternalState(): void {302this.clearResourcesState();303}304305private clearResourcesState(): void {306this.resources = [];307this.resourcesStateHash = undefined;308}309}310311312