javascript

Буфер обмена веб-приложений и как он хранит различные данные

  • суббота, 7 сентября 2024 г. в 00:00:05
https://habr.com/ru/companies/beget/articles/841446/

Если вы достаточно давно пользуетесь компьютером, то, вероятно, знаете, что буфер обмена может хранить различные данные (картинки, текст с форматированием, файлы и др.). Как разработчика, меня начало раздражать отсутствие точного представления о том, как буфер обмена хранит и организует данные разных типов.

Недавно я решил разобраться в механике работы буфера обмена и написал этот пост на основе моих изысканий. В основном речь пойдёт о буфере обмена и его API, но мы также обсудим, как он взаимодействует с системным буфером обмена.

Давайте начнём с исследования различных API и их истории. Эти API имеют весьма интересные ограничения по типам данных, и мы увидим, как некоторые компании обошли эти ограничения. Мы также рассмотрим некоторые предложения, направленные на устранение этих ограничений (наиболее примечательное из них — Web Custom Formats).

Если вам хотя бы раз было интересно, как работает веб‑буфер обмена, то эта статья для вас.

Использование асинхронного Clipboard API

Когда мы копируем содержимое сайта и вставляем его в Google Docs, часть форматирования текста сохраняется, включая ссылки, размер шрифта и цвета.

При этом, если вставить тот же текст в VS Code, копируется только содержимое текста без форматирования.

Это возможно благодаря тому, что буфер обмена хранит данные в разных представлениях, основываясь на MIME‑типах. Спецификация W3C Clipboard требует поддержки трёх типов данных для чтения и записи:

  • text/plain для текста без форматирования.

  • text/html для HTML.

  • image/png для PNG‑изображений.

В приведённом ранее примере Google Docs использовал представление text/html и сохранил форматирование на его основе. VS Code интересует только чистый текст, поэтому при вставке используется представление text/plain.

Получить нужное нам представление с помощью асинхронного метода read Clipboard API достаточно просто:

const items = await navigator.clipboard.read();

for (const item of items) {
  if (item.types.includes("text/html")) {
    const blob = await item.getType("text/html");
    const html = await blob.text();
    // Do stuff with HTML...
  }
}

Запись в буфер обмена с помощью метода write требует чуть больше телодвижений, но всё ещё достаточно проста. Сначала мы формируем Blob для каждого из представлений, которые нужно записать в буфер обмена:

const textBlob = new Blob(["Hello, world"], { type: "text/plain" });
const htmlBlob = new Blob(["Hello, <em>world<em>"], { type: "text/html" });

Затем мы передаём их новому объекту ClipboardItem в формате ключ‑значение, где тип данных является ключом, а Blob — соответствующим значением:

const clipboardItem = new ClipboardItem({
  [textBlob.type]: textBlob,
  [htmlBlob.type]: htmlBlob,
});

Примечание: Мне нравится, что ClipboardItem использует формат ключ‑значение, поскольку это совпадает с идеей использования структур данных, не позволяющих отображение невалидных состояний, что обсуждалось ранее в «Parse, don't validate».

И, наконец, мы вызываем функцию write, используя созданный ранее объект ClipboardItem:

await navigator.clipboard.write([clipboardItem]);

Что насчет других типов данных?

HTML и изображения — это здорово, но что насчёт других форматов обмена данными, например, JSON? Если бы я писал приложение с поддержкой копирования/вставки, я мог бы представить ситуацию, когда в буфер обмена потребуется записать JSON или какие‑то бинарные данные.

Попробуем записать JSON в буфер обмена:

// Create JSON blob
const json = JSON.stringify({ message: "Hello" });
const blob = new Blob([json], { type: "application/json" });

// Write JSON blob to clipboard
const clipboardItem = new ClipboardItem({ [blob.type]: blob });
await navigator.clipboard.write([clipboardItem]);

При попытке выполнить данный код мы получим ошибку:

Failed to execute 'write' on 'Clipboard':
  Type application/json not supported on write.

И почему же? Дело в том, что спецификация для метода write требует, чтобы типы данных за исключением text/plain, text/html и image/png не обрабатывались.

If type is not in the mandatory data types list, then reject [...] and abort these steps.

