From 97678c6686dc57d2a767c1743d5929a92e25f73e Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Tue, 25 Oct 2022 15:40:38 +0300 Subject: [PATCH] [MM-47801][MM-45980] Added support for security-scoped bookmarks to allow the MAS build to save files wherever needed (#2315) (#2316) * First pass * [MM-47801] Added support for security-scoped bookmarks to allow the MAS build to save files wherever needed (cherry picked from commit 635a41f998535fd50801756ca87f179797f718e8) Co-authored-by: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> --- entitlements.mas.plist | 4 + scripts/patch_macos_notification_state.js | 3 +- src/common/communication.ts | 1 + src/main/Validator.ts | 1 + src/main/downloadsManager.test.js | 4 +- src/main/downloadsManager.ts | 114 +++++++++++++----- src/main/preload/downloadsDropdown.js | 5 + src/main/views/downloadsDropdownView.ts | 17 ++- .../DownloadsDropdown/Thumbnail.tsx | 20 ++- src/types/downloads.ts | 1 + src/types/window.ts | 3 + 11 files changed, 139 insertions(+), 34 deletions(-) diff --git a/entitlements.mas.plist b/entitlements.mas.plist index a74a97c1e39..6b6ce8a7992 100644 --- a/entitlements.mas.plist +++ b/entitlements.mas.plist @@ -38,5 +38,9 @@ UQ8HT4Q2XM.Mattermost.Desktop com.apple.developer.team-identifier UQ8HT4Q2XM + com.apple.security.files.bookmarks.app-scope + + com.apple.security.files.bookmarks.document-scope + diff --git a/scripts/patch_macos_notification_state.js b/scripts/patch_macos_notification_state.js index 0a3dec4e601..799f1e61b4a 100644 --- a/scripts/patch_macos_notification_state.js +++ b/scripts/patch_macos_notification_state.js @@ -1,9 +1,10 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -const jq = require('node-jq'); const fs = require('fs'); +const jq = require('node-jq'); + jq.run( '.scripts.install = "node-gyp rebuild"', './node_modules/macos-notification-state/package.json', diff --git a/src/common/communication.ts b/src/common/communication.ts index d5f08733dd4..1a3754eb704 100644 --- a/src/common/communication.ts +++ b/src/common/communication.ts @@ -145,6 +145,7 @@ export const REQUEST_HAS_DOWNLOADS = 'request-has-downloads'; export const DOWNLOADS_DROPDOWN_FOCUSED = 'downloads-dropdown-focused'; export const RECEIVE_DOWNLOADS_DROPDOWN_SIZE = 'receive-downloads-dropdown-size'; export const SEND_DOWNLOADS_DROPDOWN_SIZE = 'send-downloads-dropdown-size'; +export const GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION = 'get-downloaded-image-thumbnail-location'; export const OPEN_DOWNLOADS_DROPDOWN_MENU = 'open-downloads-dropdown-menu'; export const CLOSE_DOWNLOADS_DROPDOWN_MENU = 'close-downloads-dropdown-menu'; diff --git a/src/main/Validator.ts b/src/main/Validator.ts index ead99aa5bf4..9e2a1096d3e 100644 --- a/src/main/Validator.ts +++ b/src/main/Validator.ts @@ -58,6 +58,7 @@ const downloadsSchema = Joi.object().pattern( addedAt: Joi.number().min(0), receivedBytes: Joi.number().min(0), totalBytes: Joi.number().min(0), + bookmark: Joi.string(), }); const configDataSchemaV0 = Joi.object({ diff --git a/src/main/downloadsManager.test.js b/src/main/downloadsManager.test.js index 83142266e85..d2340ba0853 100644 --- a/src/main/downloadsManager.test.js +++ b/src/main/downloadsManager.test.js @@ -117,6 +117,7 @@ const item = { getStartTime: () => nowSeconds, getTotalBytes: () => 4242, getSavePath: () => locationMock, + getURL: () => 'http://some-url.com/some-text.txt', hasUserGesture: jest.fn().mockReturnValue(true), setSavePath: jest.fn(), on: jest.fn(), @@ -144,7 +145,8 @@ describe('main/downloadsManager', () => { it('should handle a new download', () => { const dl = new DownloadsManager({}); path.parse.mockImplementation(() => ({base: 'file.txt'})); - dl.handleNewDownload({}, item, {id: 0, getURL: jest.fn()}); + dl.willDownloadURLs.set('http://some-url.com/some-text.txt', {filePath: locationMock}); + dl.handleNewDownload({preventDefault: jest.fn()}, item, {id: 0, getURL: jest.fn(), downloadURL: jest.fn()}); expect(dl).toHaveProperty('downloads', {'file.txt': { addedAt: nowSeconds * 1000, filename: 'file.txt', diff --git a/src/main/downloadsManager.ts b/src/main/downloadsManager.ts index 93a71a28842..32a532cb9d6 100644 --- a/src/main/downloadsManager.ts +++ b/src/main/downloadsManager.ts @@ -3,7 +3,7 @@ import path from 'path'; import fs from 'fs'; -import {DownloadItem, Event, WebContents, FileFilter, ipcMain, dialog, shell, Menu} from 'electron'; +import {DownloadItem, Event, WebContents, FileFilter, ipcMain, dialog, shell, Menu, app} from 'electron'; import log from 'electron-log'; import {ProgressInfo} from 'electron-updater'; @@ -51,12 +51,17 @@ export class DownloadsManager extends JsonFileManager { progressingItems: Map; downloads: DownloadedItems; + willDownloadURLs: Map; + bookmarks: Map; + constructor(file: string) { super(file); this.open = false; this.fileSizes = new Map(); this.progressingItems = new Map(); + this.willDownloadURLs = new Map(); + this.bookmarks = new Map(); this.autoCloseTimeout = null; this.downloads = {}; @@ -73,6 +78,7 @@ export class DownloadsManager extends JsonFileManager { this.saveAll({}); } this.checkForDeletedFiles(); + this.reloadFilesForMAS(); ipcMain.handle(REQUEST_HAS_DOWNLOADS, () => { return this.hasDownloads(); @@ -84,26 +90,44 @@ export class DownloadsManager extends JsonFileManager { ipcMain.on(NO_UPDATE_AVAILABLE, this.noUpdateAvailable); }; - handleNewDownload = (event: Event, item: DownloadItem, webContents: WebContents) => { + handleNewDownload = async (event: Event, item: DownloadItem, webContents: WebContents) => { log.debug('DownloadsManager.handleNewDownload', {item, sourceURL: webContents.getURL()}); - const shouldShowSaveDialog = this.shouldShowSaveDialog(item, Config.downloadLocation); - if (shouldShowSaveDialog) { - const saveDialogSuccess = this.showSaveDialog(item); - if (!saveDialogSuccess) { - item.cancel(); - return; + const url = item.getURL(); + + if (this.willDownloadURLs.has(url)) { + const info = this.willDownloadURLs.get(url)!; + this.willDownloadURLs.delete(url); + + if (info.bookmark) { + item.setSavePath(path.resolve(app.getPath('temp'), path.basename(info.filePath))); + this.bookmarks.set(this.getFileId(item), {originalPath: info.filePath, bookmark: info.bookmark!}); + } else { + item.setSavePath(info.filePath); } + + this.upsertFileToDownloads(item, 'progressing'); + this.progressingItems.set(this.getFileId(item), item); + this.handleDownloadItemEvents(item, webContents); + this.openDownloadsDropdown(); + this.toggleAppMenuDownloadsEnabled(true); } else { - const filename = this.createFilename(item); - const savePath = this.getSavePath(`${Config.downloadLocation}`, filename); - item.setSavePath(savePath); + event.preventDefault(); + + if (this.shouldShowSaveDialog(item, Config.downloadLocation)) { + const saveDialogResult = await this.showSaveDialog(item); + if (saveDialogResult.canceled || !saveDialogResult.filePath) { + return; + } + this.willDownloadURLs.set(url, {filePath: saveDialogResult.filePath, bookmark: saveDialogResult.bookmark}); + } else { + const filename = this.createFilename(item); + const savePath = this.getSavePath(`${Config.downloadLocation}`, filename); + this.willDownloadURLs.set(url, {filePath: savePath}); + } + + webContents.downloadURL(url); } - this.upsertFileToDownloads(item, 'progressing'); - this.progressingItems.set(this.getFileId(item), item); - this.handleDownloadItemEvents(item, webContents); - this.openDownloadsDropdown(); - this.toggleAppMenuDownloadsEnabled(true); }; /** @@ -125,6 +149,27 @@ export class DownloadsManager extends JsonFileManager { cb({}); }; + reloadFilesForMAS = () => { + // eslint-disable-next-line no-undef + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (!__IS_MAC_APP_STORE__) { + return; + } + + for (const file of Object.values(this.downloads)) { + if (file.bookmark) { + this.bookmarks.set(this.getDownloadedFileId(file), {originalPath: file.location, bookmark: file.bookmark}); + + if (file.mimeType?.toLowerCase().startsWith('image/')) { + const func = app.startAccessingSecurityScopedResource(file.bookmark); + fs.copyFileSync(file.location, path.resolve(app.getPath('temp'), path.basename(file.location))); + func(); + } + } + } + } + checkForDeletedFiles = () => { log.debug('DownloadsManager.checkForDeletedFiles'); @@ -208,10 +253,16 @@ export class DownloadsManager extends JsonFileManager { } if (fs.existsSync(item.location)) { + let func; + const bookmark = this.bookmarks.get(this.getDownloadedFileId(item)); + if (bookmark) { + func = app.startAccessingSecurityScopedResource(bookmark.bookmark); + } shell.openPath(item.location).catch((err) => { log.debug('DownloadsDropdownView.openFileError', {err}); this.showFileInFolder(item); }); + func?.(); } else { log.debug('DownloadsDropdownView.openFile', 'COULD_NOT_OPEN_FILE'); this.markFileAsDeleted(item); @@ -353,17 +404,12 @@ export class DownloadsManager extends JsonFileManager { const fileElements = filename.split('.'); const filters = this.getFileFilters(fileElements); - const newPath = dialog.showSaveDialogSync({ + return dialog.showSaveDialog({ title: filename, defaultPath: filename, filters, + securityScopedBookmarks: true, }); - - if (newPath) { - item.setSavePath(newPath); - return true; - } - return false; }; private closeDownloadsDropdown = () => { @@ -383,11 +429,11 @@ export class DownloadsManager extends JsonFileManager { } }; - private upsertFileToDownloads = (item: DownloadItem, state: DownloadItemState) => { + private upsertFileToDownloads = (item: DownloadItem, state: DownloadItemState, overridePath?: string) => { const fileId = this.getFileId(item); log.debug('DownloadsManager.upsertFileToDownloads', {fileId}); - const formattedItem = this.formatDownloadItem(item, state); + const formattedItem = this.formatDownloadItem(item, state, overridePath); this.save(fileId, formattedItem); this.checkIfMaxFilesReached(); }; @@ -447,7 +493,14 @@ export class DownloadsManager extends JsonFileManager { displayDownloadCompleted(path.basename(item.savePath), item.savePath, WindowManager.getServerNameByWebContentsId(webContents.id) || ''); } - this.upsertFileToDownloads(item, state); + const bookmark = this.bookmarks.get(this.getFileId(item)); + if (bookmark) { + const func = app.startAccessingSecurityScopedResource(bookmark?.bookmark); + fs.copyFileSync(path.resolve(app.getPath('temp'), path.basename(bookmark.originalPath)), bookmark.originalPath); + func(); + } + + this.upsertFileToDownloads(item, state, bookmark?.originalPath); this.fileSizes.delete(item.getFilename()); this.progressingItems.delete(this.getFileId(item)); @@ -508,7 +561,7 @@ export class DownloadsManager extends JsonFileManager { /** * Internal utils */ - private formatDownloadItem = (item: DownloadItem, state: DownloadItemState): DownloadedItem => { + private formatDownloadItem = (item: DownloadItem, state: DownloadItemState, overridePath?: string): DownloadedItem => { const totalBytes = this.getFileSize(item); const receivedBytes = item.getReceivedBytes(); const progress = getPercentage(receivedBytes, totalBytes); @@ -517,15 +570,20 @@ export class DownloadsManager extends JsonFileManager { addedAt: doubleSecToMs(item.getStartTime()), filename: this.getFileId(item), mimeType: item.getMimeType(), - location: item.getSavePath(), + location: overridePath ?? item.getSavePath(), progress, receivedBytes, state, totalBytes, type: DownloadItemTypeEnum.FILE, + bookmark: this.getBookmark(item), }; }; + private getBookmark = (item: DownloadItem) => { + return this.bookmarks.get(this.getFileId(item))?.bookmark; + } + private getFileSize = (item: DownloadItem) => { const itemTotalBytes = item.getTotalBytes(); if (!itemTotalBytes) { diff --git a/src/main/preload/downloadsDropdown.js b/src/main/preload/downloadsDropdown.js index 82cf6c664d7..ecf9e07316a 100644 --- a/src/main/preload/downloadsDropdown.js +++ b/src/main/preload/downloadsDropdown.js @@ -20,6 +20,7 @@ import { START_UPGRADE, TOGGLE_DOWNLOADS_DROPDOWN_MENU, UPDATE_DOWNLOADS_DROPDOWN, + GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, } from 'common/communication'; console.log('preloaded for the downloadsDropdown!'); @@ -28,6 +29,10 @@ contextBridge.exposeInMainWorld('process', { platform: process.platform, }); +contextBridge.exposeInMainWorld('mas', { + getThumbnailLocation: (location) => ipcRenderer.invoke(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, location), +}); + window.addEventListener('click', () => { ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN_MENU); }); diff --git a/src/main/views/downloadsDropdownView.ts b/src/main/views/downloadsDropdownView.ts index c611eb60773..36b9832478a 100644 --- a/src/main/views/downloadsDropdownView.ts +++ b/src/main/views/downloadsDropdownView.ts @@ -1,6 +1,8 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {BrowserView, BrowserWindow, ipcMain, IpcMainEvent} from 'electron'; +import path from 'path'; + +import {app, BrowserView, BrowserWindow, ipcMain, IpcMainEvent, IpcMainInvokeEvent} from 'electron'; import log from 'electron-log'; @@ -17,6 +19,7 @@ import { REQUEST_DOWNLOADS_DROPDOWN_INFO, UPDATE_DOWNLOADS_DROPDOWN, UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, + GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, } from 'common/communication'; import {TAB_BAR_HEIGHT, DOWNLOADS_DROPDOWN_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, DOWNLOADS_DROPDOWN_FULL_WIDTH} from 'common/utils/constants'; import {getLocalPreload, getLocalURLString} from 'main/utils'; @@ -66,6 +69,7 @@ export default class DownloadsDropdownView { ipcMain.on(DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, this.showFileInFolder); ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads); ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem); + ipcMain.handle(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, this.getDownloadImageThumbnailLocation); } updateDownloads = (event: IpcMainEvent, downloads: DownloadedItems) => { @@ -198,4 +202,15 @@ export default class DownloadsDropdownView { // @ts-ignore this.view.webContents.destroy(); } + + getDownloadImageThumbnailLocation = (event: IpcMainInvokeEvent, location: string) => { + // eslint-disable-next-line no-undef + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (!__IS_MAC_APP_STORE__) { + return location; + } + + return path.resolve(app.getPath('temp'), path.basename(location)); + } } diff --git a/src/renderer/components/DownloadsDropdown/Thumbnail.tsx b/src/renderer/components/DownloadsDropdown/Thumbnail.tsx index 98eac214d9d..1c895d25b6b 100644 --- a/src/renderer/components/DownloadsDropdown/Thumbnail.tsx +++ b/src/renderer/components/DownloadsDropdown/Thumbnail.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useEffect, useState} from 'react'; import {DownloadedItem} from 'types/downloads'; import {CheckCircleIcon, CloseCircleIcon} from '@mattermost/compass-icons/components'; @@ -19,6 +19,8 @@ const colorRed = '#D24B4E'; const isWin = window.process.platform === 'win32'; const Thumbnail = ({item}: OwnProps) => { + const [imageUrl, setImageUrl] = useState(); + const showBadge = (state: DownloadedItem['state']) => { switch (state) { case 'completed': @@ -42,15 +44,27 @@ const Thumbnail = ({item}: OwnProps) => { } }; + useEffect(() => { + const fetchThumbnail = async () => { + const imageUrl = await window.mas.getThumbnailLocation(item.location); + setImageUrl(imageUrl); + }; + + fetchThumbnail(); + }, [item]); + const showImagePreview = isImageFile(item) && item.state === 'completed'; + if (showImagePreview && !imageUrl) { + return null; + } return (
- {showImagePreview ? + {showImagePreview && imageUrl ?
: diff --git a/src/types/downloads.ts b/src/types/downloads.ts index 9598f1891ab..32a51307a7d 100644 --- a/src/types/downloads.ts +++ b/src/types/downloads.ts @@ -17,6 +17,7 @@ export type DownloadedItem = { addedAt: number; receivedBytes: number; totalBytes: number; + bookmark?: string; } export type DownloadedItems = Record; diff --git a/src/types/window.ts b/src/types/window.ts index 346dd012bca..cb188ebfc07 100644 --- a/src/types/window.ts +++ b/src/types/window.ts @@ -20,5 +20,8 @@ declare global { timers: { setImmediate: typeof setImmediate; }; + mas: { + getThumbnailLocation: (location: string) => Promise; + }; } }