diff --git a/packages/suite/src/actions/suite/metadataActions.ts b/packages/suite/src/actions/suite/metadataActions.ts
index 453df45bedc..3ef7469cc8f 100644
--- a/packages/suite/src/actions/suite/metadataActions.ts
+++ b/packages/suite/src/actions/suite/metadataActions.ts
@@ -3,6 +3,8 @@ import { createAction } from '@reduxjs/toolkit';
import { selectDevices } from '@suite-common/wallet-core';
import { Account } from '@suite-common/wallet-types';
import { StaticSessionId } from '@trezor/connect';
+import { createZip } from '@trezor/utils';
+import { notificationsActions } from '@suite-common/toast-notifications';
import { METADATA, METADATA_LABELING } from 'src/actions/suite/constants';
import { Dispatch, GetState } from 'src/types/suite';
@@ -18,6 +20,8 @@ import * as metadataUtils from 'src/utils/suite/metadata';
import { selectSelectedProviderForLabels } from 'src/reducers/suite/metadataReducer';
import type { AbstractMetadataProvider, PasswordManagerState } from 'src/types/suite/metadata';
+import { getProviderInstance } from './metadataProviderActions';
+
export type MetadataAction =
| { type: typeof METADATA.ENABLE }
| { type: typeof METADATA.DISABLE }
@@ -165,3 +169,55 @@ export const encryptAndSaveMetadata = async ({
return providerInstance.setFileContent(fileName, encrypted);
};
+
+export const exportMetadataToLocalFile = () => async (dispatch: Dispatch, getState: GetState) => {
+ const providerInstance = dispatch(
+ getProviderInstance({
+ clientId: selectSelectedProviderForLabels(getState())!.clientId,
+ dataType: 'labels',
+ }),
+ );
+
+ if (!providerInstance) return;
+
+ const filesListResult = await providerInstance.getFilesList();
+
+ if (!filesListResult.success || !filesListResult.payload?.length) {
+ dispatch(
+ notificationsActions.addToast({ type: 'error', error: 'Exporting labels failed' }),
+ );
+
+ return;
+ }
+
+ const files = filesListResult.payload;
+
+ return Promise.all(
+ files.map(file => {
+ return providerInstance.getFileContent(file).then(result => {
+ if (!result.success) throw new Error(result.error);
+
+ return { name: file, content: result.payload };
+ });
+ }),
+ )
+ .then(filesContent => {
+ const zipBlob = createZip(filesContent);
+ // Trigger download
+ const a = document.createElement('a');
+ a.href = URL.createObjectURL(zipBlob);
+ a.download = 'archive.zip';
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(a.href);
+ })
+ .catch(_err => {
+ dispatch(
+ notificationsActions.addToast({ type: 'error', error: 'Exporting labels failed' }),
+ );
+
+ return;
+ });
+};
diff --git a/packages/suite/src/views/settings/SettingsDebug/Metadata.tsx b/packages/suite/src/views/settings/SettingsDebug/Metadata.tsx
new file mode 100644
index 00000000000..5c54842cef8
--- /dev/null
+++ b/packages/suite/src/views/settings/SettingsDebug/Metadata.tsx
@@ -0,0 +1,33 @@
+import { useState } from 'react';
+
+import { Button } from '@trezor/components';
+
+import { ActionColumn, SectionItem, TextColumn } from 'src/components/suite';
+import { useDispatch } from 'src/hooks/suite';
+import { exportMetadataToLocalFile } from 'src/actions/suite/metadataActions';
+
+export const Metadata = () => {
+ const dispatch = useDispatch();
+ const [exporting, setExporting] = useState(false);
+
+ const onClick = () => {
+ setExporting(true);
+ dispatch(exportMetadataToLocalFile()).finally(() => {
+ setExporting(false);
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx b/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx
index a49284d7641..19c0836dfe5 100644
--- a/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx
+++ b/packages/suite/src/views/settings/SettingsDebug/SettingsDebug.tsx
@@ -21,6 +21,7 @@ import { TriggerHighlight } from './TriggerHighlight';
import { Backends } from './Backends';
import { PreField } from './PreField';
import { Tor } from './Tor';
+import { Metadata } from './Metadata';
export const SettingsDebug = () => {
const flags = useSelector(selectSuiteFlags);
@@ -76,6 +77,9 @@ export const SettingsDebug = () => {
{JSON.stringify(flags)}
+
+
+
);
};
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index 083999cf3be..eddf99d124a 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -50,3 +50,4 @@ export * from './extractUrlsFromText';
export * from './isFullPath';
export * from './asciiUtils';
export * from './resolveAfter';
+export * from './zip';
diff --git a/packages/utils/src/zip.ts b/packages/utils/src/zip.ts
new file mode 100644
index 00000000000..38aa9e6c7ef
--- /dev/null
+++ b/packages/utils/src/zip.ts
@@ -0,0 +1,68 @@
+export const createZip = (buffers: { name: string; content: ArrayBuffer }[]) => {
+ const fileEntries: ArrayBuffer[] = [];
+ const centralDirectory: ArrayBuffer[] = [];
+ let offset = 0;
+
+ buffers.forEach(({ name, content }) => {
+ const fileData = content;
+ const fileHeader = new Uint8Array(30 + name.length);
+ const localFileHeader = new DataView(fileHeader.buffer);
+
+ // Local file header signature
+ localFileHeader.setUint32(0, 0x04034b50, true); // "PK\3\4"
+ localFileHeader.setUint16(4, 0x0, true); // Version needed to extract
+ localFileHeader.setUint16(6, 0x0, true); // General purpose bit flag
+ localFileHeader.setUint16(8, 0x0, true); // Compression method (none)
+ localFileHeader.setUint16(10, 0x0, true); // File last mod time
+ localFileHeader.setUint16(12, 0x0, true); // File last mod date
+ localFileHeader.setUint32(14, 0, true); // CRC-32 (skipped for simplicity)
+ localFileHeader.setUint32(18, fileData.byteLength, true); // Compressed size
+ localFileHeader.setUint32(22, fileData.byteLength, true); // Uncompressed size
+ localFileHeader.setUint16(26, name.length, true); // Filename length
+
+ // Filename
+ fileHeader.set(new TextEncoder().encode(name), 30);
+
+ fileEntries.push(fileHeader, fileData);
+
+ // Central directory
+ const centralHeader = new Uint8Array(46 + name.length);
+ const centralView = new DataView(centralHeader.buffer);
+
+ centralView.setUint32(0, 0x02014b50, true); // "PK\1\2"
+ centralView.setUint16(4, 0x0, true); // Version made by
+ centralView.setUint16(6, 0x0, true); // Version needed to extract
+ centralView.setUint16(8, 0x0, true); // General purpose bit flag
+ centralView.setUint16(10, 0x0, true); // Compression method (none)
+ centralView.setUint16(12, 0x0, true); // File last mod time
+ centralView.setUint16(14, 0x0, true); // File last mod date
+ centralView.setUint32(16, 0, true); // CRC-32
+ centralView.setUint32(20, fileData.byteLength, true); // Compressed size
+ centralView.setUint32(24, fileData.byteLength, true); // Uncompressed size
+ centralView.setUint16(28, name.length, true); // Filename length
+ centralView.setUint32(42, offset, true); // Offset of local header
+
+ centralHeader.set(new TextEncoder().encode(name), 46);
+
+ centralDirectory.push(centralHeader);
+ offset += fileHeader.length + fileData.byteLength;
+ });
+
+ // End of central directory record
+ const eocd = new Uint8Array(22);
+ const eocdView = new DataView(eocd.buffer);
+
+ eocdView.setUint32(0, 0x06054b50, true); // "PK\5\6"
+ eocdView.setUint16(8, centralDirectory.length, true); // Total number of entries
+ eocdView.setUint16(10, centralDirectory.length, true); // Total number of entries
+ eocdView.setUint32(
+ 12,
+ centralDirectory.reduce((sum, cd) => sum + cd.byteLength, 0),
+ true,
+ ); // Size of central directory
+ eocdView.setUint32(16, offset, true); // Offset of start of central directory
+
+ return new Blob([...fileEntries, ...centralDirectory, eocd], {
+ type: 'application/zip',
+ });
+};