Интересно, что тип application/json был в списке обязательных с 2012 по 2021 год, но был убран из спецификации в w3c/clipboard‑apis#155. До этого изменения список обязательных типов был значительно больше — 16 типов для чтения из буфера обмена и 8 для записи. После изменения остались лишь text/plain, text/html и image/png.

Это было сделано после того, как браузеры решили перестать поддерживать многие из обязательных типов из‑за возможных проблем с безопасностью. Об этом говорится в предупреждении в разделе об обязательных типах данных в спецификации:

Warning! The data types that untrusted scripts are allowed to write to the clipboard are limited as a security precaution.

Untrusted scripts can attempt to exploit security vulnerabilities in local software by placing data known to trigger those vulnerabilities on the clipboard.

Итак, мы можем записывать в буфер обмена лишь ограниченное количество типов данных, но что насчет упоминания о «недоверенных скриптах»? Можем ли мы выполнить код «доверенным» скриптом, который позволит нам записывать другие типы данных в буфер обмена?

Свойство isTrusted

Возможно, к «доверенным» относятся события, имеющие свойство isTrusted. isTrusted — это read‑only свойство, которое принимает значение true только если событие было отправлено пользователем или пользовательским агентом, а не сгенерировано скриптом.

document.addEventListener("copy", (e) => {
  if (e.isTrusted) {
    // This event was triggered by the user agent
  }
})

Под «отправкой пользовательским агентом» подразумевается, что оно выполнялось пользователем — например, событие копирования, вызванное нажатием пользователем комбинации клавиш Ctrl+C. Это противоположно искусственному вызову события скриптом, отправленного через dispatchEvent().

document.addEventListener("copy", (e) => {
  console.log("e.isTrusted is " + e.isTrusted);
});

document.dispatchEvent(new ClipboardEvent("copy"));
//=> "e.isTrusted is false"

Давайте посмотрим, какие события буфера обмена существуют и позволяют ли они нам записывать произвольные типы данных в буфер обмена.

Clipboard Events API

ClipboardEvent отправляется для событий копирования, вырезания и вставки и содержит свойство clipboardData типа DataTransfer. DataTransfer используется Clipboard Event API для хранения нескольких представлений данных.

Запись в буфер обмена в событии copy выполняется легко:

document.addEventListener("copy", (e) => {
  e.preventDefault(); // Prevent default copy behavior

  e.clipboardData.setData("text/plain", "Hello, world");
  e.clipboardData.setData("text/html", "Hello, <em>world</em>");
});

Чтение из буфера обмена в событии paste тоже выполняется несложно:

document.addEventListener("paste", (e) => {
  e.preventDefault(); // Prevent default paste behavior

  const html = e.clipboardData.getData("text/html");
  if (html) {
    // Do stuff with HTML...
  }
});

Главный вопрос — можем ли мы записать JSON в буфер обмена?

document.addEventListener("copy", (e) => {
  e.preventDefault();

  const json = JSON.stringify({ message: "Hello" });
  e.clipboardData.setData("application/json", json); // No error
});

Ошибок нет, но действительно ли в буфер обмена был записан JSON? Давайте проверим это, написав обработчик для события вставки, который пройдет по всем сущностям в буфере обмена и выведет их тип в консоль:

document.addEventListener("paste", (e) => {
  for (const item of e.clipboardData.items) {
    const { kind, type } = item;
    if (kind === "string") {
      item.getAsString((content) => {
        console.log({ type, content });
      });
    }
  }
});

Добавив эти два обработчика и выполнив копирование и вставку, мы увидим в консоли следующий вывод:

{ "type": "application/json", content: "{\"message\":\"Hello\"}" }

Работает! Похоже, что clipboardData.setData не ограничивает типы данных так, как это делает асинхронный метод write.

Но почему? Почему мы можем читать и записывать произвольные типы данных, используя clipboardData, но не можем это делать с помощью асинхронного Clipboard API?

История clipboardData

Относительно новый асинхронный Clipboard API был добавлен в спецификацию в 2017 году, в то время как clipboardData существует уже очень давно. Черновик W3C для Clipboard API от 2006 года описывает clipboardData и его методы setData и getData (они показывают нам, что MIME‑типы на тот момент не использовались).

setData() This takes one or two parameters. The first must be set to either 'text' or 'URL' (case-insensitive).

getData() This takes one parameter, that allows the target to request a specific type of data.

