Save attachments with macOS quarantine attribute
* Attachments: Always save file to downloads directory, show toast * Add new build:dev command for casual builds
This commit is contained in:
		
					parent
					
						
							
								65befde0fa
							
						
					
				
			
			
				commit
				
					
						1bf9ca7233
					
				
			
		
					 11 changed files with 202 additions and 52 deletions
				
			
		|  | @ -782,6 +782,16 @@ | ||||||
|     "message": "A voice message must have only one attachment.", |     "message": "A voice message must have only one attachment.", | ||||||
|     "description": "Shown in toast if tries to record a voice note with any staged attachments" |     "description": "Shown in toast if tries to record a voice note with any staged attachments" | ||||||
|   }, |   }, | ||||||
|  |   "attachmentSavedToDownloads": { | ||||||
|  |     "message": "Attachment saved as \"$name$\" in your Downloads folder. Click to show.", | ||||||
|  |     "description": "Shown after user selects to save to downloads", | ||||||
|  |     "placeholders": { | ||||||
|  |       "name": { | ||||||
|  |         "content": "$1", | ||||||
|  |         "example": "proof.jpg" | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   "you": { |   "you": { | ||||||
|     "message": "You", |     "message": "You", | ||||||
|     "description": "In Android theme, shown in quote if you or someone else replies to you" |     "description": "In Android theme, shown in quote if you or someone else replies to you" | ||||||
|  |  | ||||||
|  | @ -1,11 +1,22 @@ | ||||||
| const crypto = require('crypto'); | const crypto = require('crypto'); | ||||||
| const path = require('path'); | const path = require('path'); | ||||||
|  | const { app, shell, remote } = require('electron'); | ||||||
| 
 | 
 | ||||||
| const pify = require('pify'); | const pify = require('pify'); | ||||||
| const glob = require('glob'); | const glob = require('glob'); | ||||||
| const fse = require('fs-extra'); | const fse = require('fs-extra'); | ||||||
| const toArrayBuffer = require('to-arraybuffer'); | const toArrayBuffer = require('to-arraybuffer'); | ||||||
| const { map, isArrayBuffer, isString } = require('lodash'); | const { map, isArrayBuffer, isString } = require('lodash'); | ||||||
|  | const sanitizeFilename = require('sanitize-filename'); | ||||||
|  | const getGuid = require('uuid/v4'); | ||||||
|  | 
 | ||||||
|  | let xattr; | ||||||
|  | try { | ||||||
|  |   // eslint-disable-next-line global-require, import/no-extraneous-dependencies
 | ||||||
|  |   xattr = require('fs-xattr'); | ||||||
|  | } catch (e) { | ||||||
|  |   console.log('x-attr dependncy did not load successfully'); | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| const PATH = 'attachments.noindex'; | const PATH = 'attachments.noindex'; | ||||||
| const STICKER_PATH = 'stickers.noindex'; | const STICKER_PATH = 'stickers.noindex'; | ||||||
|  | @ -153,6 +164,70 @@ exports.copyIntoAttachmentsDirectory = root => { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | exports.writeToDownloads = async ({ data, name }) => { | ||||||
|  |   const appToUse = app || remote.app; | ||||||
|  |   const downloadsPath = | ||||||
|  |     appToUse.getPath('downloads') || appToUse.getPath('home'); | ||||||
|  |   const sanitized = sanitizeFilename(name); | ||||||
|  | 
 | ||||||
|  |   const extension = path.extname(sanitized); | ||||||
|  |   const basename = path.basename(sanitized, extension); | ||||||
|  |   const getCandidateName = count => `${basename} (${count})${extension}`; | ||||||
|  | 
 | ||||||
|  |   const existingFiles = await fse.readdir(downloadsPath); | ||||||
|  |   let candidateName = sanitized; | ||||||
|  |   let count = 0; | ||||||
|  |   while (existingFiles.includes(candidateName)) { | ||||||
|  |     count += 1; | ||||||
|  |     candidateName = getCandidateName(count); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   const target = path.join(downloadsPath, candidateName); | ||||||
|  |   const normalized = path.normalize(target); | ||||||
|  |   if (!normalized.startsWith(downloadsPath)) { | ||||||
|  |     throw new Error('Invalid filename!'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   await fse.writeFile(normalized, Buffer.from(data)); | ||||||
|  | 
 | ||||||
|  |   if (process.platform === 'darwin' && xattr) { | ||||||
|  |     // kLSQuarantineTypeInstantMessageAttachment
 | ||||||
|  |     const type = '0003'; | ||||||
|  | 
 | ||||||
|  |     // Hexadecimal seconds since epoch
 | ||||||
|  |     const timestamp = Math.trunc(Date.now() / 1000).toString(16); | ||||||
|  | 
 | ||||||
|  |     const appName = 'Signal'; | ||||||
|  |     const guid = getGuid(); | ||||||
|  | 
 | ||||||
|  |     // https://ilostmynotes.blogspot.com/2012/06/gatekeeper-xprotect-and-quarantine.html
 | ||||||
|  |     const attrValue = `${type};${timestamp};${appName};${guid}`; | ||||||
|  | 
 | ||||||
|  |     await xattr.set(normalized, 'com.apple.quarantine', attrValue); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     fullPath: normalized, | ||||||
|  |     name: candidateName, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | exports.openFileInDownloads = async name => { | ||||||
|  |   const shellToUse = shell || remote.shell; | ||||||
|  |   const appToUse = app || remote.app; | ||||||
|  | 
 | ||||||
|  |   const downloadsPath = | ||||||
|  |     appToUse.getPath('downloads') || appToUse.getPath('home'); | ||||||
|  |   const target = path.join(downloadsPath, name); | ||||||
|  | 
 | ||||||
|  |   const normalized = path.normalize(target); | ||||||
|  |   if (!normalized.startsWith(downloadsPath)) { | ||||||
|  |     throw new Error('Invalid filename!'); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   shellToUse.showItemInFolder(normalized); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| //      createWriterForNew :: AttachmentsPath ->
 | //      createWriterForNew :: AttachmentsPath ->
 | ||||||
| //                            ArrayBuffer ->
 | //                            ArrayBuffer ->
 | ||||||
| //                            IO (Promise RelativePath)
 | //                            IO (Promise RelativePath)
 | ||||||
|  |  | ||||||
|  | @ -119,6 +119,8 @@ function initializeMigrations({ | ||||||
|     getPath, |     getPath, | ||||||
|     getStickersPath, |     getStickersPath, | ||||||
|     getTempPath, |     getTempPath, | ||||||
|  |     openFileInDownloads, | ||||||
|  |     writeToDownloads, | ||||||
|   } = Attachments; |   } = Attachments; | ||||||
|   const { |   const { | ||||||
|     getImageDimensions, |     getImageDimensions, | ||||||
|  | @ -187,11 +189,13 @@ function initializeMigrations({ | ||||||
|     loadPreviewData, |     loadPreviewData, | ||||||
|     loadQuoteData, |     loadQuoteData, | ||||||
|     loadStickerData, |     loadStickerData, | ||||||
|  |     openFileInDownloads, | ||||||
|     readAttachmentData, |     readAttachmentData, | ||||||
|     readDraftData, |     readDraftData, | ||||||
|     readStickerData, |     readStickerData, | ||||||
|     readTempData, |     readTempData, | ||||||
|     run, |     run, | ||||||
|  |     writeToDownloads, | ||||||
|     processNewAttachment: attachment => |     processNewAttachment: attachment => | ||||||
|       MessageType.processNewAttachment(attachment, { |       MessageType.processNewAttachment(attachment, { | ||||||
|         writeNewAttachmentData, |         writeNewAttachmentData, | ||||||
|  |  | ||||||
|  | @ -26,8 +26,11 @@ | ||||||
|     getAbsoluteTempPath, |     getAbsoluteTempPath, | ||||||
|     deleteDraftFile, |     deleteDraftFile, | ||||||
|     deleteTempFile, |     deleteTempFile, | ||||||
|  |     openFileInDownloads, | ||||||
|  |     readAttachmentData, | ||||||
|     readDraftData, |     readDraftData, | ||||||
|     writeNewDraftData, |     writeNewDraftData, | ||||||
|  |     writeToDownloads, | ||||||
|   } = window.Signal.Migrations; |   } = window.Signal.Migrations; | ||||||
|   const { |   const { | ||||||
|     getOlderMessagesByConversation, |     getOlderMessagesByConversation, | ||||||
|  | @ -87,6 +90,44 @@ | ||||||
|       return { toastMessage: i18n('conversationReturnedToInbox') }; |       return { toastMessage: i18n('conversationReturnedToInbox') }; | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
|  |   Whisper.FileSavedToast = Whisper.ToastView.extend({ | ||||||
|  |     className: 'toast toast-clickable', | ||||||
|  |     initialize(options) { | ||||||
|  |       if (!options.name) { | ||||||
|  |         throw new Error('FileSavedToast: name option was not provided!'); | ||||||
|  |       } | ||||||
|  |       this.name = options.name; | ||||||
|  |       this.timeout = 10000; | ||||||
|  | 
 | ||||||
|  |       if (window.getInteractionMode() === 'keyboard') { | ||||||
|  |         setTimeout(() => { | ||||||
|  |           this.$el.focus(); | ||||||
|  |         }, 1); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     events: { | ||||||
|  |       click: 'onClick', | ||||||
|  |       keydown: 'onKeydown', | ||||||
|  |     }, | ||||||
|  |     onClick() { | ||||||
|  |       openFileInDownloads(this.name); | ||||||
|  |       this.close(); | ||||||
|  |     }, | ||||||
|  |     onKeydown(event) { | ||||||
|  |       if (event.key !== 'Enter' && event.key !== ' ') { | ||||||
|  |         return; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       event.preventDefault(); | ||||||
|  |       event.stopPropagation(); | ||||||
|  | 
 | ||||||
|  |       openFileInDownloads(this.name); | ||||||
|  |       this.close(); | ||||||
|  |     }, | ||||||
|  |     render_attributes() { | ||||||
|  |       return { toastMessage: i18n('attachmentSavedToDownloads', this.name) }; | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
| 
 | 
 | ||||||
|   const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; |   const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; | ||||||
|   Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ |   Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ | ||||||
|  | @ -588,9 +629,16 @@ | ||||||
|       this.$('.timeline-placeholder').append(this.timelineView.el); |       this.$('.timeline-placeholder').append(this.timelineView.el); | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     showToast(ToastView) { |     showToast(ToastView, options) { | ||||||
|       const toast = new ToastView(); |       const toast = new ToastView(options); | ||||||
|       toast.$el.appendTo(this.$el); | 
 | ||||||
|  |       const lightboxEl = $('.module-lightbox'); | ||||||
|  |       if (lightboxEl.length > 0) { | ||||||
|  |         toast.$el.appendTo(lightboxEl); | ||||||
|  |       } else { | ||||||
|  |         toast.$el.appendTo(this.$el); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       toast.render(); |       toast.render(); | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|  | @ -1726,12 +1774,13 @@ | ||||||
| 
 | 
 | ||||||
|         const saveAttachment = async ({ attachment, message } = {}) => { |         const saveAttachment = async ({ attachment, message } = {}) => { | ||||||
|           const timestamp = message.sent_at; |           const timestamp = message.sent_at; | ||||||
|           Signal.Types.Attachment.save({ |           const name = await Signal.Types.Attachment.save({ | ||||||
|             attachment, |             attachment, | ||||||
|             document, |             readAttachmentData, | ||||||
|             getAbsolutePath: getAbsoluteAttachmentPath, |             writeToDownloads, | ||||||
|             timestamp, |             timestamp, | ||||||
|           }); |           }); | ||||||
|  |           this.showToast(Whisper.FileSavedToast, { name }); | ||||||
|         }; |         }; | ||||||
| 
 | 
 | ||||||
|         const onItemClick = async ({ message, attachment, type }) => { |         const onItemClick = async ({ message, attachment, type }) => { | ||||||
|  | @ -1916,18 +1965,19 @@ | ||||||
|       this.downloadAttachment({ attachment, timestamp, isDangerous }); |       this.downloadAttachment({ attachment, timestamp, isDangerous }); | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     downloadAttachment({ attachment, timestamp, isDangerous }) { |     async downloadAttachment({ attachment, timestamp, isDangerous }) { | ||||||
|       if (isDangerous) { |       if (isDangerous) { | ||||||
|         this.showToast(Whisper.DangerousFileTypeToast); |         this.showToast(Whisper.DangerousFileTypeToast); | ||||||
|         return; |         return; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       Signal.Types.Attachment.save({ |       const name = await Signal.Types.Attachment.save({ | ||||||
|         attachment, |         attachment, | ||||||
|         document, |         readAttachmentData, | ||||||
|         getAbsolutePath: getAbsoluteAttachmentPath, |         writeToDownloads, | ||||||
|         timestamp, |         timestamp, | ||||||
|       }); |       }); | ||||||
|  |       this.showToast(Whisper.FileSavedToast, { name }); | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     async displayTapToViewMessage(messageId) { |     async displayTapToViewMessage(messageId) { | ||||||
|  | @ -2124,13 +2174,14 @@ | ||||||
|       ); |       ); | ||||||
| 
 | 
 | ||||||
|       const onSave = async (options = {}) => { |       const onSave = async (options = {}) => { | ||||||
|         Signal.Types.Attachment.save({ |         const name = await Signal.Types.Attachment.save({ | ||||||
|           attachment: options.attachment, |           attachment: options.attachment, | ||||||
|           document, |  | ||||||
|           index: options.index + 1, |           index: options.index + 1, | ||||||
|           getAbsolutePath: getAbsoluteAttachmentPath, |           readAttachmentData, | ||||||
|  |           writeToDownloads, | ||||||
|           timestamp: options.message.get('sent_at'), |           timestamp: options.message.get('sent_at'), | ||||||
|         }); |         }); | ||||||
|  |         this.showToast(Whisper.FileSavedToast, { name }); | ||||||
|       }; |       }; | ||||||
| 
 | 
 | ||||||
|       const props = { |       const props = { | ||||||
|  |  | ||||||
|  | @ -11,6 +11,7 @@ | ||||||
|     templateName: 'toast', |     templateName: 'toast', | ||||||
|     initialize() { |     initialize() { | ||||||
|       this.$el.hide(); |       this.$el.hide(); | ||||||
|  |       this.timeout = 2000; | ||||||
|     }, |     }, | ||||||
| 
 | 
 | ||||||
|     close() { |     close() { | ||||||
|  | @ -24,8 +25,9 @@ | ||||||
|           _.result(this, 'render_attributes', '') |           _.result(this, 'render_attributes', '') | ||||||
|         ) |         ) | ||||||
|       ); |       ); | ||||||
|  |       this.$el.attr('tabIndex', 0); | ||||||
|       this.$el.show(); |       this.$el.show(); | ||||||
|       setTimeout(this.close.bind(this), 2000); |       setTimeout(this.close.bind(this), this.timeout); | ||||||
|     }, |     }, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -47,6 +47,7 @@ | ||||||
|     "dev:typed-scss": "yarn build:typed-scss -w", |     "dev:typed-scss": "yarn build:typed-scss -w", | ||||||
|     "dev:storybook": "start-storybook -p 6006 -s ./", |     "dev:storybook": "start-storybook -p 6006 -s ./", | ||||||
|     "build": "run-s --print-label build:grunt build:typed-scss build:webpack build:release", |     "build": "run-s --print-label build:grunt build:typed-scss build:webpack build:release", | ||||||
|  |     "build:dev": "run-s --print-label build:grunt build:typed-scss build:webpack", | ||||||
|     "build:grunt": "yarn grunt", |     "build:grunt": "yarn grunt", | ||||||
|     "build:typed-scss": "tsm sticker-creator", |     "build:typed-scss": "tsm sticker-creator", | ||||||
|     "build:webpack": "cross-env NODE_ENV=production webpack", |     "build:webpack": "cross-env NODE_ENV=production webpack", | ||||||
|  | @ -56,6 +57,9 @@ | ||||||
|     "verify": "run-p --print-label verify:*", |     "verify": "run-p --print-label verify:*", | ||||||
|     "verify:ts": "tsc --noEmit" |     "verify:ts": "tsc --noEmit" | ||||||
|   }, |   }, | ||||||
|  |   "optionalDependencies": { | ||||||
|  |     "fs-xattr": "0.3.0" | ||||||
|  |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#00fd0f8a6623c6683280976d2a92b41d09c744bc", |     "@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#00fd0f8a6623c6683280976d2a92b41d09c744bc", | ||||||
|     "@sindresorhus/is": "0.8.0", |     "@sindresorhus/is": "0.8.0", | ||||||
|  | @ -124,6 +128,7 @@ | ||||||
|     "reselect": "4.0.0", |     "reselect": "4.0.0", | ||||||
|     "rimraf": "2.6.2", |     "rimraf": "2.6.2", | ||||||
|     "sanitize.css": "11.0.0", |     "sanitize.css": "11.0.0", | ||||||
|  |     "sanitize-filename": "1.6.3", | ||||||
|     "semver": "5.4.1", |     "semver": "5.4.1", | ||||||
|     "sharp": "0.23.0", |     "sharp": "0.23.0", | ||||||
|     "spellchecker": "3.7.0", |     "spellchecker": "3.7.0", | ||||||
|  |  | ||||||
|  | @ -380,6 +380,10 @@ | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .toast-clickable { | ||||||
|  |   cursor: pointer; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .confirmation-dialog { | .confirmation-dialog { | ||||||
|   .content { |   .content { | ||||||
|     max-width: 350px; |     max-width: 350px; | ||||||
|  |  | ||||||
|  | @ -3,8 +3,6 @@ import moment from 'moment'; | ||||||
| import { isNumber, padStart } from 'lodash'; | import { isNumber, padStart } from 'lodash'; | ||||||
| 
 | 
 | ||||||
| import * as MIME from './MIME'; | import * as MIME from './MIME'; | ||||||
| import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL'; |  | ||||||
| import { saveURLAsFile } from '../util/saveURLAsFile'; |  | ||||||
| import { SignalService } from '../protobuf'; | import { SignalService } from '../protobuf'; | ||||||
| import { | import { | ||||||
|   isImageTypeSupported, |   isImageTypeSupported, | ||||||
|  | @ -326,31 +324,37 @@ export const isVoiceMessage = (attachment: Attachment): boolean => { | ||||||
|   return false; |   return false; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const save = ({ | export const save = async ({ | ||||||
|   attachment, |   attachment, | ||||||
|   document, |  | ||||||
|   index, |   index, | ||||||
|   getAbsolutePath, |   readAttachmentData, | ||||||
|  |   writeToDownloads, | ||||||
|   timestamp, |   timestamp, | ||||||
| }: { | }: { | ||||||
|   attachment: Attachment; |   attachment: Attachment; | ||||||
|   document: Document; |  | ||||||
|   index: number; |   index: number; | ||||||
|   getAbsolutePath: (relativePath: string) => string; |   readAttachmentData: (relativePath: string) => Promise<ArrayBuffer>; | ||||||
|  |   writeToDownloads: (options: { | ||||||
|  |     data: ArrayBuffer; | ||||||
|  |     name: string; | ||||||
|  |   }) => Promise<{ name: string; fullPath: string }>; | ||||||
|   timestamp?: number; |   timestamp?: number; | ||||||
| }): void => { | }): Promise<string> => { | ||||||
|   const isObjectURLRequired = is.undefined(attachment.path); |   if (!attachment.path && !attachment.data) { | ||||||
|   const url = !is.undefined(attachment.path) |     throw new Error('Attachment had neither path nor data'); | ||||||
|     ? getAbsolutePath(attachment.path) |  | ||||||
|     : arrayBufferToObjectURL({ |  | ||||||
|         data: attachment.data, |  | ||||||
|         type: MIME.APPLICATION_OCTET_STREAM, |  | ||||||
|       }); |  | ||||||
|   const filename = getSuggestedFilename({ attachment, timestamp, index }); |  | ||||||
|   saveURLAsFile({ url, filename, document }); |  | ||||||
|   if (isObjectURLRequired) { |  | ||||||
|     URL.revokeObjectURL(url); |  | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   const data = attachment.path | ||||||
|  |     ? await readAttachmentData(attachment.path) | ||||||
|  |     : attachment.data; | ||||||
|  |   const name = getSuggestedFilename({ attachment, timestamp, index }); | ||||||
|  | 
 | ||||||
|  |   const { name: savedFilename } = await writeToDownloads({ | ||||||
|  |     data, | ||||||
|  |     name, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return savedFilename; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export const getSuggestedFilename = ({ | export const getSuggestedFilename = ({ | ||||||
|  |  | ||||||
|  | @ -1135,7 +1135,7 @@ | ||||||
|     "rule": "jQuery-html(", |     "rule": "jQuery-html(", | ||||||
|     "path": "js/views/toast_view.js", |     "path": "js/views/toast_view.js", | ||||||
|     "line": "      this.$el.html(", |     "line": "      this.$el.html(", | ||||||
|     "lineNumber": 21, |     "lineNumber": 22, | ||||||
|     "reasonCategory": "usageTrusted", |     "reasonCategory": "usageTrusted", | ||||||
|     "updated": "2018-09-15T00:38:04.183Z" |     "updated": "2018-09-15T00:38:04.183Z" | ||||||
|   }, |   }, | ||||||
|  | @ -1143,7 +1143,7 @@ | ||||||
|     "rule": "jQuery-appendTo(", |     "rule": "jQuery-appendTo(", | ||||||
|     "path": "js/views/toast_view.js", |     "path": "js/views/toast_view.js", | ||||||
|     "line": "    toast.$el.appendTo(el);", |     "line": "    toast.$el.appendTo(el);", | ||||||
|     "lineNumber": 34, |     "lineNumber": 36, | ||||||
|     "reasonCategory": "usageTrusted", |     "reasonCategory": "usageTrusted", | ||||||
|     "updated": "2019-11-06T19:56:38.557Z", |     "updated": "2019-11-06T19:56:38.557Z", | ||||||
|     "reasonDetail": "Protected from arbitrary input" |     "reasonDetail": "Protected from arbitrary input" | ||||||
|  |  | ||||||
|  | @ -1,17 +0,0 @@ | ||||||
| /** |  | ||||||
|  * @prettier |  | ||||||
|  */ |  | ||||||
| export const saveURLAsFile = ({ |  | ||||||
|   filename, |  | ||||||
|   url, |  | ||||||
|   document, |  | ||||||
| }: { |  | ||||||
|   filename: string; |  | ||||||
|   url: string; |  | ||||||
|   document: Document; |  | ||||||
| }): void => { |  | ||||||
|   const anchorElement = document.createElement('a'); |  | ||||||
|   anchorElement.href = url; |  | ||||||
|   anchorElement.download = filename; |  | ||||||
|   anchorElement.click(); |  | ||||||
| }; |  | ||||||
							
								
								
									
										12
									
								
								yarn.lock
									
										
									
									
									
								
							
							
						
						
									
										12
									
								
								yarn.lock
									
										
									
									
									
								
							|  | @ -7396,6 +7396,11 @@ fs-write-stream-atomic@^1.0.8: | ||||||
|     imurmurhash "^0.1.4" |     imurmurhash "^0.1.4" | ||||||
|     readable-stream "1 || 2" |     readable-stream "1 || 2" | ||||||
| 
 | 
 | ||||||
|  | fs-xattr@0.3.0: | ||||||
|  |   version "0.3.0" | ||||||
|  |   resolved "https://registry.yarnpkg.com/fs-xattr/-/fs-xattr-0.3.0.tgz#019642eacc49f343061af19de4c13543895589ad" | ||||||
|  |   integrity sha512-BixjoRM9etRFyWOtJRcflfu5HqBWLGTYbeHiL196VRUcc/nYgS2px6w4yVaj3XmrN1bk4rZBH82A8u5Z64YcXQ== | ||||||
|  | 
 | ||||||
| fs.realpath@^1.0.0: | fs.realpath@^1.0.0: | ||||||
|   version "1.0.0" |   version "1.0.0" | ||||||
|   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" |   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" | ||||||
|  | @ -14328,6 +14333,13 @@ samsam@1.3.0: | ||||||
|   version "1.3.0" |   version "1.3.0" | ||||||
|   resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" |   resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50" | ||||||
| 
 | 
 | ||||||
|  | sanitize-filename@1.6.3: | ||||||
|  |   version "1.6.3" | ||||||
|  |   resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz#755ebd752045931977e30b2025d340d7c9090378" | ||||||
|  |   integrity sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg== | ||||||
|  |   dependencies: | ||||||
|  |     truncate-utf8-bytes "^1.0.0" | ||||||
|  | 
 | ||||||
| sanitize-filename@^1.6.2: | sanitize-filename@^1.6.2: | ||||||
|   version "1.6.2" |   version "1.6.2" | ||||||
|   resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.2.tgz#01b4fc8809f14e9d22761fe70380fe7f3f902185" |   resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.2.tgz#01b4fc8809f14e9d22761fe70380fe7f3f902185" | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Scott Nonnenberg
				Scott Nonnenberg