diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 0b9249b49..ba7d5bb47 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -782,6 +782,16 @@ "message": "A voice message must have only one attachment.", "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": { "message": "You", "description": "In Android theme, shown in quote if you or someone else replies to you" diff --git a/app/attachments.js b/app/attachments.js index 393c2c794..a4759b47d 100644 --- a/app/attachments.js +++ b/app/attachments.js @@ -1,11 +1,22 @@ const crypto = require('crypto'); const path = require('path'); +const { app, shell, remote } = require('electron'); const pify = require('pify'); const glob = require('glob'); const fse = require('fs-extra'); const toArrayBuffer = require('to-arraybuffer'); 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 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 -> // ArrayBuffer -> // IO (Promise RelativePath) diff --git a/js/modules/signal.js b/js/modules/signal.js index 672d59d69..f6e15cf04 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -119,6 +119,8 @@ function initializeMigrations({ getPath, getStickersPath, getTempPath, + openFileInDownloads, + writeToDownloads, } = Attachments; const { getImageDimensions, @@ -187,11 +189,13 @@ function initializeMigrations({ loadPreviewData, loadQuoteData, loadStickerData, + openFileInDownloads, readAttachmentData, readDraftData, readStickerData, readTempData, run, + writeToDownloads, processNewAttachment: attachment => MessageType.processNewAttachment(attachment, { writeNewAttachmentData, diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 950183575..367e35c15 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -26,8 +26,11 @@ getAbsoluteTempPath, deleteDraftFile, deleteTempFile, + openFileInDownloads, + readAttachmentData, readDraftData, writeNewDraftData, + writeToDownloads, } = window.Signal.Migrations; const { getOlderMessagesByConversation, @@ -87,6 +90,44 @@ 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; Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ @@ -588,9 +629,16 @@ this.$('.timeline-placeholder').append(this.timelineView.el); }, - showToast(ToastView) { - const toast = new ToastView(); - toast.$el.appendTo(this.$el); + showToast(ToastView, options) { + const toast = new ToastView(options); + + const lightboxEl = $('.module-lightbox'); + if (lightboxEl.length > 0) { + toast.$el.appendTo(lightboxEl); + } else { + toast.$el.appendTo(this.$el); + } + toast.render(); }, @@ -1726,12 +1774,13 @@ const saveAttachment = async ({ attachment, message } = {}) => { const timestamp = message.sent_at; - Signal.Types.Attachment.save({ + const name = await Signal.Types.Attachment.save({ attachment, - document, - getAbsolutePath: getAbsoluteAttachmentPath, + readAttachmentData, + writeToDownloads, timestamp, }); + this.showToast(Whisper.FileSavedToast, { name }); }; const onItemClick = async ({ message, attachment, type }) => { @@ -1916,18 +1965,19 @@ this.downloadAttachment({ attachment, timestamp, isDangerous }); }, - downloadAttachment({ attachment, timestamp, isDangerous }) { + async downloadAttachment({ attachment, timestamp, isDangerous }) { if (isDangerous) { this.showToast(Whisper.DangerousFileTypeToast); return; } - Signal.Types.Attachment.save({ + const name = await Signal.Types.Attachment.save({ attachment, - document, - getAbsolutePath: getAbsoluteAttachmentPath, + readAttachmentData, + writeToDownloads, timestamp, }); + this.showToast(Whisper.FileSavedToast, { name }); }, async displayTapToViewMessage(messageId) { @@ -2124,13 +2174,14 @@ ); const onSave = async (options = {}) => { - Signal.Types.Attachment.save({ + const name = await Signal.Types.Attachment.save({ attachment: options.attachment, - document, index: options.index + 1, - getAbsolutePath: getAbsoluteAttachmentPath, + readAttachmentData, + writeToDownloads, timestamp: options.message.get('sent_at'), }); + this.showToast(Whisper.FileSavedToast, { name }); }; const props = { diff --git a/js/views/toast_view.js b/js/views/toast_view.js index 1482c84a6..e1f1d52df 100644 --- a/js/views/toast_view.js +++ b/js/views/toast_view.js @@ -11,6 +11,7 @@ templateName: 'toast', initialize() { this.$el.hide(); + this.timeout = 2000; }, close() { @@ -24,8 +25,9 @@ _.result(this, 'render_attributes', '') ) ); + this.$el.attr('tabIndex', 0); this.$el.show(); - setTimeout(this.close.bind(this), 2000); + setTimeout(this.close.bind(this), this.timeout); }, }); diff --git a/package.json b/package.json index 9b82e0d49..52d2eb951 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "dev:typed-scss": "yarn build:typed-scss -w", "dev:storybook": "start-storybook -p 6006 -s ./", "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:typed-scss": "tsm sticker-creator", "build:webpack": "cross-env NODE_ENV=production webpack", @@ -56,6 +57,9 @@ "verify": "run-p --print-label verify:*", "verify:ts": "tsc --noEmit" }, + "optionalDependencies": { + "fs-xattr": "0.3.0" + }, "dependencies": { "@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#00fd0f8a6623c6683280976d2a92b41d09c744bc", "@sindresorhus/is": "0.8.0", @@ -124,6 +128,7 @@ "reselect": "4.0.0", "rimraf": "2.6.2", "sanitize.css": "11.0.0", + "sanitize-filename": "1.6.3", "semver": "5.4.1", "sharp": "0.23.0", "spellchecker": "3.7.0", diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 2aaaa2acb..79a3850af 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -380,6 +380,10 @@ } } +.toast-clickable { + cursor: pointer; +} + .confirmation-dialog { .content { max-width: 350px; diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index c742fd4d9..7b86f6021 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -3,8 +3,6 @@ import moment from 'moment'; import { isNumber, padStart } from 'lodash'; import * as MIME from './MIME'; -import { arrayBufferToObjectURL } from '../util/arrayBufferToObjectURL'; -import { saveURLAsFile } from '../util/saveURLAsFile'; import { SignalService } from '../protobuf'; import { isImageTypeSupported, @@ -326,31 +324,37 @@ export const isVoiceMessage = (attachment: Attachment): boolean => { return false; }; -export const save = ({ +export const save = async ({ attachment, - document, index, - getAbsolutePath, + readAttachmentData, + writeToDownloads, timestamp, }: { attachment: Attachment; - document: Document; index: number; - getAbsolutePath: (relativePath: string) => string; + readAttachmentData: (relativePath: string) => Promise; + writeToDownloads: (options: { + data: ArrayBuffer; + name: string; + }) => Promise<{ name: string; fullPath: string }>; timestamp?: number; -}): void => { - const isObjectURLRequired = is.undefined(attachment.path); - const url = !is.undefined(attachment.path) - ? 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); +}): Promise => { + if (!attachment.path && !attachment.data) { + throw new Error('Attachment had neither path nor data'); } + + 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 = ({ diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index fc5fb9a6c..7f2f753fa 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1135,7 +1135,7 @@ "rule": "jQuery-html(", "path": "js/views/toast_view.js", "line": " this.$el.html(", - "lineNumber": 21, + "lineNumber": 22, "reasonCategory": "usageTrusted", "updated": "2018-09-15T00:38:04.183Z" }, @@ -1143,7 +1143,7 @@ "rule": "jQuery-appendTo(", "path": "js/views/toast_view.js", "line": " toast.$el.appendTo(el);", - "lineNumber": 34, + "lineNumber": 36, "reasonCategory": "usageTrusted", "updated": "2019-11-06T19:56:38.557Z", "reasonDetail": "Protected from arbitrary input" diff --git a/ts/util/saveURLAsFile.ts b/ts/util/saveURLAsFile.ts deleted file mode 100644 index 90bc99273..000000000 --- a/ts/util/saveURLAsFile.ts +++ /dev/null @@ -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(); -}; diff --git a/yarn.lock b/yarn.lock index 19844cfd3..20fa51418 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7396,6 +7396,11 @@ fs-write-stream-atomic@^1.0.8: imurmurhash "^0.1.4" 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: version "1.0.0" 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" 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: version "1.6.2" resolved "https://registry.yarnpkg.com/sanitize-filename/-/sanitize-filename-1.6.2.tgz#01b4fc8809f14e9d22761fe70380fe7f3f902185"