Но оказывается, что clipboardData ещё старше этого документа 2006 года. Взгляните на эту цитату из раздела «Статус этого документа»:

In large part [this document] describes the functionalities as implemented in Internet Explorer...

The intention of this document is [...] to specify what actually works in current browsers, or [be] a simple target for them to improve interoperability, rather than adding new features.

В статье от 2003 года описывается, как в те времена в Internet Explorer 4 и выше можно было использовать clipboardData для чтения буфера обмена пользователя без его согласия. Поскольку Internet Explorer 4 был выпущен в 1997 году, похоже, что интерфейсу clipboardData как минимум 26 лет на момент написания статьи.

MIME‑типы появились в спецификации в 2011 году:

The dataType argument is a string, for example but not limited to a MIME type...

If a script calls getData('text/html').

На тот момент спецификация еще не определяла, какие типы данных следует использовать.

While it is possible to use any string for setData()'s type argument, sticking to common types is recommended.

[Issue] Should we list some "common types"?

Возможность использовать любую строку в setData и getData сохраняется и сегодня. Например:

document.addEventListener("copy", (e) => {
  e.preventDefault();
  e.clipboardData.setData("foo bar baz", "Hello, world");
});

document.addEventListener("paste", (e) => {
  const content = e.clipboardData.getData("foo bar baz");
  if (content) {
    console.log(content); // Logs "Hello, world!"
  }
});

Если вы вставите сниппет выше в DevTools и нажмете копирование и вставку, то вы увидите сообщение «Hello, world» в консоли.

Судя по всему, возможность использовать данные любого типа в clipboardData сохранили по историческим причинам. «Don't break the web».

Возвращаясь к isTrusted

Давайте снова рассмотрим предложение из раздела «об обязательных типах данных»:

The data types that untrusted scripts are allowed to write to the clipboard are limited as a security precaution.

Что произойдет, если мы попробуем записать в буфер обмена данные в синтетическом событии буфера обмена?

document.addEventListener("copy", (e) => {
  e.preventDefault();
  e.clipboardData.setData("text/plain", "Hello");
});

document.dispatchEvent(new ClipboardEvent("copy", {
  clipboardData: new DataTransfer(),
}));

Оно успешно отработает, но не внесет изменений в буфер обмена. Это ожидаемое поведение, описанное в спецификации:

Synthetic cut and copy events must not modify data on the system clipboard.

Synthetic paste events must not give a script access to data on the real system clipboard.

Только события копирования и вставки, которые вызываются пользовательским агентом, могут вносить изменения в буфер обмена. Это вполне логично, ведь никто не хочет, чтобы сайты могли свободно читать содержимое буфера обмена и, например, красть пароли.


Подведем итог:

  • Асинхронный Clipboard API, появившийся в 2017 году, ограничивает типы данных, которые могут быть записаны в буфер обмена и прочитаны из него. Однако он может читать и записывать данные в буфер обмена в любое время, при условии, что пользователь предоставил на это разрешение (и страница находится в фокусе).

  • Более старый Clipboard Events API не имеет строгих ограничений на типы данных, которые могут быть записаны в буфер обмена и прочитаны из него. Однако его можно использовать только в обработчиках событий копирования и вставки, вызванных пользовательским агентом (то есть когда isTrusted равен true).

Судя по всему, использование Clipboard Events API — это единственный вариант, если вы хотите записывать в буфер обмена не только текст, HTML или изображения. В этом контексте у него меньше ограничений.

Но что если вы хотите сделать кнопку «Копировать», которая будет записывать нестандартные типы данных в буфер обмена? Непохоже, что вы сможете использовать Clipboard Events API, если пользователь не вызовет событие копирования, верно?

Создаем кнопку копирования с записью произвольных данных

Я проверил работу кнопок копирования в разных веб‑приложениях и посмотрел, что записывается в буфер обмена. Результат оказался интересным.

У Google Docs есть кнопка копирования, доступная в меню правой кнопки мыши.

Эта кнопка записывает в буфер обмена три представления данных:

  • text/plain

  • text/html

  • application/x-vnd.google-docs-document-slice-clip+wrapped

Примечание: третье представление содержит JSON‑данные.

Они записывают в буфер собственный тип данных, значит асинхронный Clipboard API не используется. Как же они это делают через обработчик кликов?

