diff --git a/.circleci/config.yml b/.circleci/config.yml
index 41f78254..91839186 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -28,7 +28,7 @@ executors:
mac:
working_directory: ~/mattermost-desktop
macos:
- xcode: "10.3.0"
+ xcode: "13.0.0"
aws:
working_directory: ~/mattermost-desktop
docker:
@@ -352,6 +352,34 @@ jobs:
paths:
- "./macos-release/"
+ mac_app_store_testflight:
+ executor: mac
+
+ steps:
+ - checkout
+ - run: mkdir -p ./build
+ - attach_workspace:
+ at: ./build
+ - run:
+ name: Update node to v16
+ command: brew upgrade node || true
+ - run:
+ name: Install yq
+ command: brew install yq
+ - run:
+ name: Installing npm dependencies
+ command: npm ci
+ - run:
+ name: Copy provisioning profile
+ command: echo $MAS_PROFILE | base64 -D > ./mas.provisionprofile
+ - run:
+ name: Patch version number for MAS
+ command: ./scripts/patch_mas_version.sh
+ - run: npm run package:mas
+ - run:
+ name: 'Upload to App Store Connect'
+ command: fastlane publish_test path:"$(find . -name \*.pkg -print -quit)"
+
store_artifacts:
executor: wine-chrome
steps:
@@ -596,6 +624,7 @@ workflows:
# release-XX.YY.ZZ
# release-XX.YY.ZZ-rc-something
- /^release-\d+(\.\d+){1,2}(-rc.*)?/
+
build-for-pr:
jobs:
- build-windows-pr:
@@ -620,6 +649,8 @@ workflows:
context: windows-codesign
- mac_installer:
context: codesign-certificates
+ - mac_app_store_testflight:
+ context: desktop-mac-app-store
- store_artifacts:
context: desktop_browserview
# for master/PR builds
diff --git a/.gitignore b/.gitignore
index 77cf3c45..cdd642c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,8 @@ test_config.json
testUserData
.gitattributes
+
+fastlane/README.md
+fastlane/report.xml
+
+*.provisionprofile
diff --git a/electron-builder.json b/electron-builder.json
index e77bd917..46a67f97 100644
--- a/electron-builder.json
+++ b/electron-builder.json
@@ -1,8 +1,10 @@
{
- "publish": [{
+ "publish": [
+ {
"provider": "generic",
"url": "https://releases.mattermost.com/desktop"
- }],
+ }
+ ],
"appId": "Mattermost.Desktop",
"artifactName": "${version}/${name}-${version}-${os}-${arch}.${ext}",
"directories": {
@@ -39,7 +41,13 @@
"deb": {
"artifactName": "${version}/${name}_${version}-1_${arch}.${ext}",
"synopsis": "Mattermost Desktop App",
- "depends": ["gconf2", "gconf-service", "libnotify4", "libxtst6", "libnss3"],
+ "depends": [
+ "gconf2",
+ "gconf-service",
+ "libnotify4",
+ "libxtst6",
+ "libnss3"
+ ],
"category": "contrib/net",
"priority": "optional"
},
@@ -93,6 +101,19 @@
"LSFileQuarantineEnabled": true
}
},
+ "mas": {
+ "hardenedRuntime": false,
+ "entitlements": "./entitlements.mas.plist",
+ "entitlementsInherit": "./entitlements.mas.inherit.plist",
+ "entitlementsLoginHelper": "./entitlements.mas.inherit.plist",
+ "provisioningProfile": "./mas.provisionprofile",
+ "extendInfo": {
+ "ITSAppUsesNonExemptEncryption": false
+ }
+ },
+ "masDev": {
+ "provisioningProfile": "./mas-dev.provisionprofile"
+ },
"dmg": {
"background": "src/assets/osx/DMG_BG.png",
"contents": [
diff --git a/entitlements.mas.inherit.plist b/entitlements.mas.inherit.plist
new file mode 100644
index 00000000..de198d33
--- /dev/null
+++ b/entitlements.mas.inherit.plist
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.inherit
+
+
+
diff --git a/entitlements.mas.plist b/entitlements.mas.plist
new file mode 100644
index 00000000..f9e1e02b
--- /dev/null
+++ b/entitlements.mas.plist
@@ -0,0 +1,36 @@
+
+
+
+
+ com.apple.security.device.microphone
+
+ com.apple.security.device.camera
+
+ com.apple.security.device.audio-input
+
+ com.apple.security.cs.allow-jit
+
+ com.apple.security.cs.allow-unsigned-executable-memory
+
+ com.apple.security.cs.allow-dyld-environment-variables
+
+ com.apple.security.network.client
+
+ com.apple.security.network.server
+
+ com.apple.security.files.user-selected.read-only
+
+ com.apple.security.files.user-selected.read-write
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.application-groups
+
+ UQ8HT4Q2XM.Mattermost.Desktop
+
+ com.apple.application-identifier
+ UQ8HT4Q2XM.Mattermost.Desktop
+ com.apple.developer.team-identifier
+ UQ8HT4Q2XM
+
+
diff --git a/fastlane/Fastfile b/fastlane/Fastfile
new file mode 100644
index 00000000..6fc1ddd4
--- /dev/null
+++ b/fastlane/Fastfile
@@ -0,0 +1,35 @@
+fastlane_version '2.71.0'
+fastlane_require 'aws-sdk-s3'
+fastlane_require 'erb'
+fastlane_require 'json'
+fastlane_require 'pathname'
+
+lane :publish_test do |options|
+ api_key = ''
+ unless ENV['MACOS_API_KEY_ID'].nil? || ENV['MACOS_API_KEY_ID'].empty? ||
+ ENV['MACOS_API_ISSUER_ID'].nil? || ENV['MACOS_API_ISSUER_ID'].empty? ||
+ ENV['MACOS_API_KEY'].nil? || ENV['MACOS_API_KEY'].empty?
+ api_key_path = "#{ENV['MACOS_API_KEY_ID']}.p8"
+ File.open("../#{api_key_path}", 'w') do |f|
+ key_string = ENV['MACOS_API_KEY']
+ p8_array = key_string.split('\n')
+ p8_array.each_with_index do |value, index|
+ f.write(value)
+ f.write("\n") unless index == p8_array.length - 1
+ end
+ end
+
+ api_key = app_store_connect_api_key(
+ key_id: ENV['MACOS_API_KEY_ID'],
+ issuer_id: ENV['MACOS_API_ISSUER_ID'],
+ key_filepath: "./#{api_key_path}",
+ in_house: ENV['MACOS_IN_HOUSE'] == 'true', # optional but may be required if using match/sigh
+ )
+
+ File.delete("../#{api_key_path}")
+ end
+ pilot(
+ pkg: options[:path],
+ api_key: api_key
+ )
+end
\ No newline at end of file
diff --git a/package.json b/package.json
index 8b832906..c69b9006 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,8 @@
"package:windows": "cross-env NODE_ENV=production npm-run-all check-build-config build && electron-builder --win --x64 --ia32 --publish=never",
"package:mac": "cross-env NODE_ENV=production npm-run-all check-build-config build && electron-builder --mac --x64 --arm64 --publish=never",
"package:mac-with-universal": "cross-env NODE_ENV=production npm-run-all check-build-config build && electron-builder --mac --x64 --arm64 --universal --publish=never",
+ "package:mas": "cross-env NODE_ENV=production IS_MAC_APP_STORE=true npm-run-all check-build-config build && electron-builder --mac mas --universal --publish=never",
+ "package:mas-dev": "cross-env NODE_ENV=production IS_MAC_APP_STORE=true npm-run-all check-build-config build && electron-builder --mac mas-dev --universal --publish=never",
"package:linux": "cross-env NODE_ENV=production npm-run-all check-build-config build && electron-builder --linux --x64 --ia32 --publish=never",
"lint:js": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:js-quiet": "eslint --ignore-path .gitignore --ignore-pattern node_modules --ext .js --ext .jsx --ext .ts --ext .tsx . --quiet",
@@ -83,7 +85,8 @@
"globals": {
"__HASH_VERSION__": "5.0.0",
"__CAN_UPGRADE__": false,
- "__IS_NIGHTLY_BUILD__": false
+ "__IS_NIGHTLY_BUILD__": false,
+ "__IS_MAC_APP_STORE__": false
},
"setupFiles": [
"./src/jestSetup.js"
diff --git a/scripts/afterpack.js b/scripts/afterpack.js
index 23919a11..c55294f5 100644
--- a/scripts/afterpack.js
+++ b/scripts/afterpack.js
@@ -27,6 +27,7 @@ function getAppFileName(context) {
case 'win32':
return 'Mattermost.exe';
case 'darwin':
+ case 'mas':
return 'Mattermost.app';
case 'linux':
return context.packager.executableName;
diff --git a/scripts/notarize.js b/scripts/notarize.js
index 9654b4a9..415088bc 100644
--- a/scripts/notarize.js
+++ b/scripts/notarize.js
@@ -9,6 +9,7 @@ const config = require('../electron-builder.json');
exports.default = async function notarizing(context) {
const {electronPlatformName, appOutDir} = context;
+
if (electronPlatformName !== 'darwin' || process.platform !== 'darwin') {
return;
}
diff --git a/scripts/patch_mas_version.sh b/scripts/patch_mas_version.sh
new file mode 100755
index 00000000..cf844be5
--- /dev/null
+++ b/scripts/patch_mas_version.sh
@@ -0,0 +1,18 @@
+#!/bin/sh
+
+set -e
+
+STABLE_VERSION=$(./node_modules/.bin/semver $(jq -r .version package.json) -c)
+BUILD_VERSION=$(jq -r .version package.json | sed "s/$STABLE_VERSION-.*\.//g")
+
+if [ "$BUILD_VERSION" == "" ]; then
+ BUILD_VERSION=$STABLE_VERSION
+fi
+
+if [ "$CIRCLE_BUILD_NUM" != "" ]; then
+ BUILD_VERSION=$CIRCLE_BUILD_NUM
+fi
+
+temp_file="$(mktemp -t electron-builder.json)"
+jq -r --arg version "$STABLE_VERSION" '.mac.bundleShortVersion = $version' electron-builder.json > "${temp_file}" && mv "${temp_file}" electron-builder.json
+jq -r --arg version "$BUILD_VERSION" '.mac.bundleVersion = $version' electron-builder.json > "${temp_file}" && mv "${temp_file}" electron-builder.json
\ No newline at end of file
diff --git a/src/assets/icon.icns b/src/assets/icon.icns
index aa2d2af2..c373fd60 100644
Binary files a/src/assets/icon.icns and b/src/assets/icon.icns differ
diff --git a/src/main/app/initialize.ts b/src/main/app/initialize.ts
index 73cdc7f5..72975447 100644
--- a/src/main/app/initialize.ts
+++ b/src/main/app/initialize.ts
@@ -91,6 +91,7 @@ import {
updateSpellCheckerLocales,
wasUpdated,
initCookieManager,
+ migrateMacAppStore,
} from './utils';
export const mainProtocol = protocols?.[0]?.schemes?.[0];
@@ -118,6 +119,13 @@ export async function initialize() {
return;
}
+ // eslint-disable-next-line no-undef
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ if (__IS_MAC_APP_STORE__) {
+ migrateMacAppStore();
+ }
+
// initialization that should run once the app is ready
initializeInterCommunicationEventListeners();
initializeAfterAppReady();
@@ -198,10 +206,15 @@ function initializeBeforeAppReady() {
refreshTrayImages(Config.trayIconTheme);
// If there is already an instance, quit this one
- const gotTheLock = app.requestSingleInstanceLock();
- if (!gotTheLock) {
- app.exit();
- global.willAppQuit = true;
+ // eslint-disable-next-line no-undef
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ if (!__IS_MAC_APP_STORE__) {
+ const gotTheLock = app.requestSingleInstanceLock();
+ if (!gotTheLock) {
+ app.exit();
+ global.willAppQuit = true;
+ }
}
AllowProtocolDialog.init();
diff --git a/src/main/app/utils.test.js b/src/main/app/utils.test.js
index e79fa781..31367859 100644
--- a/src/main/app/utils.test.js
+++ b/src/main/app/utils.test.js
@@ -1,27 +1,58 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
+import fs from 'fs';
+
+import {dialog} from 'electron';
+
import Config from 'common/config';
+import JsonFileManager from 'common/JsonFileManager';
import {TAB_MESSAGING, TAB_FOCALBOARD, TAB_PLAYBOOKS} from 'common/tabs/TabView';
import Utils from 'common/utils/util';
+import {updatePaths} from 'main/constants';
import {ServerInfo} from 'main/server/serverInfo';
-import {getDeeplinkingURL, updateServerInfos, resizeScreen} from './utils';
+import {getDeeplinkingURL, updateServerInfos, resizeScreen, migrateMacAppStore} from './utils';
+
+jest.mock('fs', () => ({
+ readFileSync: jest.fn(),
+ writeFileSync: jest.fn(),
+ existsSync: jest.fn(),
+}));
+
+jest.mock('electron', () => ({
+ app: {
+ getPath: () => '/path/to/data',
+ getAppPath: () => '/path/to/app',
+ },
+ nativeImage: {
+ createFromPath: jest.fn(),
+ },
+ dialog: {
+ showOpenDialogSync: jest.fn(),
+ showMessageBoxSync: jest.fn(),
+ },
+}));
jest.mock('electron-log', () => ({
info: jest.fn(),
+ error: jest.fn(),
}));
jest.mock('common/config', () => ({
set: jest.fn(),
}));
+jest.mock('common/JsonFileManager');
jest.mock('common/utils/util', () => ({
isVersionGreaterThanOrEqualTo: jest.fn(),
getDisplayBoundaries: jest.fn(),
}));
jest.mock('main/autoUpdater', () => ({}));
+jest.mock('main/constants', () => ({
+ updatePaths: jest.fn(),
+}));
jest.mock('main/menus/app', () => ({}));
jest.mock('main/menus/tray', () => ({}));
jest.mock('main/server/serverInfo', () => ({
@@ -190,4 +221,73 @@ describe('main/app/utils', () => {
expect(browserWindow.center).toHaveBeenCalled();
});
});
+
+ describe('migrateMacAppStore', () => {
+ it('should skip migration if already migrated', () => {
+ JsonFileManager.mockImplementation(() => ({
+ getValue: () => true,
+ }));
+ migrateMacAppStore();
+ expect(dialog.showMessageBoxSync).not.toHaveBeenCalled();
+ });
+
+ it('should skip migration if folder does not exist', () => {
+ JsonFileManager.mockImplementation(() => ({
+ getValue: () => false,
+ }));
+ fs.existsSync.mockReturnValue(false);
+ migrateMacAppStore();
+ expect(fs.existsSync).toHaveBeenCalled();
+ expect(dialog.showMessageBoxSync).not.toHaveBeenCalled();
+ });
+
+ it('should skip migration and set value if the user rejects import', () => {
+ const migrationPrefs = {
+ getValue: () => false,
+ setValue: jest.fn(),
+ };
+ JsonFileManager.mockImplementation(() => migrationPrefs);
+ fs.existsSync.mockReturnValue(true);
+ dialog.showMessageBoxSync.mockReturnValue(1);
+ migrateMacAppStore();
+ expect(migrationPrefs.setValue).toHaveBeenCalledWith('masConfigs', true);
+ expect(dialog.showOpenDialogSync).not.toHaveBeenCalled();
+ });
+
+ it('should do nothing if no directory is chosen, or if the dialog is closed', () => {
+ JsonFileManager.mockImplementation(() => ({
+ getValue: () => false,
+ }));
+ fs.existsSync.mockReturnValue(true);
+ dialog.showMessageBoxSync.mockReturnValue(0);
+ dialog.showOpenDialogSync.mockReturnValue([]);
+ migrateMacAppStore();
+ expect(dialog.showOpenDialogSync).toHaveBeenCalled();
+ expect(updatePaths).not.toHaveBeenCalled();
+ });
+
+ it('should copy all of the configs when they exist to the new directory', () => {
+ const migrationPrefs = {
+ getValue: () => false,
+ setValue: jest.fn(),
+ };
+ JsonFileManager.mockImplementation(() => migrationPrefs);
+ fs.readFileSync.mockReturnValue('config-data');
+ fs.existsSync.mockImplementation((path) => {
+ if (path === '/Library/Application Support/Mattermost') {
+ return true;
+ }
+ return ['config', 'app-state', 'bounds-info', 'migration-info'].some((filename) => path.endsWith(`${filename}.json`));
+ });
+ dialog.showMessageBoxSync.mockReturnValue(0);
+ dialog.showOpenDialogSync.mockReturnValue(['/old/data/path']);
+ migrateMacAppStore();
+ expect(fs.readFileSync).toHaveBeenCalledWith('/old/data/path/config.json');
+ expect(fs.writeFileSync).toHaveBeenCalledWith('/path/to/data/config.json', 'config-data');
+ expect(fs.readFileSync).not.toHaveBeenCalledWith('/old/data/path/allowedProtocols.json');
+ expect(fs.writeFileSync).not.toHaveBeenCalledWith('/path/to/data/allowedProtocols.json', 'config-data');
+ expect(updatePaths).toHaveBeenCalled();
+ expect(migrationPrefs.setValue).toHaveBeenCalledWith('masConfigs', true);
+ });
+ });
});
diff --git a/src/main/app/utils.ts b/src/main/app/utils.ts
index e60a6858..72c584f8 100644
--- a/src/main/app/utils.ts
+++ b/src/main/app/utils.ts
@@ -1,20 +1,26 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import {app, BrowserWindow, Menu, Rectangle, Session, session} from 'electron';
+import fs from 'fs';
+
+import path from 'path';
+
+import {app, BrowserWindow, Menu, Rectangle, Session, session, dialog, nativeImage} from 'electron';
import log from 'electron-log';
-import {TeamWithTabs} from 'types/config';
+import {MigrationInfo, TeamWithTabs} from 'types/config';
import {RemoteInfo} from 'types/server';
import {Boundaries} from 'types/utils';
import Config from 'common/config';
+import JsonFileManager from 'common/JsonFileManager';
import {MattermostServer} from 'common/servers/MattermostServer';
import {TAB_FOCALBOARD, TAB_MESSAGING, TAB_PLAYBOOKS} from 'common/tabs/TabView';
import urlUtils from 'common/utils/url';
import Utils from 'common/utils/util';
import updateManager from 'main/autoUpdater';
+import {migrationInfoPath, updatePaths} from 'main/constants';
import {createMenu as createAppMenu} from 'main/menus/app';
import {createMenu as createTrayMenu} from 'main/menus/tray';
import {ServerInfo} from 'main/server/serverInfo';
@@ -23,6 +29,12 @@ import WindowManager from 'main/windows/windowManager';
import {mainProtocol} from './initialize';
+const configFileNames = ['config', 'allowedProtocols', 'app-state', 'certificate', 'trustedOrigins', 'bounds-info', 'migration-info'];
+
+const assetsDir = path.resolve(app.getAppPath(), 'assets');
+const appIconURL = path.resolve(assetsDir, 'appicon_with_spacing_32.png');
+const appIcon = nativeImage.createFromPath(appIconURL);
+
export function openDeepLink(deeplinkingUrl: string) {
try {
WindowManager.showMainWindow(deeplinkingUrl);
@@ -177,3 +189,62 @@ export function initCookieManager(session: Session) {
flushCookiesStore(session);
});
}
+
+export function migrateMacAppStore() {
+ const migrationPrefs = new JsonFileManager(migrationInfoPath);
+ const oldPath = path.join(app.getPath('userData'), '../../../../../../../Library/Application Support/Mattermost');
+
+ // Check if we've already migrated
+ if (migrationPrefs.getValue('masConfigs')) {
+ return;
+ }
+
+ // Check if the files are there to migrate
+ try {
+ const exists = fs.existsSync(oldPath);
+ if (!exists) {
+ log.info('MAS: No files to migrate, skipping');
+ return;
+ }
+ } catch (e) {
+ log.error('MAS: Failed to check for existing Mattermost Desktop install, skipping', e);
+ return;
+ }
+
+ const cancelImport = dialog.showMessageBoxSync({
+ title: 'Mattermost',
+ message: 'Import Existing Configuration',
+ detail: 'It appears that an existing Mattermost configuration exists, would you like to import it? You will be asked to pick the correct configuration directory.',
+ icon: appIcon,
+ buttons: ['Select Directory and Import', 'Don\'t Import'],
+ type: 'info',
+ defaultId: 0,
+ cancelId: 1,
+ });
+
+ if (cancelImport) {
+ migrationPrefs.setValue('masConfigs', true);
+ return;
+ }
+
+ const result = dialog.showOpenDialogSync({
+ defaultPath: oldPath,
+ properties: ['openDirectory'],
+ });
+ if (!(result && result[0])) {
+ return;
+ }
+
+ try {
+ for (const fileName of configFileNames) {
+ if (fs.existsSync(path.resolve(result[0], `${fileName}.json`))) {
+ const contents = fs.readFileSync(path.resolve(result[0], `${fileName}.json`));
+ fs.writeFileSync(path.resolve(app.getPath('userData'), `${fileName}.json`), contents);
+ }
+ }
+ updatePaths(true);
+ migrationPrefs.setValue('masConfigs', true);
+ } catch (e) {
+ log.error('MAS: An error occurred importing the existing configuration', e);
+ }
+}
diff --git a/src/types/config.ts b/src/types/config.ts
index 46186e5f..8106f23f 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -126,4 +126,5 @@ export type LocalConfiguration = Config & {
export type MigrationInfo = {
updateTrayIconWin32: boolean;
+ masConfigs: boolean;
}
diff --git a/webpack.config.base.js b/webpack.config.base.js
index 2a14adfb..f0a1099d 100644
--- a/webpack.config.base.js
+++ b/webpack.config.base.js
@@ -20,6 +20,7 @@ const codeDefinitions = {
__HASH_VERSION__: !isRelease && JSON.stringify(VERSION),
__CAN_UPGRADE__: JSON.stringify(true), // we should set this to false when working on a store version. Hardcoding for now.
__IS_NIGHTLY_BUILD__: JSON.stringify(process.env.CIRCLE_BRANCH === 'nightly'),
+ __IS_MAC_APP_STORE__: JSON.stringify(process.env.IS_MAC_APP_STORE === 'true'),
};
codeDefinitions['process.env.NODE_ENV'] = JSON.stringify(process.env.NODE_ENV);