From 3fae4516251f3fa04a2016b422211a1a20608961 Mon Sep 17 00:00:00 2001 From: Martin Varmuza Date: Mon, 18 Nov 2024 10:56:56 +0100 Subject: [PATCH] feat(suite): add option to export labeling files --- .../src/actions/suite/metadataActions.ts | 44 ++++++++++++ .../views/settings/SettingsDebug/Metadata.tsx | 32 +++++++++ .../settings/SettingsDebug/SettingsDebug.tsx | 4 ++ packages/utils/src/index.ts | 1 + packages/utils/src/zip.ts | 68 +++++++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 packages/suite/src/views/settings/SettingsDebug/Metadata.tsx create mode 100644 packages/utils/src/zip.ts diff --git a/packages/suite/src/actions/suite/metadataActions.ts b/packages/suite/src/actions/suite/metadataActions.ts index 453df45bedc..e75bf6b2742 100644 --- a/packages/suite/src/actions/suite/metadataActions.ts +++ b/packages/suite/src/actions/suite/metadataActions.ts @@ -3,6 +3,7 @@ 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 { METADATA, METADATA_LABELING } from 'src/actions/suite/constants'; import { Dispatch, GetState } from 'src/types/suite'; @@ -17,6 +18,7 @@ import { 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 } @@ -165,3 +167,45 @@ 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(); + + // todo: handle error, toast "no files to export"? + if (!filesListResult.success || !filesListResult.payload?.length) 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 => { + // todo: handle error, failed to get file content + }); +}; 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..64bebf29b57 --- /dev/null +++ b/packages/suite/src/views/settings/SettingsDebug/Metadata.tsx @@ -0,0 +1,32 @@ +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 789c7947314..9455cee8cf5 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -49,3 +49,4 @@ export * from './throttler'; export * from './extractUrlsFromText'; export * from './isFullPath'; export * from './asciiUtils'; +export * from './zip'; diff --git a/packages/utils/src/zip.ts b/packages/utils/src/zip.ts new file mode 100644 index 00000000000..f60439f09cd --- /dev/null +++ b/packages/utils/src/zip.ts @@ -0,0 +1,68 @@ +export const createZip = (buffers: { name: string; content: ArrayBuffer }[]) => { + const fileEntries: ArrayBuffer[] = []; + let 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', + }); +};