Я запустил профилировщик, нажал «Копировать» и изучил результат. Оказалось, что при нажатии на кнопку копирования срабатывает вызов document.execCommand("copy").

Это меня удивило. Первой мыслью было: «Разве execCommand не является устаревшим методом копирования текста в буфер?»

Так и есть, но Google по какой‑то причине использует его. Особенность execCommand в том, что он позволяет программно отправить доверенное событие копирования, как если бы это выполнил пользователь.

document.addEventListener("copy", (e) => {
  console.log("e.isTrusted is " + e.isTrusted);
});

document.execCommand("copy");
//=> "e.isTrusted is true"

Примечание: Safari требует активного выделения для отправки события копирования с помощью execCommand("copy"). Его можно имитировать, добавив не пустой input‑элемент в DOM и выбрав его перед вызовом execCommand("copy"), после чего input может быть удален из DOM.

Итак, использование execCommand позволяет нам записывать произвольные данные в буфер обмена в ответ на события кликов. Отлично!

Что насчет вставки? Можем ли мы использовать execCommand("paste")?

Создаем кнопку вставки

Давайте проверим кнопку вставки в Google Docs и посмотрим, что делает она.

На моем MacBook я получил уведомление, что для использования вставки требуется установка расширения.

А вот на ноутбуке с Windows кнопка вставки просто сработала.

Странно, откуда эта непоследовательность? Проверить, будет ли работать кнопка вставки, можно, выполнив функцию queryCommandSupported("paste"):

document.queryCommandSupported("paste");

На моем MacBook я получил false в Chrome и Firefox и true в Safari.

Safari, заботясь о приватности, потребовал подтвердить вставку. На мой взгляд, это хорошая идея, поскольку явно дает понять, что сайт собирается прочитать что‑то из буфера обмена.

На ноутбуке с Windows я получил true в Chrome и Edge и false в Firefox. Непоследовательность в Chrome удивляет — почему он позволяет использовать execCommand("paste") в Windows, но не в macOS? Найти какую‑либо информацию по этой теме мне не удалось.

Также я нахожу странным то, что Google не пытается использовать асинхронный Clipboard API, когда функция execCommand("paste") недоступна. Даже если бы они не могли использовать представление application/x-vnd.google-[...], HTML‑представление содержит внутренние ID, которые можно было бы использовать.

<!-- HTML representation, cleaned up -->
<meta charset="utf-8">
<b id="docs-internal-guid-[guid]" style="...">
  <span style="...">Copied text</span>
</b>

Еще один пример приложения с кнопкой вставки – Figma, и у них подход совершенно иной. Давайте посмотрим, как все устроено у них.

Копирование и вставка в Figma

Figma — веб‑приложение, их нативное приложение использует Electron. Давайте посмотрим, что записывает в буфер их кнопка копирования.

При копировании в Figma записываются два представления в буфер обмена: text/plain и text/html. На первый взгляд это удивляет. Как Figma отображает различные опции макетов и стилизации, используя простой HTML?

Но если мы посмотрим на содержимое HTML, мы увидим два пустых элемента span, содержащих свойства data-metadata и data-buffer:

<meta charset="utf-8">
<div>
  <span data-metadata="<!--(figmeta)eyJma[...]9ifQo=(/figmeta)-->"></span>
  <span data-buffer="<!--(figma)ZmlnL[...]P/Ag==(/figma)-->"></span>
</div>
<span style="white-space:pre-wrap;">Text</span>

Примечание: Длина строки data-buffer — примерно 26 000 символов для пустого фрейма. После чего data-buffer растет линейно в соответствии с количеством копируемого содержимого.

Выглядит как base64 — по eyJ в начале строки мы можем определить, что data-metadata — строка JSON, закодированная в base64. При декодировании data-metadata с помощью JSON.parse(atob()) мы получаем следующее:

{
  "fileKey": "4XvKUK38NtRPZASgUJiZ87",
  "pasteID": 1261442360,
  "dataType": "scene"
}

Примечание: исходные fileKey и pasteID были заменены.

Но что насчет data-buffer? Декодирование из base64 дает нам следующее:

fig-kiwiF\x00\x00\x00\x1CK\x00\x00µ½\v\x9CdI[...]\x197Ü\x83\x03

