Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 2 commits into from
Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions entitlements.mas.plist
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,9 @@
<string>UQ8HT4Q2XM.Mattermost.Desktop</string>
<key>com.apple.developer.team-identifier</key>
<string>UQ8HT4Q2XM</string>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.files.bookmarks.document-scope</key>
<true/>
</dict>
</plist>
3 changes: 2 additions & 1 deletion scripts/patch_macos_notification_state.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/common/communication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,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';
Expand Down
1 change: 1 addition & 0 deletions src/main/Validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ const downloadsSchema = Joi.object<DownloadedItems>().pattern(
addedAt: Joi.number().min(0),
receivedBytes: Joi.number().min(0),
totalBytes: Joi.number().min(0),
bookmark: Joi.string(),
});

const configDataSchemaV0 = Joi.object<ConfigV0>({
Expand Down
4 changes: 3 additions & 1 deletion src/main/downloadsManager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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',
Expand Down
114 changes: 86 additions & 28 deletions src/main/downloadsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -51,12 +51,17 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
progressingItems: Map<string, DownloadItem>;
downloads: DownloadedItems;

willDownloadURLs: Map<string, {filePath: string; bookmark?: string}>;
bookmarks: Map<string, {originalPath: string; bookmark: string}>;

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 = {};

Expand All @@ -73,6 +78,7 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
this.saveAll({});
}
this.checkForDeletedFiles();
this.reloadFilesForMAS();

ipcMain.handle(REQUEST_HAS_DOWNLOADS, () => {
return this.hasDownloads();
Expand All @@ -84,26 +90,44 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
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);
};

/**
Expand All @@ -125,6 +149,27 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
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');

Expand Down Expand Up @@ -208,10 +253,16 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
}

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);
Expand Down Expand Up @@ -353,17 +404,12 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
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 = () => {
Expand All @@ -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);
log.debug('DownloadsManager.upsertFileToDownloads', {fileId});

const formattedItem = this.formatDownloadItem(item, state);
const formattedItem = this.formatDownloadItem(item, state, overridePath);
this.save(fileId, formattedItem);
this.checkIfMaxFilesReached();
};
Expand Down Expand Up @@ -447,7 +493,14 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
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));
Expand Down Expand Up @@ -508,7 +561,7 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
/**
* 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);
Expand All @@ -517,15 +570,20 @@ export class DownloadsManager extends JsonFileManager<DownloadedItems> {
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) {
Expand Down
5 changes: 5 additions & 0 deletions src/main/preload/downloadsDropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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!');
Expand All @@ -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);
});
Expand Down
17 changes: 16 additions & 1 deletion src/main/views/downloadsDropdownView.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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));
}
}
20 changes: 17 additions & 3 deletions src/renderer/components/DownloadsDropdown/Thumbnail.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,6 +19,8 @@ const colorRed = '#D24B4E';
const isWin = window.process.platform === 'win32';

const Thumbnail = ({item}: OwnProps) => {
const [imageUrl, setImageUrl] = useState<string | undefined>();

const showBadge = (state: DownloadedItem['state']) => {
switch (state) {
case 'completed':
Expand All @@ -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 (
<div className='DownloadsDropdown__Thumbnail__Container'>
{showImagePreview ?
{showImagePreview && imageUrl ?
<div
className='DownloadsDropdown__Thumbnail preview'
style={{
backgroundImage: `url("${isWin ? `file:///${item.location.replaceAll('\\', '/')}` : item.location}")`,
backgroundImage: `url("${isWin ? `file:///${imageUrl.replaceAll('\\', '/')}` : imageUrl}")`,
backgroundSize: 'cover',
}}
/> :
Expand Down
1 change: 1 addition & 0 deletions src/types/downloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type DownloadedItem = {
addedAt: number;
receivedBytes: number;
totalBytes: number;
bookmark?: string;
}

export type DownloadedItems = Record<string, DownloadedItem>;
Expand Down
Loading