[MM-47801][MM-45980] Added support for security-scoped bookmarks to allow the MAS build to save files wherever needed (#2315)

* First pass

* [MM-47801] Added support for security-scoped bookmarks to allow the MAS build to save files wherever needed
This commit is contained in:
Devin Binnie 2022-10-25 08:02:00 -04:00 committed by GitHub
parent 0f51a628f0
commit 635a41f998
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 139 additions and 34 deletions

View file

@ -38,5 +38,9 @@
<string>UQ8HT4Q2XM.Mattermost.Desktop</string> <string>UQ8HT4Q2XM.Mattermost.Desktop</string>
<key>com.apple.developer.team-identifier</key> <key>com.apple.developer.team-identifier</key>
<string>UQ8HT4Q2XM</string> <string>UQ8HT4Q2XM</string>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.files.bookmarks.document-scope</key>
<true/>
</dict> </dict>
</plist> </plist>

View file

@ -1,9 +1,10 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
const jq = require('node-jq');
const fs = require('fs'); const fs = require('fs');
const jq = require('node-jq');
jq.run( jq.run(
'.scripts.install = "node-gyp rebuild"', '.scripts.install = "node-gyp rebuild"',
'./node_modules/macos-notification-state/package.json', './node_modules/macos-notification-state/package.json',

View file

@ -148,6 +148,7 @@ export const REQUEST_HAS_DOWNLOADS = 'request-has-downloads';
export const DOWNLOADS_DROPDOWN_FOCUSED = 'downloads-dropdown-focused'; export const DOWNLOADS_DROPDOWN_FOCUSED = 'downloads-dropdown-focused';
export const RECEIVE_DOWNLOADS_DROPDOWN_SIZE = 'receive-downloads-dropdown-size'; export const RECEIVE_DOWNLOADS_DROPDOWN_SIZE = 'receive-downloads-dropdown-size';
export const SEND_DOWNLOADS_DROPDOWN_SIZE = 'send-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 OPEN_DOWNLOADS_DROPDOWN_MENU = 'open-downloads-dropdown-menu';
export const CLOSE_DOWNLOADS_DROPDOWN_MENU = 'close-downloads-dropdown-menu'; export const CLOSE_DOWNLOADS_DROPDOWN_MENU = 'close-downloads-dropdown-menu';

View file

@ -58,6 +58,7 @@ const downloadsSchema = Joi.object<DownloadedItems>().pattern(
addedAt: Joi.number().min(0), addedAt: Joi.number().min(0),
receivedBytes: Joi.number().min(0), receivedBytes: Joi.number().min(0),
totalBytes: Joi.number().min(0), totalBytes: Joi.number().min(0),
bookmark: Joi.string(),
}); });
const configDataSchemaV0 = Joi.object<ConfigV0>({ const configDataSchemaV0 = Joi.object<ConfigV0>({

View file

@ -117,6 +117,7 @@ const item = {
getStartTime: () => nowSeconds, getStartTime: () => nowSeconds,
getTotalBytes: () => 4242, getTotalBytes: () => 4242,
getSavePath: () => locationMock, getSavePath: () => locationMock,
getURL: () => 'http://some-url.com/some-text.txt',
hasUserGesture: jest.fn().mockReturnValue(true), hasUserGesture: jest.fn().mockReturnValue(true),
setSavePath: jest.fn(), setSavePath: jest.fn(),
on: jest.fn(), on: jest.fn(),
@ -144,7 +145,8 @@ describe('main/downloadsManager', () => {
it('should handle a new download', () => { it('should handle a new download', () => {
const dl = new DownloadsManager({}); const dl = new DownloadsManager({});
path.parse.mockImplementation(() => ({base: 'file.txt'})); 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': { expect(dl).toHaveProperty('downloads', {'file.txt': {
addedAt: nowSeconds * 1000, addedAt: nowSeconds * 1000,
filename: 'file.txt', filename: 'file.txt',

View file

@ -3,7 +3,7 @@
import path from 'path'; import path from 'path';
import fs from 'fs'; 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 log from 'electron-log';
import {ProgressInfo} from 'electron-updater'; import {ProgressInfo} from 'electron-updater';
@ -51,12 +51,17 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
progressingItems: Map<string, DownloadItem>; progressingItems: Map<string, DownloadItem>;
downloads: DownloadedItems; downloads: DownloadedItems;
willDownloadURLs: Map<string, {filePath: string; bookmark?: string}>;
bookmarks: Map<string, {originalPath: string; bookmark: string}>;
constructor(file: string) { constructor(file: string) {
super(file); super(file);
this.open = false; this.open = false;
this.fileSizes = new Map(); this.fileSizes = new Map();
this.progressingItems = new Map(); this.progressingItems = new Map();
this.willDownloadURLs = new Map();
this.bookmarks = new Map();
this.autoCloseTimeout = null; this.autoCloseTimeout = null;
this.downloads = {}; this.downloads = {};
@ -73,6 +78,7 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
this.saveAll({}); this.saveAll({});
} }
this.checkForDeletedFiles(); this.checkForDeletedFiles();
this.reloadFilesForMAS();
ipcMain.handle(REQUEST_HAS_DOWNLOADS, () => { ipcMain.handle(REQUEST_HAS_DOWNLOADS, () => {
return this.hasDownloads(); return this.hasDownloads();
@ -84,26 +90,44 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
ipcMain.on(NO_UPDATE_AVAILABLE, this.noUpdateAvailable); 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()}); log.debug('DownloadsManager.handleNewDownload', {item, sourceURL: webContents.getURL()});
const shouldShowSaveDialog = this.shouldShowSaveDialog(item, Config.downloadLocation); const url = item.getURL();
if (shouldShowSaveDialog) {
const saveDialogSuccess = this.showSaveDialog(item); if (this.willDownloadURLs.has(url)) {
if (!saveDialogSuccess) { const info = this.willDownloadURLs.get(url)!;
item.cancel(); this.willDownloadURLs.delete(url);
return;
} 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 { } else {
const filename = this.createFilename(item); item.setSavePath(info.filePath);
const savePath = this.getSavePath(`${Config.downloadLocation}`, filename);
item.setSavePath(savePath);
} }
this.upsertFileToDownloads(item, 'progressing'); this.upsertFileToDownloads(item, 'progressing');
this.progressingItems.set(this.getFileId(item), item); this.progressingItems.set(this.getFileId(item), item);
this.handleDownloadItemEvents(item, webContents); this.handleDownloadItemEvents(item, webContents);
this.openDownloadsDropdown(); this.openDownloadsDropdown();
this.toggleAppMenuDownloadsEnabled(true); this.toggleAppMenuDownloadsEnabled(true);
} else {
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);
}
}; };
/** /**
@ -125,6 +149,27 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
cb({}); 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 = () => { checkForDeletedFiles = () => {
log.debug('DownloadsManager.checkForDeletedFiles'); log.debug('DownloadsManager.checkForDeletedFiles');
@ -208,10 +253,16 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
} }
if (fs.existsSync(item.location)) { 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) => { shell.openPath(item.location).catch((err) => {
log.debug('DownloadsDropdownView.openFileError', {err}); log.debug('DownloadsDropdownView.openFileError', {err});
this.showFileInFolder(item); this.showFileInFolder(item);
}); });
func?.();
} else { } else {
log.debug('DownloadsDropdownView.openFile', 'COULD_NOT_OPEN_FILE'); log.debug('DownloadsDropdownView.openFile', 'COULD_NOT_OPEN_FILE');
this.markFileAsDeleted(item); this.markFileAsDeleted(item);
@ -353,17 +404,12 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
const fileElements = filename.split('.'); const fileElements = filename.split('.');
const filters = this.getFileFilters(fileElements); const filters = this.getFileFilters(fileElements);
const newPath = dialog.showSaveDialogSync({ return dialog.showSaveDialog({
title: filename, title: filename,
defaultPath: filename, defaultPath: filename,
filters, filters,
securityScopedBookmarks: true,
}); });
if (newPath) {
item.setSavePath(newPath);
return true;
}
return false;
}; };
private closeDownloadsDropdown = () => { private closeDownloadsDropdown = () => {
@ -383,11 +429,11 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
} }
}; };
private upsertFileToDownloads = (item: DownloadItem, state: DownloadItemState) => { private upsertFileToDownloads = (item: DownloadItem, state: DownloadItemState, overridePath?: string) => {
const fileId = this.getFileId(item); const fileId = this.getFileId(item);
log.debug('DownloadsManager.upsertFileToDownloads', {fileId}); log.debug('DownloadsManager.upsertFileToDownloads', {fileId});
const formattedItem = this.formatDownloadItem(item, state); const formattedItem = this.formatDownloadItem(item, state, overridePath);
this.save(fileId, formattedItem); this.save(fileId, formattedItem);
this.checkIfMaxFilesReached(); this.checkIfMaxFilesReached();
}; };
@ -447,7 +493,14 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
displayDownloadCompleted(path.basename(item.savePath), item.savePath, WindowManager.getServerNameByWebContentsId(webContents.id) || ''); 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.fileSizes.delete(item.getFilename());
this.progressingItems.delete(this.getFileId(item)); this.progressingItems.delete(this.getFileId(item));
@ -508,7 +561,7 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
/** /**
* Internal utils * Internal utils
*/ */
private formatDownloadItem = (item: DownloadItem, state: DownloadItemState): DownloadedItem => { private formatDownloadItem = (item: DownloadItem, state: DownloadItemState, overridePath?: string): DownloadedItem => {
const totalBytes = this.getFileSize(item); const totalBytes = this.getFileSize(item);
const receivedBytes = item.getReceivedBytes(); const receivedBytes = item.getReceivedBytes();
const progress = getPercentage(receivedBytes, totalBytes); const progress = getPercentage(receivedBytes, totalBytes);
@ -517,15 +570,20 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
addedAt: doubleSecToMs(item.getStartTime()), addedAt: doubleSecToMs(item.getStartTime()),
filename: this.getFileId(item), filename: this.getFileId(item),
mimeType: item.getMimeType(), mimeType: item.getMimeType(),
location: item.getSavePath(), location: overridePath ?? item.getSavePath(),
progress, progress,
receivedBytes, receivedBytes,
state, state,
totalBytes, totalBytes,
type: DownloadItemTypeEnum.FILE, type: DownloadItemTypeEnum.FILE,
bookmark: this.getBookmark(item),
}; };
}; };
private getBookmark = (item: DownloadItem) => {
return this.bookmarks.get(this.getFileId(item))?.bookmark;
}
private getFileSize = (item: DownloadItem) => { private getFileSize = (item: DownloadItem) => {
const itemTotalBytes = item.getTotalBytes(); const itemTotalBytes = item.getTotalBytes();
if (!itemTotalBytes) { if (!itemTotalBytes) {

View file

@ -20,6 +20,7 @@ import {
START_UPGRADE, START_UPGRADE,
TOGGLE_DOWNLOADS_DROPDOWN_MENU, TOGGLE_DOWNLOADS_DROPDOWN_MENU,
UPDATE_DOWNLOADS_DROPDOWN, UPDATE_DOWNLOADS_DROPDOWN,
GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION,
} from 'common/communication'; } from 'common/communication';
console.log('preloaded for the downloadsDropdown!'); console.log('preloaded for the downloadsDropdown!');
@ -28,6 +29,10 @@ contextBridge.exposeInMainWorld('process', {
platform: process.platform, platform: process.platform,
}); });
contextBridge.exposeInMainWorld('mas', {
getThumbnailLocation: (location) => ipcRenderer.invoke(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, location),
});
window.addEventListener('click', () => { window.addEventListener('click', () => {
ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN_MENU); ipcRenderer.send(CLOSE_DOWNLOADS_DROPDOWN_MENU);
}); });

View file

@ -1,6 +1,8 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // 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'; import log from 'electron-log';
@ -17,6 +19,7 @@ import {
REQUEST_DOWNLOADS_DROPDOWN_INFO, REQUEST_DOWNLOADS_DROPDOWN_INFO,
UPDATE_DOWNLOADS_DROPDOWN, UPDATE_DOWNLOADS_DROPDOWN,
UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM,
GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION,
} from 'common/communication'; } from 'common/communication';
import {TAB_BAR_HEIGHT, DOWNLOADS_DROPDOWN_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, DOWNLOADS_DROPDOWN_FULL_WIDTH} from 'common/utils/constants'; import {TAB_BAR_HEIGHT, DOWNLOADS_DROPDOWN_WIDTH, DOWNLOADS_DROPDOWN_HEIGHT, DOWNLOADS_DROPDOWN_FULL_WIDTH} from 'common/utils/constants';
import {getLocalPreload, getLocalURLString} from 'main/utils'; 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(DOWNLOADS_DROPDOWN_SHOW_FILE_IN_FOLDER, this.showFileInFolder);
ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads); ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN, this.updateDownloads);
ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem); ipcMain.on(UPDATE_DOWNLOADS_DROPDOWN_MENU_ITEM, this.updateDownloadsDropdownMenuItem);
ipcMain.handle(GET_DOWNLOADED_IMAGE_THUMBNAIL_LOCATION, this.getDownloadImageThumbnailLocation);
} }
updateDownloads = (event: IpcMainEvent, downloads: DownloadedItems) => { updateDownloads = (event: IpcMainEvent, downloads: DownloadedItems) => {
@ -198,4 +202,15 @@ export default class DownloadsDropdownView {
// @ts-ignore // @ts-ignore
this.view.webContents.destroy(); 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));
}
} }