Похоже на бинарный формат. Немного покопавшись, я пришел к выводу, что речь идет о формате сообщений Kiwi, придуманный со-основателем и бывшим CTO Figma Evan Wallace и использующийся для кодирования файлов формата .fig.

Поскольку Kiwi основывается на заданной схеме, не зная ее, мы не сможем распарсить полученные при декодировании данные. На наше счастье, Evan создал общедоступный парсер файлов .fig. Давайте попробуем использовать его на нашем буфере!

Для конвертации буфера в файл .fig я написал простенький скрипт, генерирующий Blob URL:

const base64 = "ZmlnL[...]P/Ag==";
const blob = base64toBlob(base64, "application/octet-stream");

console.log(URL.createObjectURL(blob));
//=> blob:<origin>/1fdf7c0a-5b56-4cb5-b7c0-fb665122b2ab

Затем я скачал полученный blob как файл формата .fig, загрузил его в парсер и voilà:

Получается, что при копировании в Figma создается небольшой Figma‑файл и кодируется как base64. Полученная строка затем копируется в свойство data-buffer пустого HTML‑элемента span и сохраняется в буфер обмена пользователя.

Преимущества использования HTML

На первый взгляд мне показалось это немного нелепым, но у такого подхода есть серьезные преимущества. Чтобы понять почему, нужно обратить внимание на то, как web-based Clipboard API взаимодействует с Clipboard API разных операционных систем.

Windows, macOS и Linux имеют разные форматы для записи данных в буфер обмена. Для записи HTML в буфер обмена в Windows есть CF_HTML, а в macOSNSPasteboard.PasteboardType.html.

Все операционные системы предоставляют типы для “стандартных” форматов (plain text, HTML, PNG-изображения). Но какой формат ОС должен использовать браузер, когда пользователь пытается скопировать произвольный формат application/foo-bar в буфер обмена?

Поскольку подходящего варианта не существует, браузер не записывает его в каком-либо из поддерживаемых буфером обмена операционной системы форматах. Вместо этого создается только отображение, поддерживаемое только браузером, из-за чего произвольные данные удается копировать между разными вкладками в браузере, но не приложениями.

Вот почему использование распространенных форматов text/plain, text/html и image/png настолько удобно. Они соответствуют форматам, часто используемым буфером обмена ОС и могут быть легко прочитаны другими приложениями, что дает возможность использовать копирование и вставку между приложениями. В случае Figma использование text/html позволяет скопировать Figma-элемент с figma.com и вставить его в нативном приложении и наоборот.

Что записывается браузерами в буфер обмена при копировании кастомных типов?

Мы узнали, что можно читать и записывать собственные типы данных, используя буфер обмена между вкладками браузера, но не между приложениями. Но что именно записывается в буфер обмена ОС, когда мы пишем собственный тип данных в веб‑буфер обмена?

Я выполнил следующий код в обработчике события copy в каждом из основных браузеров на моем MacBook:

document.addEventListener("copy", (e) => {
  e.preventDefault();
  e.clipboardData.setData("text/plain", "Hello, world");
  e.clipboardData.setData("text/html", "<em>Hello, world</em>");
  e.clipboardData.setData("application/json", JSON.stringify({ type: "Hello, world" }));
  e.clipboardData.setData("foo bar baz", "Hello, world");
});

После чего проверил содержимое буфера обмена с помощью Pasteboard Viewer. Chrome добавляет четыре записи в Pasteboard:

  • public.html содержит HTML-представление.

  • public.utf8-plain-text содержит текстовое представление.

  • org.chromium.web-custom-data содержит кастомное представление.

  • org.chromium.source-url содержит URL страницы, с которой производилось копирование.

Просмотрев содержимое org.chromium.web-custom-data, мы увидим скопированные нами данные:

Представьте, что "î" с акцентом и непоследовательные переносы строк – результат некорректного отображения символов переноса строки.
Представьте, что "î" с акцентом и непоследовательные переносы строк – результат некорректного отображения символов переноса строки.

Firefox также создает записи public.html и public.utf8-plain-text, но записывает кастомные данные в org.mozilla.custom-clipdata. URL источника при этом никуда не записывается, в отличие от Chrome.

