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);