View file

@ -1,7 +1,7 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React, {useEffect, useState} from 'react';
import {DownloadedItem} from 'types/downloads'; import {DownloadedItem} from 'types/downloads';
import {CheckCircleIcon, CloseCircleIcon} from '@mattermost/compass-icons/components'; import {CheckCircleIcon, CloseCircleIcon} from '@mattermost/compass-icons/components';
@ -19,6 +19,8 @@ const colorRed = '#D24B4E';
const isWin = window.process.platform === 'win32'; const isWin = window.process.platform === 'win32';
const Thumbnail = ({item}: OwnProps) => { const Thumbnail = ({item}: OwnProps) => {
const [imageUrl, setImageUrl] = useState<string | undefined>();
const showBadge = (state: DownloadedItem['state']) => { const showBadge = (state: DownloadedItem['state']) => {
switch (state) { switch (state) {
case 'completed': 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'; const showImagePreview = isImageFile(item) && item.state === 'completed';
if (showImagePreview && !imageUrl) {
return null;
}
return ( return (
<div className='DownloadsDropdown__Thumbnail__Container'> <div className='DownloadsDropdown__Thumbnail__Container'>
{showImagePreview ? {showImagePreview && imageUrl ?
<div <div
className='DownloadsDropdown__Thumbnail preview' className='DownloadsDropdown__Thumbnail preview'
style={{ style={{
backgroundImage: `url("${isWin ? `file:///${item.location.replaceAll('\\', '/')}` : item.location}")`, backgroundImage: `url("${isWin ? `file:///${imageUrl.replaceAll('\\', '/')}` : imageUrl}")`,
backgroundSize: 'cover', backgroundSize: 'cover',
}} }}
/> : /> :

View file

@ -17,6 +17,7 @@ export type DownloadedItem = {
addedAt: number; addedAt: number;
receivedBytes: number; receivedBytes: number;
totalBytes: number; totalBytes: number;
bookmark?: string;
} }
export type DownloadedItems = Record<string, DownloadedItem>; export type DownloadedItems = Record<string, DownloadedItem>;

View file

@ -20,5 +20,8 @@ declare global {
timers: { timers: {
setImmediate: typeof setImmediate; setImmediate: typeof setImmediate;
}; };
mas: {
getThumbnailLocation: (location: string) => Promise<string>;
};
} }
} }