Как вы, наверное, догадались, Safari тоже создает записи public.html и public.utf8-plain-text. Кастомные данные пишутся в com.apple.WebKit.custom-pasteboard-data и, что интересно, хранит в ней полный список представлений (включая HTML и plain text), а также URL источника.

Примечание: Safari позволяет копировать данные между вкладками только если URL (домен) источника в них совпадает. Подобное ограничение не наблюдается в Chrome или Firefox, несмотря на то, что Chrome также сохраняет URL источника.

Raw Clipboard Access для веб-приложений

В 2019 году было предложено создание API Raw Clipboard Access для прямого доступа к записи и чтению буфера обмена операционной системы.

Следующий отрывок из раздела “Мотивация” на chromestatus.com для API Raw Clipboard Access коротко описывает его преимущества:

Without Raw Clipboard Access [...] web applications are generally limited to a small subset of formats, and are unable to interoperate with the long tail of formats. For example, Figma and Photopea are unable to interoperate with most image formats.

Однако предложение на добавление этого API было отклонено, в связи с проблемами с безопасностью, такими как возможность удаленного выполнения кода в нативных приложениях.

Новое предложение по записи кастомных данных в буфер обмена – Custom Formats (также его называют Pickling).

Web Custom Formats (Pickling)

В 2022 году Chromium реализовал поддержку Web Custom Formats в асинхронном Clipboard API.

Он позволяет веб‑приложениям записывать кастомные данные через асинхронный Clipboard API, добавляя префикс “web “ к типам данных.

// Create JSON blob
const json = JSON.stringify({ message: "Hello, world" });
const jsonBlob = new Blob([json], { type: "application/json" });

// Write JSON blob to clipboard as a Web Custom Format
const clipboardItem = new ClipboardItem({
  [`web ${jsonBlob.type}`]: jsonBlob,
});
navigator.clipboard.write([clipboardItem]);

Чтение кастомных данных осуществляется также с помощью Clipboard API:

const items = await navigator.clipboard.read();
for (const item of items) {
  if (item.types.includes("web application/json")) {
    const blob = await item.getType("web application/json");
    const json = await blob.text();
    // Do stuff with JSON...
  }
}

Более интересно то, что данные записываются в системный буфер обмена. При записи в системный буфер обмена записывается следующая информация:

  • Маппинг типов данных с самими данными

  • Записи буфера обмена для каждого типа данных

На macOS маппинг записывается в org.w3.web-custom-format.map и выглядит следующим образом:

{
  "application/json": "org.w3.web-custom-format.type-0",
  "application/octet-stream": "org.w3.web-custom-format.type-1"
}

Ключи org.w3.web-custom-format.type-[index] соответствуют записям в системном буфере обмена, содержащим необработанные данные из блобов. Это позволяет нативным приложениям обращаться к маппингу, чтобы определить, доступно ли данное представление, и затем прочитать необработанные данные, которые содержатся в записи буфера обмена.

Примечание: Windows и Linux используют другую схему именования для маппинга и записей в буфере обмена.

Это позволяет исключить проблемы с безопасностью, так как веб-приложения не могут записывать необработанные данные в произвольный формат системного буфера обмена. С этим связана проблема совместимости, явно описанная в спецификации Pickling в асинхронном Clipboard API:

Non-goals

Allow interoperability with legacy native applications, without update. This was explored in a raw clipboard proposal, and may be explored further in the future, but comes with significant security challenges (remote code execution in system native applications).

Как следствие, для обеспечения совместимости буфера обмена с веб‑приложениями при использовании нестандартных типов данных потребуется обновлять нативные приложения.

Web Custom Formats стали доступны в браузерах, основанных на Chromium, с 2022 года, но остальные браузеры до сих пор не реализовали это API.

Заключение

На данный момент нет хорошего способа записи собственных типов данных в буфер обмена, который работал бы во всех браузерах. Подход Figma с копированием закодированных в base64 строк в HTML‑представление груб, но эффективен для обхода ряда ограничений, связанных с работой с Clipboard API. Это неплохой подход для передачи собственного типа данных через буфер обмена.

Я нахожу предложение о введении Web Custom Formats интересным и надеюсь, что оно будет реализовано основными браузерами. Его реализация позволила бы безопасно и удобно записывать кастомные форматы в буфер обмена.

Благодарю за прочтение и надеюсь, что статья была вам интересна.