[CP-2490] Backup (#1742)

This commit is contained in:
Michał Kurczewski
2024-03-14 14:36:41 +01:00
committed by GitHub
parent 5706e171d3
commit cc3d76d72e
156 changed files with 3858 additions and 958 deletions

View File

@@ -2,5 +2,6 @@ module.exports = {
extends: "@mudita/stylelint-config",
rules: {
"no-descending-specificity": null,
}
"selector-type-no-unknown": [true, { ignoreTypes: ["$dummyValue"] }],
},
}

View File

@@ -81,6 +81,7 @@ import installExtension, {
REDUX_DEVTOOLS,
REACT_DEVELOPER_TOOLS,
} from "electron-devtools-installer"
import { AppEvents, callRenderer } from "shared/utils"
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
@@ -190,6 +191,10 @@ const createWindow = async () => {
app.exit()
})
win.on("focus", () => {
callRenderer(AppEvents.WindowFocused)
})
new MetadataInitializer(metadataStore).init()
const registerDownloadListener = createDownloadListenerRegistrar(win)

View File

File diff suppressed because one or more lines are too long

View File

@@ -1,593 +0,0 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import "reflect-metadata"
import { check as checkPort } from "tcp-port-used"
import {
app,
BrowserWindow,
BrowserWindowConstructorOptions,
shell,
} from "electron"
import { ipcMain } from "electron-better-ipc"
import * as path from "path"
import * as url from "url"
import packageInfo from "Root/apps/mudita-center/package.json"
import registerPureOsDownloadListener from "Core/__deprecated__/main/functions/register-pure-os-download-listener"
import registerNewsListener from "Core/__deprecated__/main/functions/register-news-listener/register-news-listener"
import registerAppLogsListeners from "Core/__deprecated__/main/functions/register-app-logs-listener"
import registerContactsExportListener from "Core/contacts/backend/export-contacts"
import registerWriteFileListener from "Core/__deprecated__/main/functions/register-write-file-listener"
import registerCopyFileListener from "Core/__deprecated__/main/functions/register-copy-file-listener"
import registerWriteGzipListener from "Core/__deprecated__/main/functions/register-write-gzip-listener"
import registerRmdirListener from "Core/__deprecated__/main/functions/register-rmdir-listener"
import registerArchiveFilesListener from "Core/__deprecated__/main/functions/register-archive-files-listener"
import createDownloadListenerRegistrar from "Core/__deprecated__/main/functions/create-download-listener-registrar"
import {
registerDownloadHelpHandler,
removeDownloadHelpHandler,
} from "Core/__deprecated__/main/functions/download-help-handler"
import {
registerSetHelpStoreHandler,
removeSetHelpStoreHandler,
} from "Core/__deprecated__/main/functions/set-help-store-handler"
import {
registerGetHelpStoreHandler,
removeGetHelpStoreHandler,
} from "Core/__deprecated__/main/functions/get-help-store-handler"
import { GoogleAuthActions } from "Core/__deprecated__/common/enums/google-auth-actions.enum"
import {
authServerPort,
createAuthServer,
killAuthServer,
} from "Core/__deprecated__/main/auth-server"
import logger from "Core/__deprecated__/main/utils/logger"
import { Scope } from "Core/__deprecated__/renderer/models/external-providers/google/google.interface"
import { OutlookAuthActions } from "Core/__deprecated__/common/enums/outlook-auth-actions.enum"
import {
clientId,
redirectUrl,
} from "Core/__deprecated__/renderer/models/external-providers/outlook/outlook.constants"
import { TokenRequester } from "Core/__deprecated__/renderer/models/external-providers/outlook/token-requester"
import {
GOOGLE_AUTH_WINDOW_SIZE,
WINDOW_SIZE,
DEFAULT_WINDOWS_SIZE,
} from "./config"
import autoupdate, { mockAutoupdate } from "./autoupdate"
import {
URL_MAIN,
URL_OVERVIEW,
} from "Core/__deprecated__/renderer/constants/urls"
import { Mode } from "Core/__deprecated__/common/enums/mode.enum"
import { HelpActions } from "Core/__deprecated__/common/enums/help-actions.enum"
import { AboutActions } from "Core/__deprecated__/common/enums/about-actions.enum"
import { PureSystemActions } from "Core/__deprecated__/common/enums/pure-system-actions.enum"
import { BrowserActions } from "Core/__deprecated__/common/enums/browser-actions.enum"
import {
createMetadataStore,
MetadataStore,
MetadataInitializer,
registerMetadataAllGetValueListener,
registerMetadataGetValueListener,
registerMetadataSetValueListener,
} from "Core/metadata"
import { registerOsUpdateAlreadyDownloadedCheck } from "Core/update/requests"
import { createSettingsService } from "Core/settings/containers/settings.container"
import { ApplicationModule } from "Core/core/application.module"
import registerExternalUsageDevice from "Core/device/listeners/register-external-usage-device.listner"
import installExtension, {
REDUX_DEVTOOLS,
REACT_DEVELOPER_TOOLS,
} from "electron-devtools-installer"
import isPrereleaseSet from "Core/utils/is-prerelease-set"
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
require("dotenv").config()
// FIXME: electron v12 added changes to the remote module. This module has many subtle pitfalls.
// There is almost always a better way to accomplish your task than using this module.
// You can read more in https://github.com/electron/remote#migrating-from-remote
require("@electron/remote/main").initialize()
logger.info("Starting the app")
let win: BrowserWindow | null
let helpWindow: BrowserWindow | null = null
let googleAuthWindow: BrowserWindow | null = null
let outlookAuthWindow: BrowserWindow | null = null
const licenseWindow: BrowserWindow | null = null
const termsWindow: BrowserWindow | null = null
const policyWindow: BrowserWindow | null = null
const metadataStore: MetadataStore = createMetadataStore()
// Disabling browser security features
// to address CORS issue between local and remote servers.
// To be handled as part of ticket https://appnroll.atlassian.net/browse/CP-2242
app.commandLine.appendSwitch(
"disable-features",
"BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights"
)
const gotTheLock = app.requestSingleInstanceLock()
// Fetch and log all errors
process.on("uncaughtException", (error) => {
logger.error(error)
// TODO: Add contact support modal
})
const productionEnvironment = process.env.NODE_ENV === "production"
const commonWindowOptions: BrowserWindowConstructorOptions = {
resizable: true,
fullscreen: false,
fullscreenable: true,
useContentSize: true,
webPreferences: {
nodeIntegration: true,
webSecurity: false,
// FIXME: electron v12 throw error: 'Require' is not defined. `contextIsolation` default value is changed to `true`.
// You can read more in https://www.electronjs.org/blog/electron-12-0#breaking-changes
contextIsolation: false,
},
}
const getWindowOptions = (
extendedWindowOptions?: BrowserWindowConstructorOptions
) => ({
...commonWindowOptions,
...extendedWindowOptions,
})
const installElectronDevToolExtensions = async () => {
try {
await installExtension([REDUX_DEVTOOLS, REACT_DEVELOPER_TOOLS], {
loadExtensionOptions: {
allowFileAccess: true,
},
})
console.info(`[INFO] Successfully added devtools extensions`)
} catch (err) {
console.warn(
"[WARN] An error occurred while trying to add devtools extensions:\n",
err
)
}
}
const createWindow = async () => {
const version = packageInfo.version
const prereleaseSet = isPrereleaseSet(version)
const title = prereleaseSet ? `Mudita Center - ${version}` : "Mudita Center"
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call
;(global as any).__static = require("path")
.join(__dirname, "/static")
.replace(/\\/g, "\\\\")
win = new BrowserWindow(
getWindowOptions({
minWidth: WINDOW_SIZE.minWidth,
width: WINDOW_SIZE.width,
minHeight: WINDOW_SIZE.minHeight,
height: WINDOW_SIZE.height,
title,
})
)
// FIXME: electron v12 added changes to the remote module. This module has many subtle pitfalls.
// There is almost always a better way to accomplish your task than using this module.
// You can read more in https://github.com/electron/remote#migrating-from-remote
require("@electron/remote/main").enable(win.webContents)
win.removeMenu()
win.webContents.on("before-input-event", (event, input) => {
if ((input.control || input.meta) && input.key.toLowerCase() === "r") {
event.preventDefault()
}
})
win.on("closed", () => {
win = null
app.exit()
})
new MetadataInitializer(metadataStore).init()
const registerDownloadListener = createDownloadListenerRegistrar(win)
const settingsService = createSettingsService()
settingsService.init()
const appModules = new ApplicationModule(ipcMain, win)
registerPureOsDownloadListener(registerDownloadListener)
registerOsUpdateAlreadyDownloadedCheck()
registerNewsListener()
registerAppLogsListeners()
registerContactsExportListener()
registerWriteFileListener()
registerCopyFileListener()
registerRmdirListener()
registerWriteGzipListener()
registerArchiveFilesListener()
registerMetadataAllGetValueListener()
registerMetadataGetValueListener()
registerMetadataSetValueListener()
registerExternalUsageDevice()
if (productionEnvironment) {
win.setMenuBarVisibility(false)
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
win.loadURL(
url.format({
pathname: path.join(__dirname, "index.html"),
protocol: "file:",
slashes: true,
})
)
autoupdate(win)
} else {
await installElectronDevToolExtensions()
process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = "1"
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
win.loadURL(`http://localhost:2003`)
mockAutoupdate(win)
}
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return {
action: "deny",
overrideBrowserWindowOptions: {},
}
})
if (productionEnvironment) {
win.webContents.once("dom-ready", () => {
appModules.lateInitialization()
})
} else {
// Open DevTools, see https://github.com/electron/electron/issues/12438 for why we wait for dom-ready
win.webContents.once("dom-ready", () => {
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
win!.webContents.openDevTools()
appModules.lateInitialization()
})
win.webContents.once("dom-ready", () => {
win!.webContents.once("devtools-opened", () => {
win!.focus()
})
win!.webContents.openDevTools()
})
}
logger.updateMetadata()
}
if (!gotTheLock) {
app.quit()
} else {
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
app.on("ready", createWindow)
app.on("window-all-closed", () => {
app.quit()
})
app.on("activate", () => {
if (win === null) {
void createWindow()
}
})
}
ipcMain.answerRenderer(HelpActions.OpenWindow, () => {
const title = "Mudita Center - Help"
if (helpWindow === null) {
helpWindow = new BrowserWindow(
getWindowOptions({
width: DEFAULT_WINDOWS_SIZE.width,
height: DEFAULT_WINDOWS_SIZE.height,
title,
})
)
// FIXME: electron v12 added changes to the remote module. This module has many subtle pitfalls.
// There is almost always a better way to accomplish your task than using this module.
// You can read more in https://github.com/electron/remote#migrating-from-remote
require("@electron/remote/main").enable(helpWindow.webContents)
helpWindow.removeMenu()
helpWindow.on("closed", () => {
removeDownloadHelpHandler()
removeSetHelpStoreHandler()
removeGetHelpStoreHandler()
helpWindow = null
})
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
helpWindow.loadURL(
!productionEnvironment
? `http://localhost:2003/?mode=${Mode.Help}#${URL_MAIN.help}`
: url.format({
pathname: path.join(__dirname, "index.html"),
protocol: "file:",
slashes: true,
hash: URL_MAIN.help,
search: `?mode=${Mode.Help}`,
})
)
registerDownloadHelpHandler()
registerSetHelpStoreHandler()
registerGetHelpStoreHandler()
} else {
helpWindow.show()
}
})
const createOpenWindowListener = (
channel: string,
mode: string,
urlMain: string,
title: string,
newWindow: BrowserWindow | null = null
) => {
ipcMain.answerRenderer(channel, async () => {
if (newWindow === null) {
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/await-thenable
newWindow = new BrowserWindow(
getWindowOptions({
width: DEFAULT_WINDOWS_SIZE.width,
height: DEFAULT_WINDOWS_SIZE.height,
title,
})
)
// FIXME: electron v12 added changes to the remote module. This module has many subtle pitfalls.
// There is almost always a better way to accomplish your task than using this module.
// You can read more in https://github.com/electron/remote#migrating-from-remote
require("@electron/remote/main").enable(newWindow.webContents)
newWindow.removeMenu()
newWindow.on("closed", () => {
newWindow = null
})
await newWindow.loadURL(
!productionEnvironment
? `http://localhost:2003/?mode=${mode}#${urlMain}`
: url.format({
pathname: path.join(__dirname, "index.html"),
protocol: "file:",
slashes: true,
hash: urlMain,
search: `?mode=${mode}`,
})
)
newWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return {
action: "allow",
overrideBrowserWindowOptions: {},
}
})
} else {
newWindow.show()
}
})
}
ipcMain.answerRenderer(BrowserActions.PolicyOpenBrowser, () =>
shell.openExternal(
`${process.env.MUDITA_CENTER_SERVER_URL ?? ""}/privacy-policy-url`
)
)
ipcMain.answerRenderer(BrowserActions.UpdateOpenBrowser, () =>
shell.openExternal("https://mudita.com")
)
ipcMain.answerRenderer(BrowserActions.AppleOpenBrowser, () =>
shell.openExternal(
"https://support.apple.com/en-al/guide/contacts/adrbdcfd32e6/mac#:~:text=In%20the%20Contacts%20app%20on,vcf)%20only."
)
)
createOpenWindowListener(
AboutActions.LicenseOpenWindow,
Mode.License,
URL_MAIN.license,
"Mudita Center - License",
licenseWindow
)
createOpenWindowListener(
AboutActions.TermsOpenWindow,
Mode.TermsOfService,
URL_MAIN.termsOfService,
"Mudita Center - Terms of service",
termsWindow
)
createOpenWindowListener(
AboutActions.PolicyOpenWindow,
Mode.PrivacyPolicy,
URL_MAIN.privacyPolicy,
"Mudita Center - Privacy policy",
policyWindow
)
createOpenWindowListener(
PureSystemActions.SarOpenWindow,
Mode.Sar,
URL_OVERVIEW.sar,
"Mudita Center - SAR information",
policyWindow
)
const createErrorWindow = async (googleAuthWindow: BrowserWindow) => {
return await googleAuthWindow.loadURL(
!productionEnvironment
? `http://localhost:2003/?mode=${Mode.ServerError}#${URL_MAIN.error}`
: url.format({
pathname: path.join(__dirname, "index.html"),
protocol: "file:",
slashes: true,
hash: URL_MAIN.error,
search: `?mode=${Mode.ServerError}`,
})
)
}
ipcMain.answerRenderer(GoogleAuthActions.OpenWindow, async (scope: Scope) => {
const title = "Mudita Center - Google Auth"
if (process.env.MUDITA_CENTER_SERVER_URL) {
const cb = (data: string) => {
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
ipcMain.callRenderer(
win as BrowserWindow,
GoogleAuthActions.GotCredentials,
data
)
}
if (googleAuthWindow === null) {
googleAuthWindow = new BrowserWindow(
getWindowOptions({
width: GOOGLE_AUTH_WINDOW_SIZE.width,
height: GOOGLE_AUTH_WINDOW_SIZE.height,
title,
webPreferences: {
nodeIntegration: true,
},
})
)
googleAuthWindow.removeMenu()
googleAuthWindow.on("close", () => {
void ipcMain.callRenderer(
win as BrowserWindow,
GoogleAuthActions.CloseWindow
)
googleAuthWindow = null
killAuthServer()
})
if (await checkPort(authServerPort)) {
await createErrorWindow(googleAuthWindow)
return
}
createAuthServer(cb)
let scopeUrl: string
switch (scope) {
case Scope.Calendar:
scopeUrl = "https://www.googleapis.com/auth/calendar"
break
case Scope.Contacts:
scopeUrl = "https://www.googleapis.com/auth/contacts"
break
}
const url = `${process.env.MUDITA_CENTER_SERVER_URL}/google-auth-init`
void (await googleAuthWindow.loadURL(`${url}?scope=${scopeUrl}`))
} else {
googleAuthWindow.show()
}
} else {
console.log("No Google Auth URL defined!")
}
})
ipcMain.answerRenderer(GoogleAuthActions.CloseWindow, () => {
killAuthServer()
googleAuthWindow?.close()
})
ipcMain.answerRenderer(
OutlookAuthActions.OpenWindow,
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/require-await
async (data: { authorizationUrl: string; scope: string }) => {
const title = "Mudita Center - Outlook Auth"
const { authorizationUrl, scope } = data
if (clientId) {
if (outlookAuthWindow === null) {
outlookAuthWindow = new BrowserWindow(
getWindowOptions({
width: 600,
height: 600,
title,
})
)
outlookAuthWindow.removeMenu()
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
outlookAuthWindow.loadURL(authorizationUrl)
outlookAuthWindow.on("close", () => {
void ipcMain.callRenderer(
win as BrowserWindow,
OutlookAuthActions.CloseWindow
)
outlookAuthWindow = null
})
const {
session: { webRequest },
} = outlookAuthWindow.webContents
webRequest.onBeforeRequest(
{
urls: [`${redirectUrl}*`],
}, // * character is used to "catch all" url params
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
async ({ url }) => {
const code = new URL(url).searchParams.get("code") || ""
try {
const tokenRequester = new TokenRequester()
const tokens = await tokenRequester.requestTokens(code, scope)
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
ipcMain.callRenderer(
win as BrowserWindow,
OutlookAuthActions.GotCredentials,
tokens
)
} catch (error) {
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
ipcMain.callRenderer(
win as BrowserWindow,
OutlookAuthActions.GotCredentials,
{ error }
)
}
outlookAuthWindow?.close()
outlookAuthWindow = null
}
)
} else {
outlookAuthWindow.show()
}
} else {
logger.info("No Outlook Auth URL defined!")
}
}
)
ipcMain.answerRenderer(OutlookAuthActions.CloseWindow, () => {
outlookAuthWindow?.close()
})

View File

@@ -16,6 +16,10 @@ import store from "Core/__deprecated__/renderer/store"
jest.mock("Core/feature-flags")
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
test("matches snapshot without tabs", () => {
const currentLocation = "/overview"
const { container } = renderWithThemeAndIntl(

View File

@@ -27,6 +27,10 @@ import {
} from "Core/device-initialization/reducers/device-initialization.interface"
import { DeviceManagerState } from "Core/device-manager/reducers/device-manager.interface"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("Core/feature-flags")
type Props = MenuProps

View File

@@ -862,5 +862,32 @@
"module.genericBackup.createButtonLabel": "Create backup",
"module.genericBackup.restoreButtonLabel": "Restore from backup",
"module.genericViews.update.tag": "Update is available ({version}).",
"module.genericViews.update.actionLabel": "You can update it on your device."
"module.genericViews.update.actionLabel": "You can update it on your device.",
"module.genericViews.backup.features.title": "Create backup",
"module.genericViews.backup.features.description": "All backup data stays on your computer.",
"module.genericViews.backup.features.cancelButtonLabel": "Cancel",
"module.genericViews.backup.features.createButtonLabel": "Create backup",
"module.genericViews.backup.password.title": "Create password for backup",
"module.genericViews.backup.password.subtitle": "(optional)",
"module.genericViews.backup.password.description": "You can protect backup with a new password.",
"module.genericViews.backup.password.description2": "* You can't change/recover the password later.",
"module.genericViews.backup.password.passwordPlaceholder": "Password",
"module.genericViews.backup.password.passwordRepeatPlaceholder": "Repeat password",
"module.genericViews.backup.password.passwordRepeatNotMatchingError": "Passwords do not match",
"module.genericViews.backup.password.confirmButtonLabel": "Confirm password",
"module.genericViews.backup.password.skipButtonLabel": "Skip password",
"module.genericViews.backup.progress.title": "Creating backup",
"module.genericViews.backup.progress.description": "Please wait and do not unplug your device from computer.",
"module.genericViews.backup.progress.progressDetails": "This might take a few minutes",
"module.genericViews.backup.progress.progressDetailsForFeature": "Backing up {featureLabel}",
"module.genericViews.backup.success.title": "Backup complete",
"module.genericViews.backup.success.description": "Your data was successfully secured.\nOpen the backup folder to see your backup data or close this window.",
"module.genericViews.backup.success.openBackupButtonLabel": "Open backup folder",
"module.genericViews.backup.success.closeButtonLabel": "Close",
"module.genericViews.backup.failure.title": "Backup failed",
"module.genericViews.backup.failure.defaultErrorMessage": "The backup process was interrupted.",
"module.genericViews.backup.failure.closeButtonLabel": "Close",
"module.genericViews.backup.directoryOpenFailure.title": "Directory open failed",
"module.genericViews.backup.directoryOpenFailure.defaultErrorMessage": "The directory could not be opened.\n\nYou can still try to find the backup in the default location:",
"module.genericViews.backup.directoryOpenFailure.closeButtonLabel": "Close"
}

View File

@@ -24,6 +24,7 @@ import { appInitializationReducer } from "Core/app-initialization/reducers/app-i
import { deviceManagerReducer } from "Core/device-manager/reducers/device-manager.reducer"
import {
genericBackupsReducer,
genericFileTransferReducer,
genericModalsReducer,
genericViewsReducer,
} from "generic-view/store"
@@ -51,6 +52,7 @@ export const reducers = {
genericViews: genericViewsReducer,
genericModals: genericModalsReducer,
genericBackups: genericBackupsReducer,
genericFileTransfer: genericFileTransferReducer,
appState: appStateReducer,
}

View File

@@ -17,6 +17,10 @@ import { AppUpdateStepModalTestIds } from "Core/__deprecated__/renderer/wrappers
import { Provider } from "react-redux"
import store from "../../store"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
const onCloseMock = jest.fn()
const openExternalMock = jest.fn()

View File

@@ -11,6 +11,10 @@ import ModalsManager from "Core/modals-manager/components/modals-manager.contain
import ContactSupportFlow from "Core/contact-support/containers/contact-support-flow.container"
import { ContactSupportFlowTestIds } from "Core/contact-support/components/contact-support-flow-test-ids.component"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
type Props = ComponentProps<typeof ModalsManager>
const defaultProps: Props = {}

View File

@@ -38,6 +38,10 @@ window.IntersectionObserver = jest
type Props = ComponentProps<typeof Contacts>
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("@electron/remote", () => ({
Menu: () => ({
popup: jest.fn(),

View File

@@ -46,6 +46,7 @@ import {
} from "Core/device-manager/services"
import { APIModule } from "device/feature"
import { FileSystemDialogModule } from "shared/app-state"
import { SystemUtilsModule } from "system-utils/feature"
export class ApplicationModule {
public modules: Module[] = [
@@ -89,6 +90,7 @@ export class ApplicationModule {
new DeviceResolverService(),
this.eventEmitter
)
private systemUtilsModule = new SystemUtilsModule()
constructor(
private ipc: MainProcessIpc,
@@ -105,11 +107,12 @@ export class ApplicationModule {
this.initializeInitializer = new InitializeInitializer()
this.modules.forEach(this.initModule)
this.apiModule = new APIModule(this.deviceManager)
this.apiModule = new APIModule(this.deviceManager, this.systemUtilsModule)
this.controllerInitializer.initialize(this.apiModule.getAPIServices())
this.controllerInitializer.initialize(
FileSystemDialogModule.getControllers()
)
this.controllerInitializer.initialize(this.systemUtilsModule.getServices())
}
lateInitialization(): void {

View File

@@ -3,11 +3,11 @@
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { AppError } from "Core/core/errors"
import { AppError, AppErrorType } from "Core/core/errors"
export type ResultObject<
Data,
ErrorType extends string = string,
ErrorType extends AppErrorType = AppErrorType,
ErrorData = unknown
> = SuccessResult<Data> | FailedResult<ErrorData, ErrorType>
@@ -18,7 +18,7 @@ export class SuccessResult<Data> {
constructor(public data: Data) {}
}
export class FailedResult<Data, ErrorType extends string = string> {
export class FailedResult<Data, ErrorType extends AppErrorType = AppErrorType> {
public ok: false = false
constructor(
@@ -32,7 +32,7 @@ export class Result {
return new SuccessResult<Data>(data)
}
static failed<Data, ErrorType extends string = string>(
static failed<Data, ErrorType extends AppErrorType = AppErrorType>(
error: AppError<ErrorType>,
data?: Data
): FailedResult<Data, ErrorType> {

View File

@@ -35,99 +35,101 @@ import ConfiguredDevicesDiscovery from "Core/discovery-device/components/configu
import DevicesInitialization from "Core/device-initialization/components/devices-initialization.component"
import AvailableDeviceListContainer from "Core/discovery-device/components/available-device-list.container"
import DeviceConnecting from "Core/discovery-device/components/device-connecting.component"
import { GenericView } from "generic-view/feature"
import { ApiDeviceModals, GenericView } from "generic-view/feature"
import { APIConnectionDemo } from "generic-view/ui"
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export default () => (
<Switch>
<Redirect exact from={URL_MAIN.root} to={URL_ONBOARDING.root} />
<Redirect from={URL_ONBOARDING.root} to={URL_ONBOARDING.welcome} exact />
<>
<Switch>
<Redirect exact from={URL_MAIN.root} to={URL_ONBOARDING.root} />
<Redirect from={URL_ONBOARDING.root} to={URL_ONBOARDING.welcome} exact />
<Route exact path={[...Object.values(URL_ONBOARDING)]}>
<LayoutBlankWrapper>
<Route path={URL_ONBOARDING.welcome} component={Onboarding} />
<Route
path={URL_ONBOARDING.troubleshooting}
component={OnboardingTroubleshooting}
/>
</LayoutBlankWrapper>
</Route>
<Route exact path={URL_OVERVIEW.pureSystem}>
<LayoutDesktopWrapperWithoutHeader>
<Route path={URL_OVERVIEW.pureSystem} component={PureSystem} />
</LayoutDesktopWrapperWithoutHeader>
</Route>
<Route exact path={[...Object.values(URL_DISCOVERY_DEVICE)]}>
<LayoutBlankWrapper>
<Route
path={URL_DISCOVERY_DEVICE.root}
component={ConfiguredDevicesDiscovery}
exact
/>
<Route
path={URL_DISCOVERY_DEVICE.deviceConnecting}
component={DeviceConnecting}
exact
/>
<Route
path={URL_DISCOVERY_DEVICE.availableDeviceListModal}
component={AvailableDeviceListContainer}
exact
/>
</LayoutBlankWrapper>
</Route>
<Route exact path={[...Object.values(URL_DEVICE_INITIALIZATION)]}>
<LayoutBlankWrapper>
<Route
path={URL_DEVICE_INITIALIZATION.root}
component={DevicesInitialization}
/>
</LayoutBlankWrapper>
</Route>
<Route>
<LayoutDesktopWrapper>
<Switch>
<Route exact path={[...Object.values(URL_ONBOARDING)]}>
<LayoutBlankWrapper>
<Route path={URL_ONBOARDING.welcome} component={Onboarding} />
<Route
path={"/generic/api-connection-demo"}
component={APIConnectionDemo}
path={URL_ONBOARDING.troubleshooting}
component={OnboardingTroubleshooting}
/>
</LayoutBlankWrapper>
</Route>
<Route exact path={URL_OVERVIEW.pureSystem}>
<LayoutDesktopWrapperWithoutHeader>
<Route path={URL_OVERVIEW.pureSystem} component={PureSystem} />
</LayoutDesktopWrapperWithoutHeader>
</Route>
<Route exact path={[...Object.values(URL_DISCOVERY_DEVICE)]}>
<LayoutBlankWrapper>
<Route
path={URL_DISCOVERY_DEVICE.root}
component={ConfiguredDevicesDiscovery}
exact
/>
<Route
path={"/generic/:viewKey/:subviewKey"}
component={GenericView}
/>
<Route path={"/generic/:viewKey"} component={GenericView} />
<Route path={URL_MAIN.filesManager} component={FilesManager} />
<Route path={URL_MAIN.messages} component={Messages} exact />
<Route
path={`${URL_MAIN.messages}${URL_TABS.templates}`}
component={TemplatesContainer}
/>
<Route path={URL_MAIN.news} component={News} />
<Route path={URL_OVERVIEW.root} component={Overview} exact />
<Route path={URL_MAIN.contacts} component={Contacts} exact />
<Route path={URL_MAIN.settings} component={BackupContainer} exact />
<Route
path={`${URL_MAIN.settings}${URL_TABS.notifications}`}
component={NotificationsContainer}
path={URL_DISCOVERY_DEVICE.deviceConnecting}
component={DeviceConnecting}
exact
/>
<Route
path={`${URL_MAIN.settings}${URL_TABS.audioConversion}`}
component={AudioConversionContainer}
path={URL_DISCOVERY_DEVICE.availableDeviceListModal}
component={AvailableDeviceListContainer}
exact
/>
<Route
path={`${URL_MAIN.settings}${URL_TABS.about}`}
component={AboutContainer}
/>
</Switch>
</LayoutDesktopWrapper>
</Route>
</LayoutBlankWrapper>
</Route>
<Redirect to={URL_OVERVIEW.root} />
</Switch>
<Route exact path={[...Object.values(URL_DEVICE_INITIALIZATION)]}>
<LayoutBlankWrapper>
<Route
path={URL_DEVICE_INITIALIZATION.root}
component={DevicesInitialization}
/>
</LayoutBlankWrapper>
</Route>
<Route>
<LayoutDesktopWrapper>
<Switch>
<Route
path={"/generic/api-connection-demo"}
component={APIConnectionDemo}
/>
<Route
path={"/generic/:viewKey/:subviewKey"}
component={GenericView}
/>
<Route path={"/generic/:viewKey"} component={GenericView} />
<Route path={URL_MAIN.filesManager} component={FilesManager} />
<Route path={URL_MAIN.messages} component={Messages} exact />
<Route
path={`${URL_MAIN.messages}${URL_TABS.templates}`}
component={TemplatesContainer}
/>
<Route path={URL_MAIN.news} component={News} />
<Route path={URL_OVERVIEW.root} component={Overview} exact />
<Route path={URL_MAIN.contacts} component={Contacts} exact />
<Route path={URL_MAIN.settings} component={BackupContainer} exact />
<Route
path={`${URL_MAIN.settings}${URL_TABS.notifications}`}
component={NotificationsContainer}
/>
<Route
path={`${URL_MAIN.settings}${URL_TABS.audioConversion}`}
component={AudioConversionContainer}
/>
<Route
path={`${URL_MAIN.settings}${URL_TABS.about}`}
component={AboutContainer}
/>
</Switch>
</LayoutDesktopWrapper>
</Route>
<Redirect to={URL_OVERVIEW.root} />
</Switch>
<ApiDeviceModals />
</>
)

View File

@@ -18,8 +18,12 @@ import { useDeviceDetachedEffect } from "Core/core/hooks/use-device-detached-eff
import { useDeviceConnectFailedEffect } from "Core/core/hooks/use-device-connect-failed-effect"
import { useDiscoveryRedirectEffect } from "Core/core/hooks/use-discovery-redirect-effect"
import { useRouterListener } from "Core/core/hooks"
import { useAPISerialPortListeners, useOutbox } from "generic-view/store"
import {
OutboxWrapper,
useAPISerialPortListeners,
useBackupList,
useAppEventsListeners,
} from "generic-view/store"
const BaseApp: FunctionComponent = () => {
useRouterListener()
@@ -33,10 +37,11 @@ const BaseApp: FunctionComponent = () => {
useDiscoveryRedirectEffect()
// API
useAPISerialPortListeners()
useOutbox()
useAppEventsListeners()
useBackupList()
return (
<>
<OutboxWrapper />
<CrashDump />
<NetworkStatusChecker />
<ModalsManager />

View File

@@ -3,13 +3,19 @@
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export class AppError<Type extends string = string> extends Error {
export type AppErrorType = string | number
export class AppError<
Type extends AppErrorType = AppErrorType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Payload = any
> extends Error {
constructor(
public readonly type: Type,
public readonly message: string = "",
// AUTO DISABLED - fix me if you like :)
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
public readonly payload?: any
public readonly payload?: Payload
) {
super()

View File

@@ -4,3 +4,4 @@
*/
export { AppError } from "./app-error"
export type { AppErrorType } from "./app-error"

View File

@@ -19,6 +19,7 @@ import { ApiError } from "device/models"
import { intl } from "Core/__deprecated__/renderer/utils/intl"
import { defineMessages } from "react-intl"
import {
closeButtonStyles,
IconButton,
ModalBase,
ModalCenteredContent,
@@ -122,9 +123,9 @@ export const APIDeviceInitializationModalFlow: FunctionComponent = () => {
variant={"small"}
overlayHidden
closeButton={
<IconButton onClick={onModalClose}>
<CloseButton onClick={onModalClose}>
<ModalCloseIcon />
</IconButton>
</CloseButton>
}
>
<ModalCenteredContent>
@@ -151,3 +152,7 @@ const ConnectingText = styled.p`
font-weight: ${({ theme }) => theme.fontWeight.bold};
margin: 2.4rem 0 0;
`
const CloseButton = styled(IconButton)`
${closeButtonStyles};
`

View File

@@ -8,12 +8,14 @@ import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import { DeviceManagerEvent } from "Core/device-manager/constants"
import { setActiveDeviceRequest } from "Core/device-manager/requests"
import { DeviceId } from "Core/device/constants/device-id"
import { cleanBackupProcess } from "generic-view/store"
export const setActiveDevice = createAsyncThunk<
DeviceId | undefined,
DeviceId | undefined,
{ state: ReduxRootState }
>(DeviceManagerEvent.SetActiveDevice, async (payload) => {
>(DeviceManagerEvent.SetActiveDevice, async (payload, { dispatch }) => {
await setActiveDeviceRequest(payload)
dispatch(cleanBackupProcess())
return payload
})

View File

@@ -12,11 +12,18 @@ import { Result, ResultObject } from "Core/core/builder"
import { deleteFiles } from "Core/files-manager/actions/delete-files.action"
import { AppError } from "Core/core/errors"
import { testError } from "Core/__deprecated__/renderer/store/constants"
import { fulfilledAction, pendingAction } from "Core/__deprecated__/renderer/store"
import {
fulfilledAction,
pendingAction,
} from "Core/__deprecated__/renderer/store"
import * as loadStorageInfoActionModule from "Core/device/actions/load-storage-info.action"
import * as loadDeviceDataActionModule from "Core/device/actions/load-device-data.action"
import { DeviceEvent } from "Core/device"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("Core/files-manager/requests/delete-files.request")
jest
.spyOn(loadStorageInfoActionModule, "loadStorageInfoAction")
@@ -26,14 +33,12 @@ jest
type: pendingAction(DeviceEvent.LoadStorageInfo),
} as unknown as jest.Mock)
)
jest
.spyOn(loadDeviceDataActionModule, "loadDeviceData")
.mockImplementation(
() =>
({
type: fulfilledAction(DeviceEvent.LoadDeviceData),
} as unknown as jest.Mock)
)
jest.spyOn(loadDeviceDataActionModule, "loadDeviceData").mockImplementation(
() =>
({
type: fulfilledAction(DeviceEvent.LoadDeviceData),
} as unknown as jest.Mock)
)
const filePaths = [
"user/music/example_file_name.mp3",
"user/music/second_example_file_name.wav",

View File

@@ -18,16 +18,18 @@ import * as loadDeviceDataActionModule from "Core/device/actions/load-device-dat
import { fulfilledAction } from "Core/__deprecated__/renderer/store"
import { DeviceEvent } from "Core/device"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("Core/files-manager/requests/get-files.request")
jest
.spyOn(loadDeviceDataActionModule, "loadDeviceData")
.mockImplementation(
() =>
({
type: fulfilledAction(DeviceEvent.LoadDeviceData),
} as unknown as jest.Mock)
)
jest.spyOn(loadDeviceDataActionModule, "loadDeviceData").mockImplementation(
() =>
({
type: fulfilledAction(DeviceEvent.LoadDeviceData),
} as unknown as jest.Mock)
)
const successObjectResult: ResultObject<File[]> = Result.success([
{

View File

@@ -12,6 +12,10 @@ import { HelpComponentTestIds } from "Core/help/components/help.enum"
import Help from "Core/help/components/help.component"
import { data } from "Core/__deprecated__/seeds/help"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("@electron/remote", () => ({
dialog: {
showOpenDialog: jest.fn(),

View File

@@ -16,6 +16,10 @@ import { Provider } from "react-redux"
import { CheckForUpdateState } from "Core/update/constants/check-for-update-state.constant"
import { CaseColour } from "Core/device"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("Core/feature-flags")
jest.mock("@electron/remote", () => ({

View File

@@ -20,6 +20,10 @@ import { CheckForUpdateState } from "Core/update/constants/check-for-update-stat
// TODO [mw] add integration tests for update process - scope of the next PR (after all the changes from CP-1681 are done)
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("Core/feature-flags")
jest.mock("@electron/remote", () => ({

View File

@@ -23,6 +23,10 @@ import { CheckForUpdateState } from "Core/update/constants/check-for-update-stat
type Props = ComponentProps<typeof Overview>
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("@electron/remote", () => ({
Menu: () => ({
popup: jest.fn(),

View File

@@ -15,6 +15,10 @@ import { initialState as update } from "Core/update/reducers"
import { OsRelease } from "Core/update/dto"
import { OsReleaseType, Product } from "Core/update/constants"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
const defaultProps: SystemUpdateTextProps = {
checkForUpdateFailed: false,
checkForUpdateInProgress: false,

View File

@@ -22,6 +22,10 @@ import { renderWithThemeAndIntl } from "Core/__deprecated__/renderer/utils/rende
import React from "react"
import { CheckForUpdateState } from "Core/update/constants/check-for-update-state.constant"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("@electron/remote", () => ({
Menu: () => ({
popup: jest.fn(),

View File

@@ -15,6 +15,10 @@ import { flags } from "Core/feature-flags"
import store from "Core/__deprecated__/renderer/store"
import { Provider } from "react-redux"
jest.mock("Core/settings/store/schemas/generate-application-id", () => ({
generateApplicationId: () => "123",
}))
jest.mock("Core/feature-flags")
jest.mock("electron-better-ipc", () => {
return {

View File

@@ -0,0 +1,28 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import getMAC from "getmac"
export const generateApplicationId = (): string | null => {
const maxApplicationIdLength = 16
const mac = getMACOrNull()
if (mac !== null) {
const uniqueValue = mac.replace(/:/g, "").slice(-maxApplicationIdLength)
const padLength = maxApplicationIdLength - uniqueValue.length
const pad = Math.random().toString(16).slice(-padLength)
return `${pad}${uniqueValue}`
} else {
return null
}
}
const getMACOrNull = (): string | null => {
try {
return getMAC()
} catch (ex) {
return null
}
}

View File

@@ -5,33 +5,11 @@
import path from "path"
import { Schema } from "electron-store"
import getMAC from "getmac"
import getAppPath from "Core/__deprecated__/main/utils/get-app-path"
import { Settings } from "Core/settings/dto"
import { ConversionFormat, Convert } from "Core/settings/constants"
import translationConfig from "App/translations.config.json"
const generateApplicationId = (): string | null => {
const maxApplicationIdLength = 16
const mac = getMACOrNull()
if (mac !== null) {
const uniqueValue = mac.replace(/:/g, "").slice(-maxApplicationIdLength)
const padLength = maxApplicationIdLength - uniqueValue.length
const pad = Math.random().toString(16).slice(-padLength)
return `${pad}${uniqueValue}`
} else {
return null
}
}
const getMACOrNull = (): string | null => {
try {
return getMAC()
} catch (ex) {
return null
}
}
import { generateApplicationId } from "Core/settings/store/schemas/generate-application-id"
export const settingsSchema: Schema<Settings> = {
applicationId: {

View File

@@ -10,3 +10,6 @@ export * from "./lib/api-features"
export * from "./lib/outbox"
export * from "./lib/menu"
export * from "./lib/server"
export * from "./lib/file-transfer"
export * from "./lib/file-manager"
export * from "./lib/backup"

View File

@@ -7,9 +7,14 @@ import { DeviceManager } from "Core/device-manager/services"
import { APIConfigService } from "./api-config/api-config.service"
import { APIFeaturesService } from "./api-features/api-features.service"
import { APIBackupService } from "./backup"
import { FileManager } from "./file-manager"
import { APIMenuService } from "./menu"
import { APIOutboxService } from "./outbox/outbox.service"
import { ServerService } from "./server/server.service"
import { APIFileTransferService } from "./file-transfer"
import { ServiceBridge } from "./service-bridge"
import { SystemUtilsModule } from "system-utils/feature"
import { createSettingsService } from "Core/settings/containers/settings.container"
export class APIModule {
private apiConfigService: APIConfigService
@@ -18,14 +23,26 @@ export class APIModule {
private apiMenuService: APIMenuService
private serverService: ServerService
private backupService: APIBackupService
private fileTransferService: APIFileTransferService
private fileManager: FileManager
private serviceBridge: ServiceBridge
constructor(deviceManager: DeviceManager) {
constructor(
deviceManager: DeviceManager,
systemUtilsModule: SystemUtilsModule
) {
this.serviceBridge = new ServiceBridge()
this.apiConfigService = new APIConfigService(deviceManager)
this.apiFeaturesService = new APIFeaturesService(deviceManager)
this.apiOutboxService = new APIOutboxService(deviceManager)
this.apiMenuService = new APIMenuService(deviceManager)
this.serverService = new ServerService()
this.backupService = new APIBackupService(deviceManager)
this.fileTransferService = new APIFileTransferService(deviceManager)
this.fileManager = new FileManager(deviceManager, this.serviceBridge)
this.serviceBridge.systemUtilsModule = systemUtilsModule
this.serviceBridge.fileTransfer = this.fileTransferService
this.serviceBridge.settingsService = createSettingsService()
}
public getAPIServices() {
@@ -36,6 +53,8 @@ export class APIModule {
this.apiMenuService,
this.serverService,
this.backupService,
this.fileTransferService,
this.fileManager,
]
}
}

View File

@@ -112,7 +112,7 @@ export class APIBackupService {
}
private parsePreBackupResponse(
response: ResultObject<ApiResponse<unknown>, string, unknown>,
response: ResultObject<ApiResponse<unknown>, string | number, unknown>,
features: string[]
) {
if (!response.ok) {

View File

@@ -8,7 +8,7 @@ import { ipcRenderer } from "electron-better-ipc"
import { APIBackupServiceEvents, PreBackup } from "device/models"
import { DeviceId } from "Core/device/constants/device-id"
export const getStartPreBackupRequest = (
export const startPreBackupRequest = (
features: string[],
deviceId?: DeviceId
): Promise<ResultObject<PreBackup>> => {

View File

@@ -0,0 +1,177 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { Result, ResultObject } from "Core/core/builder"
import { IpcEvent } from "Core/core/decorators"
import { ServiceBridge } from "../service-bridge"
import { FileManagerServiceEvents, GeneralError } from "device/models"
import { AppError } from "Core/core/errors"
import { DeviceManager } from "Core/device-manager/services"
import { DeviceId } from "Core/device/constants/device-id"
import packageInfo from "../../../../../../apps/mudita-center/package.json"
import { writeFileSync, writeJSONSync, mkdirSync, readdirSync } from "fs-extra"
import AES from "crypto-js/aes"
import path from "path"
export class FileManager {
constructor(
private deviceManager: DeviceManager,
private serviceBridge: ServiceBridge
) {}
@IpcEvent(FileManagerServiceEvents.SaveFile)
public saveFileByTransferId({
filePath,
transferId,
}: {
filePath: string
transferId: number
}): ResultObject<undefined> {
try {
const file =
this.serviceBridge.fileTransfer.getFileByTransferId(transferId)
writeFileSync(filePath, file.chunks.join(), "base64")
return Result.success(undefined)
} catch (e) {
console.log(e)
return Result.failed(new AppError(GeneralError.IncorrectResponse))
}
}
@IpcEvent(FileManagerServiceEvents.GetBackupPath)
public getBackupPath({ deviceId }: { deviceId?: DeviceId }) {
const device = deviceId
? this.deviceManager.getAPIDeviceById(deviceId)
: this.deviceManager.apiDevice
if (!device) {
return Result.failed(new AppError(GeneralError.NoDevice, ""))
}
const vendorId = device.portInfo.vendorId ?? "unknown"
const productId = device.portInfo.productId ?? "unknown"
const { osBackupLocation } =
this.serviceBridge.settingsService.getSettings()
const backupLocation = path.join(
osBackupLocation,
`${vendorId}-${productId}`
)
return Result.success(backupLocation)
}
@IpcEvent(FileManagerServiceEvents.OpenBackupDirectory)
public openBackupDirectory({ deviceId }: { deviceId?: DeviceId }) {
const backupDirectory = this.getBackupPath({ deviceId })
if (!backupDirectory.ok) {
return Result.failed(new AppError(GeneralError.InternalError, ""))
}
return this.serviceBridge.systemUtilsModule.directory.open({
path: backupDirectory.data,
})
}
@IpcEvent(FileManagerServiceEvents.SaveBackupFile)
public saveBackupFile({
deviceId,
featureToTransferId,
password,
}: {
deviceId?: DeviceId
featureToTransferId: Record<string, number>
password?: string
}) {
const device = deviceId
? this.deviceManager.getAPIDeviceById(deviceId)
: this.deviceManager.apiDevice
if (!device) {
return Result.failed(new AppError(GeneralError.NoDevice, ""))
}
try {
const vendorId = device.portInfo.vendorId ?? ""
const productId = device.portInfo.productId ?? ""
const serialNumber = device.portInfo.serialNumber ?? ""
const timestamp = new Date().getTime()
const appVersion = packageInfo.version
const backupDirectory = this.getBackupPath({ deviceId })
if (!backupDirectory.ok) {
return Result.failed(new AppError(GeneralError.InternalError, ""))
}
const backupFilePath = path.join(
backupDirectory.data,
`${timestamp}_${serialNumber}.mcbackup`
)
const data = Object.entries(featureToTransferId).reduce(
(acc, [feature, transferId]) => {
const transfer =
this.serviceBridge.fileTransfer.getFileByTransferId(transferId)
const featureData = password
? AES.encrypt(transfer.chunks.join(), password).toString()
: transfer.chunks.join()
return { ...acc, [feature]: featureData }
},
{}
)
const fileToSave = {
header: {
vendorId,
productId,
serialNumber,
appVersion,
...(password && { crypto: "AES" }),
},
data,
}
mkdirSync(backupDirectory.data, { recursive: true })
writeJSONSync(backupFilePath, fileToSave)
return Result.success(undefined)
} catch (e) {
console.log(e)
return Result.failed(new AppError(GeneralError.InternalError))
}
}
@IpcEvent(FileManagerServiceEvents.ReadDirectory)
public readDirectory({ path }: { path: string }): ResultObject<string[]> {
try {
const result = readdirSync(path)
return Result.success(result)
} catch (e) {
console.log(e)
return Result.failed(new AppError(GeneralError.InternalError))
}
}
@IpcEvent(FileManagerServiceEvents.ReadBackupDirectory)
public readBackupDirectory({
deviceId,
}: {
deviceId?: DeviceId
}): ResultObject<string[]> {
const device = deviceId
? this.deviceManager.getAPIDeviceById(deviceId)
: this.deviceManager.apiDevice
const pathResult = this.getBackupPath({ deviceId })
if (!pathResult.ok) {
return Result.failed(new AppError(GeneralError.InternalError))
}
return this.readDirectory({ path: pathResult.data })
}
}

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export * from "./file-manager.service"
export * from "./save-file.request"
export * from "./save-backup-file.request"
export * from "./open-backup-directory.request"
export * from "./read-directory.request"
export * from "./read-backup-directory.request"

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ResultObject } from "Core/core/builder"
import { DeviceId } from "Core/device/constants/device-id"
import { FileManagerServiceEvents } from "device/models"
import { ipcRenderer } from "electron-better-ipc"
export const openBackupDirectoryRequest = (
deviceId?: DeviceId
): Promise<ResultObject<string>> => {
return ipcRenderer.callMain(FileManagerServiceEvents.OpenBackupDirectory, {
deviceId,
})
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ResultObject } from "Core/core/builder"
import { DeviceId } from "Core/device/constants/device-id"
import { FileManagerServiceEvents } from "device/models"
import { ipcRenderer } from "electron-better-ipc"
export const readBackupDirectoryRequest = (
deviceId: DeviceId
): Promise<ResultObject<string[]>> => {
return ipcRenderer.callMain(FileManagerServiceEvents.ReadBackupDirectory, {
deviceId,
})
}

View File

@@ -0,0 +1,16 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ResultObject } from "Core/core/builder"
import { FileManagerServiceEvents } from "device/models"
import { ipcRenderer } from "electron-better-ipc"
export const readDirectoryRequest = (
path: string
): Promise<ResultObject<string[]>> => {
return ipcRenderer.callMain(FileManagerServiceEvents.ReadDirectory, {
path,
})
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ResultObject } from "Core/core/builder"
import { DeviceId } from "Core/device/constants/device-id"
import { FileManagerServiceEvents } from "device/models"
import { ipcRenderer } from "electron-better-ipc"
export const saveBackupFileRequest = (
featureToTransferId: Record<string, number>,
deviceId?: DeviceId,
password?: string
): Promise<ResultObject<undefined>> => {
return ipcRenderer.callMain(FileManagerServiceEvents.SaveBackupFile, {
featureToTransferId,
deviceId,
password,
})
}

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ResultObject } from "Core/core/builder"
import { FileManagerServiceEvents } from "device/models"
import { ipcRenderer } from "electron-better-ipc"
export const saveFileRequest = (
filePath: string,
transferId: number
): Promise<ResultObject<undefined>> => {
return ipcRenderer.callMain(FileManagerServiceEvents.SaveFile, {
filePath,
transferId,
})
}

View File

@@ -0,0 +1,339 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { Result, ResultObject } from "Core/core/builder"
import { IpcEvent } from "Core/core/decorators"
import { AppError, AppErrorType } from "Core/core/errors"
import { DeviceManager } from "Core/device-manager/services"
import { DeviceId } from "Core/device/constants/device-id"
import {
ApiFileTransferError,
ApiFileTransferServiceEvents,
FileTransferStatuses,
GeneralError,
PreTransferGet,
PreTransferGetValidator,
PreTransferSendValidator,
TransferGetValidator,
TransferSend,
TransferSendValidator,
} from "device/models"
import { readFileSync } from "fs-extra"
import crc from "js-crc"
interface Transfer {
crc32: string
fileSize: number
filePath: string
chunks: string[]
}
const DEFAULT_MAX_REPEATS = 2
export class APIFileTransferService {
constructor(
private deviceManager: DeviceManager,
private transfers: Record<string, Transfer> = {}
) {}
private prepareFile(path: string) {
const file = readFileSync(path, {
encoding: "base64",
})
return {
file,
crc32: crc.crc32(file),
}
}
public getFileByTransferId(transferId: number) {
return this.transfers[transferId]
}
// Sending files to device
@IpcEvent(ApiFileTransferServiceEvents.PreSend)
public async preTransferSend({
filePath,
targetPath,
deviceId,
}: {
filePath: string
targetPath: string
deviceId?: DeviceId
}): Promise<
ResultObject<{
transferId: number
chunksCount: number
}>
> {
const device = deviceId
? this.deviceManager.getAPIDeviceById(deviceId)
: this.deviceManager.apiDevice
if (!device) {
return Result.failed(new AppError(GeneralError.NoDevice, ""))
}
const { crc32, file } = this.prepareFile(filePath)
const response = await device.request({
endpoint: "PRE_FILE_TRANSFER",
method: "POST",
body: {
filePath: targetPath,
fileSize: file.length,
crc32,
},
})
if (response.ok) {
const preTransferResponse = PreTransferSendValidator.safeParse(
response.data.body
)
const success = preTransferResponse.success
if (!success) {
return handleError(response.data.status)
}
this.transfers[preTransferResponse.data.transferId] = {
crc32,
fileSize: file.length,
filePath,
chunks:
file.match(
new RegExp(`.{1,${preTransferResponse.data.chunkSize}}`, "g")
) || [],
}
return Result.success({
transferId: preTransferResponse.data.transferId,
chunksCount:
this.transfers[preTransferResponse.data.transferId].chunks.length,
})
}
return handleError(response.error.type)
}
@IpcEvent(ApiFileTransferServiceEvents.Send)
public async transferSend({
transferId,
chunkNumber,
deviceId,
repeats = 0,
maxRepeats = DEFAULT_MAX_REPEATS,
}: {
transferId: number
chunkNumber: number
deviceId?: DeviceId
repeats: number
maxRepeats: number
}): Promise<ResultObject<TransferSend>> {
const device = deviceId
? this.deviceManager.getAPIDeviceById(deviceId)
: this.deviceManager.apiDevice
if (!device) {
return Result.failed(new AppError(GeneralError.NoDevice, ""))
}
const data = this.transfers[transferId].chunks[chunkNumber - 1]
const response = await device.request({
endpoint: "FILE_TRANSFER",
method: "POST",
body: {
transferId,
chunkNumber,
data,
},
})
if (!response.ok) {
if (repeats < maxRepeats) {
return this.transferSend({
deviceId,
transferId,
chunkNumber,
repeats: repeats + 1,
maxRepeats,
})
} else {
return handleError(response.error.type)
}
}
const transferResponse = TransferSendValidator.safeParse(response.data.body)
const success =
transferResponse.success &&
[
FileTransferStatuses.WholeFileTransferred,
FileTransferStatuses.FileChunkTransferred,
].includes(response.data.status as number)
if (!success) {
return handleError(response.data.status)
}
return Result.success(transferResponse.data)
}
private validateChecksum(transferId: number) {
const transfer = this.transfers[transferId]
const data = transfer.chunks.join("")
const crc32 = crc.crc32(data)
return crc32.toLowerCase() === transfer.crc32.toLowerCase()
}
@IpcEvent(ApiFileTransferServiceEvents.PreGet)
public async preTransferGet({
filePath,
deviceId,
}: {
filePath: string
deviceId?: DeviceId
}): Promise<ResultObject<PreTransferGet>> {
const device = deviceId
? this.deviceManager.getAPIDeviceById(deviceId)
: this.deviceManager.apiDevice
if (!device) {
return Result.failed(new AppError(GeneralError.NoDevice, ""))
}
const response = await device.request({
endpoint: "PRE_FILE_TRANSFER",
method: "GET",
body: {
filePath,
},
})
if (response.ok) {
const preTransferResponse = PreTransferGetValidator.safeParse(
response.data.body
)
const success = preTransferResponse.success
if (!success) {
return handleError(response.data.status)
}
this.transfers[preTransferResponse.data.transferId] = {
crc32: preTransferResponse.data.crc32,
fileSize: preTransferResponse.data.fileSize,
filePath,
chunks: [],
}
return Result.success(preTransferResponse.data)
}
return handleError(response.error.type)
}
@IpcEvent(ApiFileTransferServiceEvents.Get)
public async transferGet({
deviceId,
transferId,
chunkNumber,
repeats = 0,
maxRepeats = DEFAULT_MAX_REPEATS,
}: {
deviceId?: DeviceId
transferId: number
chunkNumber: number
repeats: number
maxRepeats: number
}): Promise<ResultObject<undefined>> {
const device = deviceId
? this.deviceManager.getAPIDeviceById(deviceId)
: this.deviceManager.apiDevice
if (!device) {
return Result.failed(new AppError(GeneralError.NoDevice, ""))
}
const response = await device.request({
endpoint: "FILE_TRANSFER",
method: "GET",
body: {
transferId,
chunkNumber,
},
})
if (!response.ok) {
if (repeats < maxRepeats) {
return this.transferGet({
deviceId,
transferId,
chunkNumber,
repeats: repeats + 1,
maxRepeats,
})
} else {
return handleError(response.error.type)
}
}
const transferResponse = TransferGetValidator.safeParse(response.data.body)
const success =
transferResponse.success &&
[
FileTransferStatuses.WholeFileTransferred,
FileTransferStatuses.FileChunkTransferred,
].includes(response.data.status as number)
if (!success) {
return handleError(response.data.status)
}
this.transfers[transferId].chunks[chunkNumber - 1] =
transferResponse.data.data
if (
(response.data.status as number) ===
FileTransferStatuses.WholeFileTransferred
) {
if (this.validateChecksum(transferId)) {
return Result.success(undefined)
} else {
return Result.failed(
new AppError(
ApiFileTransferError.CRCMismatch,
ApiFileTransferError[ApiFileTransferError.CRCMismatch]
)
)
}
}
return Result.success(undefined)
}
@IpcEvent(ApiFileTransferServiceEvents.Clear)
public transferClear({ transferId }: { transferId: number }) {
delete this.transfers[transferId]
}
}
const handleError = (responseStatus: AppErrorType) => {
if (ApiFileTransferError[responseStatus as ApiFileTransferError]) {
return Result.failed<
{ transferId?: number; filePath: string },
AppErrorType
>(
new AppError(
responseStatus,
ApiFileTransferError[responseStatus as ApiFileTransferError]
)
)
} else {
return Result.failed(new AppError(GeneralError.IncorrectResponse, ""))
}
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ipcRenderer } from "electron-better-ipc"
import { ApiFileTransferServiceEvents } from "device/models"
import { DeviceId } from "Core/device/constants/device-id"
import { ResultObject } from "Core/core/builder"
export const getFileRequest = (
transferId: number,
chunkNumber: number,
deviceId?: DeviceId
): Promise<ResultObject<undefined>> => {
return ipcRenderer.callMain(ApiFileTransferServiceEvents.Get, {
transferId,
chunkNumber,
deviceId,
})
}

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export * from "./file-transfer.service"
export * from "./start-pre-send-file.request"
export * from "./send-file.request"
export * from "./send-clear.request"
export * from "./start-pre-get-file.request"
export * from "./get-file.request"

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ipcRenderer } from "electron-better-ipc"
import { ApiFileTransferServiceEvents } from "device/models"
export const sendClearRequest = (transferId: number): Promise<void> => {
return ipcRenderer.callMain(ApiFileTransferServiceEvents.Clear, {
transferId,
})
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ipcRenderer } from "electron-better-ipc"
import { ApiFileTransferServiceEvents, TransferSend } from "device/models"
import { DeviceId } from "Core/device/constants/device-id"
import { ResultObject } from "Core/core/builder"
export const sendFileRequest = (
transferId: number,
chunkNumber: number,
deviceId?: DeviceId
): Promise<ResultObject<TransferSend>> => {
return ipcRenderer.callMain(ApiFileTransferServiceEvents.Send, {
transferId,
chunkNumber,
deviceId,
})
}

View File

@@ -0,0 +1,19 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ipcRenderer } from "electron-better-ipc"
import { ApiFileTransferServiceEvents, PreTransferGet } from "device/models"
import { DeviceId } from "Core/device/constants/device-id"
import { ResultObject } from "Core/core/builder"
export const startPreGetFileRequest = (
filePath: string,
deviceId?: DeviceId
): Promise<ResultObject<PreTransferGet>> => {
return ipcRenderer.callMain(ApiFileTransferServiceEvents.PreGet, {
filePath,
deviceId,
})
}

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { ipcRenderer } from "electron-better-ipc"
import { ApiFileTransferServiceEvents } from "device/models"
import { DeviceId } from "Core/device/constants/device-id"
import { ResultObject } from "Core/core/builder"
export const startPreSendFileRequest = (
filePath: string,
targetPath: string,
deviceId?: DeviceId
): Promise<
ResultObject<{
transferId: number
chunksCount: number
}>
> => {
return ipcRenderer.callMain(ApiFileTransferServiceEvents.PreSend, {
filePath,
targetPath,
deviceId,
})
}

View File

@@ -118,7 +118,7 @@ export class APIRestoreService {
}
private parseRestoreResponse(
response: ResultObject<ApiResponse<unknown>, string, unknown>
response: ResultObject<ApiResponse<unknown>, string | number, unknown>
) {
if (!response.ok) {
return Result.failed(response.error)

View File

@@ -0,0 +1,6 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export * from "./service-bridge"

View File

@@ -0,0 +1,55 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { SettingsService } from "Core/settings/services"
import { APIFileTransferService } from "../file-transfer"
import { SystemUtilsModule } from "system-utils/feature"
export class ServiceBridge {
private _fileTransfer?: APIFileTransferService
get fileTransfer(): APIFileTransferService {
if (!this._fileTransfer) {
throw new Error(
"APIFileTransferService reference has not been set in ServiceBridge"
)
}
return this._fileTransfer
}
set fileTransfer(value: APIFileTransferService) {
this._fileTransfer = value
}
private _settingsService?: SettingsService
get settingsService(): SettingsService {
if (!this._settingsService) {
throw new Error(
"SettingsService reference has not been set in ServiceBridge"
)
}
return this._settingsService
}
set settingsService(value: SettingsService) {
this._settingsService = value
}
private _systemUtilsModule?: SystemUtilsModule
get systemUtilsModule(): SystemUtilsModule {
if (!this._systemUtilsModule) {
throw new Error(
"SystemUtilsService reference has not been set in ServiceBridge"
)
}
return this._systemUtilsModule
}
set systemUtilsModule(value: SystemUtilsModule) {
this._systemUtilsModule = value
}
constructor() {}
}

View File

@@ -4,7 +4,8 @@
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true
"strict": true,
"resolveJsonModule": true,
},
"files": [],
"include": [],

View File

@@ -13,4 +13,5 @@ export * from "./lib/api-config"
export * from "./lib/server"
export * from "./lib/general-error"
export * from "./lib/backup"
export * from "./lib/file-transfer"
export * from "./lib/restore"

View File

@@ -12,6 +12,8 @@ const APIEndpoints = {
Outbox: "OUTBOX",
PreBackup: "PRE_BACKUP",
PostBackup: "POST_BACKUP",
PreFileTransfer: "PRE_FILE_TRANSFER",
FileTransfer: "FILE_TRANSFER",
PreRestore: "PRE_RESTORE",
Restore: "RESTORE",
} as const
@@ -36,6 +38,8 @@ const APIRequests = {
OUTBOX: [APIMethods.GET],
PRE_BACKUP: [APIMethods.POST, APIMethods.GET],
POST_BACKUP: [APIMethods.POST],
PRE_FILE_TRANSFER: [APIMethods.POST, APIMethods.GET],
FILE_TRANSFER: [APIMethods.POST, APIMethods.GET],
PRE_RESTORE: [APIMethods.POST],
RESTORE: [APIMethods.POST, APIMethods.GET],
} as const

View File

@@ -0,0 +1,9 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export enum FileTransferStatuses {
WholeFileTransferred = 200,
FileChunkTransferred = 206,
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { z } from "zod"
export const PreTransferSendValidator = z.object({
transferId: z.number(),
chunkSize: z.number().positive(),
})
export const TransferSendValidator = z.object({
transferId: z.number(),
chunkNumber: z.number().positive(),
})
export type TransferSend = z.infer<typeof TransferSendValidator>
export const PreTransferGetValidator = z.object({
transferId: z.number(),
chunkSize: z.number().positive(),
fileSize: z.number().positive(),
crc32: z.string(),
})
export type PreTransferGet = z.infer<typeof PreTransferGetValidator>
export const TransferGetValidator = z.object({
transferId: z.number(),
chunkNumber: z.number().positive(),
data: z.string().min(1),
})

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export * from "./file-transfer"
export * from "./file-transfer-statuses"

View File

@@ -6,8 +6,18 @@
export enum GeneralError {
NoDevice = "no-device",
IncorrectResponse = "incorrect-response",
InternalError = "internal-error",
}
export enum ApiError {
DeviceLocked = 423,
}
export enum ApiFileTransferError {
AccessRestricted = 403,
IncorrectPath = 404,
FileAlreadyExists = 409,
CRCMismatch = 422,
Unknown = 500,
NotEnoughSpace = 507,
}

View File

@@ -7,4 +7,5 @@ export enum APIBackupServiceEvents {
StartPreBackup = "apiservice_backup-start-pre-backup",
CheckPreBackup = "apiservice_backup-check-pre-backup",
PostBackup = "apiservice_backup-post-backup",
GetBackupList = "apiservice_backup-get-backup-list",
}

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export enum ApiFileTransferServiceEvents {
PreGet = "apiservice_file_transfer-pre-transfer-get",
Get = "apiservice_file_transfer-transfer-get",
PreSend = "apiservice_file_transfer-pre-transfer-send",
Send = "apiservice_file_transfer-transfer-send",
Clear = "apiservice_file_transfer-transfer-clear",
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
export enum FileManagerServiceEvents {
SaveFile = "apiservice_file_manager-save-file",
SaveBackupFile = "apiservice_file_manager-save-backup-file",
GetBackupPath = "apiservice_file_manager-get-backup-path",
OpenBackupDirectory = "apiservice_file_manager-open-directory",
ReadDirectory = "apiservice_file_manager-read-directory",
ReadBackupDirectory = "apiservice_file_manager-read-backup-directory",
}

View File

@@ -10,3 +10,5 @@ export * from "./api-config-service-events"
export * from "./api-backup-service-events"
export * from "./api-restore-service-events"
export * from "./server-service-events"
export * from "./api-file-transfer-service-events"
export * from "./file-manager-service-events"

View File

@@ -5,3 +5,4 @@
export * from "./lib/generic-view"
export * from "./lib/recursive-layout"
export * from "./lib/api-device-modals"

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import React, { FunctionComponent } from "react"
import { GenericThemeProvider } from "generic-view/theme"
import { BackupError, ModalBase, ModalCenteredContent } from "generic-view/ui"
import { useDispatch, useSelector } from "react-redux"
import {
cleanBackupProcess,
selectActiveDevice,
selectBackupProcessStatus,
} from "generic-view/store"
export const ApiDeviceModals: FunctionComponent = () => {
return (
<GenericThemeProvider>
<BackupErrorModal />
</GenericThemeProvider>
)
}
const BackupErrorModal: FunctionComponent = () => {
const dispatch = useDispatch()
const activeDevice = useSelector(selectActiveDevice)
const backupStatus = useSelector(selectBackupProcessStatus)
const opened = backupStatus === "FAILED" && !activeDevice
const onClose = () => {
dispatch(cleanBackupProcess())
}
return (
<ModalBase opened={opened} variant={"small"}>
<ModalCenteredContent>
<BackupError closeAction={{ type: "custom", callback: onClose }} />
</ModalCenteredContent>
</ModalBase>
)
}

View File

@@ -13,3 +13,9 @@ export * from "./lib/modals/reducer"
export * from "./lib/modals/actions"
export * from "./lib/backup/reducer"
export * from "./lib/backup/actions"
export * from "./lib/backup/create-backup.action"
export * from "./lib/backup/refresh-backup-list.action"
export * from "./lib/file-transfer/reducer"
export * from "./lib/file-transfer/actions"
export * from "./lib/file-transfer/send-file.action"
export * from "./lib/file-transfer/get-file.action"

View File

@@ -18,5 +18,18 @@ export enum ActionName {
CloseAllModals = "generic-modals/close-all-modals",
ReplaceModal = "generic-modals/replace-modal",
CloseDomainModals = "generic-modals/close-domain-modals",
AddBackupFiles = "generic-backups/add-backup-files",
SetBackupProcess = "generic-backups/set-backup-process",
CleanBackupProcess = "generic-backups/clean-backup-process",
BackupProcessStatus = "generic-backups/backup-process-status",
SetBackupProcessFileStatus = "generic-backups/set-backup-process-file-status",
CreateBackup = "generic-backups/create-backup",
RefreshBackupList = "generic-backups/refresh-backup-list",
FileTransferSend = "generic-file-transfer/send",
PreFileTransferSend = "generic-file-transfer/pre-send",
ChunkFileTransferSend = "generic-file-transfer/chunk-sent",
ClearFileTransferSendError = "generic-file-transfer/clear-send-errors",
PreFileTransferGet = "generic-file-transfer/pre-get",
FileTransferGet = "generic-file-transfer/get",
ChunkFileTransferGet = "generic-file-transfer/chunk-get",
ClearFileTransferGetError = "generic-file-transfer/clear-get-errors",
}

View File

@@ -4,8 +4,24 @@
*/
import { createAction } from "@reduxjs/toolkit"
import { Backup } from "./reducer"
import {
BackupProcess,
BackupProcessFileStatus,
BackupProcessStatus,
} from "./reducer"
import { ActionName } from "../action-names"
export const setBackupFiles = createAction<Backup[]>(ActionName.AddBackupFiles)
export const setBackupProcess = createAction<BackupProcess>(
ActionName.SetBackupProcess
)
export const cleanBackupProcess = createAction(ActionName.CleanBackupProcess)
export const setBackupProcessFileStatus = createAction<{
feature: string
status: BackupProcessFileStatus
}>(ActionName.SetBackupProcessFileStatus)
export const setBackupProcessStatus = createAction<BackupProcessStatus>(
ActionName.BackupProcessStatus
)

View File

@@ -0,0 +1,166 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createAsyncThunk } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import {
checkPreBackupRequest,
postBackupRequest,
saveBackupFileRequest,
sendClearRequest,
startPreBackupRequest,
} from "device/feature"
import { ActionName } from "../action-names"
import { getFile } from "../file-transfer/get-file.action"
import {
setBackupProcess,
setBackupProcessFileStatus,
setBackupProcessStatus,
} from "./actions"
import { refreshBackupList } from "./refresh-backup-list.action"
export const createBackup = createAsyncThunk<
undefined,
{
features: string[]
password?: string
},
{ state: ReduxRootState }
>(
ActionName.CreateBackup,
async (
{ features, password },
{ getState, dispatch, rejectWithValue, signal }
) => {
let aborted = false
const abortListener = async () => {
signal.removeEventListener("abort", abortListener)
aborted = true
abortFileRequest?.()
await clearTransfers?.()
if (backupId && deviceId) {
await postBackupRequest(backupId, deviceId)
}
}
signal.addEventListener("abort", abortListener)
if (aborted) {
return rejectWithValue(undefined)
}
dispatch(
setBackupProcess({
status: "PRE_BACKUP",
featureFilesTransfer: features.reduce((acc, item) => {
return { ...acc, [item]: { done: false } }
}, {}),
})
)
const deviceId = getState().genericViews.activeDevice
if (deviceId === undefined || aborted) {
return rejectWithValue(undefined)
}
const startPreBackupResponse = await startPreBackupRequest(
features,
deviceId
)
if (!startPreBackupResponse.ok || aborted) {
console.log(startPreBackupResponse.error)
return rejectWithValue(undefined)
}
const backupId = startPreBackupResponse.data.backupId
let backupFeaturesFiles = startPreBackupResponse.data.features
let abortFileRequest: VoidFunction
const featureToTransferId: Record<string, number> = {}
const clearTransfers = () => {
return Promise.all(
Object.values(featureToTransferId).map(async (transferId) => {
await sendClearRequest(transferId)
})
)
}
while (backupFeaturesFiles === undefined) {
if (aborted) {
return rejectWithValue(undefined)
}
const checkPreBackupResponse = await checkPreBackupRequest(
backupId,
features,
deviceId
)
if (!checkPreBackupResponse.ok) {
console.log(checkPreBackupResponse.error)
return rejectWithValue(undefined)
}
backupFeaturesFiles = checkPreBackupResponse.data.features
}
dispatch(setBackupProcessStatus("FILES_TRANSFER"))
for (let i = 0; i < features.length; ++i) {
if (aborted) {
return rejectWithValue(undefined)
}
const feature = features[i]
dispatch(setBackupProcessFileStatus({ feature, status: "IN_PROGRESS" }))
const filePromise = dispatch(
getFile({
deviceId,
filePath: backupFeaturesFiles[feature],
targetPath: "",
})
)
abortFileRequest = filePromise.abort
const file = await filePromise
if (
file.meta.requestStatus === "fulfilled" &&
file.payload &&
"transferId" in file.payload
) {
featureToTransferId[feature] = file.payload.transferId
dispatch(setBackupProcessFileStatus({ feature, status: "DONE" }))
} else if (!aborted) {
console.log("Error while downloading file")
await clearTransfers()
await postBackupRequest(backupId, deviceId)
return rejectWithValue(undefined)
}
}
if (aborted) {
return rejectWithValue(undefined)
}
dispatch(setBackupProcessStatus("SAVE_FILE"))
const saveBackupFileResponse = await saveBackupFileRequest(
featureToTransferId,
deviceId,
password
)
if (!saveBackupFileResponse.ok) {
console.log("Error while saving file")
await clearTransfers()
await postBackupRequest(backupId, deviceId)
return rejectWithValue(undefined)
}
dispatch(refreshBackupList())
if (!aborted) {
await postBackupRequest(backupId, deviceId)
}
return undefined
}
)

View File

@@ -4,43 +4,91 @@
*/
import { createReducer } from "@reduxjs/toolkit"
import { setBackupFiles } from "./actions"
import {
cleanBackupProcess,
setBackupProcess,
setBackupProcessFileStatus,
setBackupProcessStatus,
} from "./actions"
import { createBackup } from "./create-backup.action"
import { refreshBackupList } from "./refresh-backup-list.action"
export interface Backup {
fileName: string
date: Date
features: string[]
device: {
serialNumber: string
vendorId: string
productId: string
osVersion: string
}
serialNumber: string
}
export type BackupProcessStatus =
| "PRE_BACKUP"
| "FILES_TRANSFER"
| "SAVE_FILE"
| "DONE"
| "FAILED"
export type BackupProcessFileStatus = "PENDING" | "IN_PROGRESS" | "DONE"
export interface BackupProcess {
status: "PRE_BACKUP" | "FILES_TRANSFER" | "SAVE_FILE" | "DONE" | "FAILED"
featureFilesTransfer: Record<
string,
{ transferId?: number; status: BackupProcessFileStatus }
>
}
interface BackupState {
files: Backup[]
lastBackupRefresh: number
backups: Backup[]
backupProcess?: BackupProcess
}
const initialState: BackupState = {
// Demo data
files: [
{
fileName: "backup1.json",
date: new Date(),
features: ["calls", "messages"],
device: {
serialNumber: "0123456789ABCDEF",
vendorId: "0e8d",
productId: "2006",
osVersion: "12",
},
},
],
lastBackupRefresh: 0,
backups: [],
}
export const genericBackupsReducer = createReducer(initialState, (builder) => {
builder.addCase(setBackupFiles, (state, action) => {
state.files = action.payload
builder.addCase(cleanBackupProcess, (state, action) => {
delete state.backupProcess
})
builder.addCase(setBackupProcess, (state, action) => {
state.backupProcess = action.payload
})
builder.addCase(setBackupProcessFileStatus, (state, action) => {
if (state.backupProcess) {
state.backupProcess.featureFilesTransfer[action.payload.feature].status =
action.payload.status
}
})
builder.addCase(setBackupProcessStatus, (state, action) => {
if (state.backupProcess) {
state.backupProcess.status = action.payload
}
})
builder.addCase(createBackup.rejected, (state, action) => {
if (state.backupProcess) {
state.backupProcess.status = "FAILED"
} else {
state.backupProcess = {
status: "FAILED",
featureFilesTransfer: {},
}
}
})
builder.addCase(createBackup.fulfilled, (state, action) => {
if (state.backupProcess) {
state.backupProcess.status = "DONE"
} else {
state.backupProcess = {
status: "DONE",
featureFilesTransfer: {},
}
}
})
builder.addCase(refreshBackupList.fulfilled, (state, action) => {
if (state.lastBackupRefresh < action.payload.refreshTimestamp) {
state.lastBackupRefresh = action.payload.refreshTimestamp
state.backups = action.payload.backups
}
})
})

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createAsyncThunk } from "@reduxjs/toolkit"
import { DeviceId } from "Core/device/constants/device-id"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import { readBackupDirectoryRequest } from "device/feature"
import { ActionName } from "../action-names"
import { Backup } from "./reducer"
export const refreshBackupList = createAsyncThunk<
{ refreshTimestamp: number; backups: Backup[]; deviceId: DeviceId },
undefined,
{ state: ReduxRootState }
>(
ActionName.RefreshBackupList,
async (_, { getState, dispatch, rejectWithValue }) => {
const refreshTimestamp = new Date().getTime()
const deviceId = getState().genericViews.activeDevice
if (!deviceId) {
return rejectWithValue(undefined)
}
const backupsList = await readBackupDirectoryRequest(deviceId)
if (!backupsList.ok) {
return rejectWithValue(undefined)
}
const backups =
(backupsList.data
.map((item) => {
const isFormatValid = /^\d+[_][a-zA-Z0-9]+[.]mcbackup$/i.test(item)
if (!isFormatValid) {
return null
}
const [fileName] = item.split(".")
const [timestamp, serialNumber] = fileName.split("_")
const result: Backup = {
date: new Date(Number(timestamp)),
fileName: item,
serialNumber,
}
return result
})
.filter(Boolean) as Backup[]) ?? []
return {
refreshTimestamp,
backups,
deviceId,
}
}
)

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createAction } from "@reduxjs/toolkit"
import { ActionName } from "../action-names"
import { FileProgress } from "./reducer"
import { SendFileErrorPayload } from "./send-file.action"
import { GetFileErrorPayload } from "Libs/generic-view/store/src"
export const fileTransferSendPrepared = createAction<
Pick<FileProgress, "chunksCount" | "transferId">
>(ActionName.PreFileTransferSend)
export const fileTransferChunkSent = createAction<
Pick<FileProgress, "chunksTransferred" | "transferId">
>(ActionName.ChunkFileTransferSend)
export const fileTransferGetPrepared = createAction<
Pick<FileProgress, "chunksCount" | "transferId" | "filePath">
>(ActionName.PreFileTransferGet)
export const fileTransferChunkGet = createAction<
Pick<FileProgress, "chunksTransferred" | "transferId">
>(ActionName.ChunkFileTransferGet)
export const clearSendErrors = createAction<SendFileErrorPayload>(
ActionName.ClearFileTransferSendError
)
export const clearGetErrors = createAction<GetFileErrorPayload>(
ActionName.ClearFileTransferGetError
)

View File

@@ -0,0 +1,129 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createAsyncThunk } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import {
getFileRequest,
saveFileRequest,
sendClearRequest,
startPreGetFileRequest,
} from "device/feature"
import { DeviceId } from "Core/device/constants/device-id"
import { ActionName } from "../action-names"
import { fileTransferChunkGet, fileTransferGetPrepared } from "./actions"
import { AppError, AppErrorType } from "Core/core/errors"
import { GeneralError, PreTransferGet } from "device/models"
import { Result } from "Core/core/builder"
export type GetFileErrorPayload = {
transferId?: number
filePath: string
}
interface GetFileError {
deviceId: DeviceId
error: AppError<AppErrorType, GetFileErrorPayload>
}
export const getFile = createAsyncThunk<
{ transferId: number },
{
deviceId: DeviceId
filePath: string
targetPath: string
preTransfer?: PreTransferGet
},
{ state: ReduxRootState; rejectValue: GetFileError | undefined }
>(
ActionName.FileTransferGet,
async (
{ deviceId, filePath, targetPath, preTransfer },
{ rejectWithValue, dispatch, signal }
) => {
let aborted = false
const abortListener = async () => {
signal.removeEventListener("abort", abortListener)
aborted = true
const transferId = preTransferResponse?.ok
? preTransferResponse.data.transferId
: preTransferResponse?.error.payload
if (transferId) {
await sendClearRequest(transferId)
}
}
signal.addEventListener("abort", abortListener)
if (aborted) {
return rejectWithValue(undefined)
}
const preTransferResponse = preTransfer
? Result.success(preTransfer)
: await startPreGetFileRequest(filePath, deviceId)
if (preTransferResponse.ok && !aborted) {
const { transferId, chunkSize, fileSize } = preTransferResponse.data
const chunksCount = Math.ceil(fileSize / chunkSize)
dispatch(
fileTransferGetPrepared({
transferId,
chunksCount,
filePath,
})
)
for (let chunkNumber = 1; chunkNumber <= chunksCount; chunkNumber++) {
if (aborted) {
return rejectWithValue(undefined)
}
const { ok, error } = await getFileRequest(transferId, chunkNumber)
if (!ok) {
await sendClearRequest(transferId)
return rejectWithValue({
deviceId,
error: {
...error,
payload: {
transferId,
filePath,
},
},
})
}
dispatch(
fileTransferChunkGet({
transferId,
chunksTransferred: chunkNumber,
})
)
}
if (aborted) {
return rejectWithValue(undefined)
}
if (targetPath) {
await saveFileRequest(targetPath, transferId)
await sendClearRequest(transferId)
}
return { transferId }
} else {
return rejectWithValue({
deviceId,
error: {
name: GeneralError.IncorrectResponse,
type: GeneralError.IncorrectResponse,
message: "Incorrect response",
payload: {
transferId: preTransferResponse.error?.payload,
filePath,
},
},
})
}
}
)

View File

@@ -0,0 +1,129 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createReducer } from "@reduxjs/toolkit"
import { AppErrorType } from "Core/core/errors"
import {
clearGetErrors,
clearSendErrors,
fileTransferChunkGet,
fileTransferChunkSent,
fileTransferGetPrepared,
fileTransferSendPrepared,
} from "./actions"
import { sendFile } from "./send-file.action"
import { getFile } from "./get-file.action"
export interface FileTransferError {
code?: AppErrorType
message?: string
filePath?: string
transferId?: number
}
export interface FileProgress {
transferId: number
chunksCount: number
chunksTransferred: number
filePath?: string
}
interface FileTransferState {
sendingFilesProgress: {
[transferId: number]: FileProgress
}
sendingErrors?: FileTransferError[]
receivingFilesProgress: {
[transferId: number]: FileProgress
}
receivingErrors?: FileTransferError[]
}
const initialState: FileTransferState = {
sendingFilesProgress: {},
sendingErrors: [],
receivingFilesProgress: {},
receivingErrors: [],
}
export const genericFileTransferReducer = createReducer(
initialState,
(builder) => {
builder.addCase(fileTransferSendPrepared, (state, action) => {
state.sendingFilesProgress[action.payload.transferId] = {
transferId: action.payload.transferId,
chunksCount: action.payload.chunksCount,
chunksTransferred: 0,
}
})
builder.addCase(fileTransferChunkSent, (state, action) => {
state.sendingFilesProgress[action.payload.transferId].chunksTransferred =
action.payload.chunksTransferred
})
builder.addCase(sendFile.fulfilled, (state, action) => {
delete state.sendingFilesProgress[action.payload.transferId]
})
builder.addCase(sendFile.rejected, (state, action) => {
const { transferId, filePath } = action.payload?.error.payload || {}
if (transferId) {
delete state.sendingFilesProgress[transferId]
}
state.sendingErrors?.push({
code: action.payload?.error.type,
message: action.payload?.error.message,
transferId,
filePath,
})
})
builder.addCase(clearSendErrors, (state, action) => {
state.sendingErrors = state.sendingErrors?.filter(
(error) => error.transferId !== action.payload.transferId
)
})
builder.addCase(fileTransferGetPrepared, (state, action) => {
state.receivingFilesProgress[action.payload.transferId] = {
transferId: action.payload.transferId,
chunksCount: action.payload.chunksCount,
chunksTransferred: 0,
filePath: action.payload.filePath,
}
})
builder.addCase(fileTransferChunkGet, (state, action) => {
state.receivingFilesProgress[
action.payload.transferId
].chunksTransferred = action.payload.chunksTransferred
})
builder.addCase(getFile.fulfilled, (state, action) => {
delete state.receivingFilesProgress[action.payload.transferId]
})
builder.addCase(getFile.rejected, (state, action) => {
if (action.meta.aborted) {
const transfer = Object.entries(state.receivingFilesProgress).find(
([, item]) => item.filePath === action.meta.arg.filePath
)
if (transfer) {
const transferId = Number(transfer[0])
delete state.receivingFilesProgress[transferId]
}
} else {
const { transferId, filePath } = action.payload?.error.payload || {}
if (transferId) {
delete state.receivingFilesProgress[transferId]
}
state.receivingErrors?.push({
code: action.payload?.error.type,
message: action.payload?.error.message,
transferId,
filePath: action.payload?.error.payload?.filePath,
})
}
})
builder.addCase(clearGetErrors, (state, action) => {
state.receivingErrors = state.receivingErrors?.filter(
(error) => error.transferId !== action.payload.transferId
)
})
}
)

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createAsyncThunk } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import {
sendClearRequest,
sendFileRequest,
startPreSendFileRequest,
} from "device/feature"
import { DeviceId } from "Core/device/constants/device-id"
import { ActionName } from "../action-names"
import { fileTransferChunkSent, fileTransferSendPrepared } from "./actions"
import { AppError, AppErrorType } from "Core/core/errors"
import { GeneralError } from "device/models"
export type SendFileErrorPayload = {
transferId?: number
filePath: string
}
interface SendFileError {
deviceId: DeviceId
error: AppError<AppErrorType, SendFileErrorPayload>
}
export const sendFile = createAsyncThunk<
{ transferId: number },
{ deviceId: DeviceId; filePath: string; targetPath: string },
{ state: ReduxRootState; rejectValue: SendFileError }
>(
ActionName.FileTransferSend,
async ({ deviceId, filePath, targetPath }, { rejectWithValue, dispatch }) => {
const preTransferResponse = await startPreSendFileRequest(
filePath,
targetPath,
deviceId
)
if (preTransferResponse.ok) {
const { transferId, chunksCount } = preTransferResponse.data
dispatch(
fileTransferSendPrepared({
transferId,
chunksCount,
})
)
for (let chunkNumber = 1; chunkNumber <= chunksCount; chunkNumber++) {
// TODO: consider using signal to abort
const { ok, error } = await sendFileRequest(transferId, chunkNumber)
if (!ok) {
await sendClearRequest(transferId)
return rejectWithValue({
deviceId,
error: {
...error,
payload: {
transferId,
filePath,
},
},
})
}
dispatch(
fileTransferChunkSent({
transferId,
chunksTransferred: chunkNumber,
})
)
}
await sendClearRequest(transferId)
return { transferId }
} else {
return rejectWithValue({
deviceId,
error: {
name: GeneralError.IncorrectResponse,
type: GeneralError.IncorrectResponse,
message: "Incorrect response",
payload: {
transferId: preTransferResponse.error.payload,
filePath,
},
},
})
}
}
)

View File

@@ -7,3 +7,5 @@ export * from "./use-api-serial-port-listeners"
export * from "./use-outbox"
export * from "./use-screen-title"
export * from "./use-locked-device-handler"
export * from "./use-backup-list"
export * from "./use-app-events-listeners"

View File

@@ -5,15 +5,19 @@
import { Device } from "Core/device-manager/reducers/device-manager.interface"
import { useEffect } from "react"
import { useDispatch } from "react-redux"
import { useDispatch, useSelector } from "react-redux"
import { answerMain, DeviceManagerMainEvent } from "shared/utils"
import { detachDevice } from "../views/actions"
import { getAPIConfig } from "../get-api-config"
import { Dispatch } from "Core/__deprecated__/renderer/store"
import { DeviceType } from "Core/device"
import { setBackupProcessStatus } from "../backup/actions"
import { closeAllModals } from "../modals/actions"
import { selectBackupProcessStatus } from "../selectors"
export const useAPISerialPortListeners = () => {
const dispatch = useDispatch<Dispatch>()
const backupProcess = useSelector(selectBackupProcessStatus)
useEffect(() => {
const unregisterFailListener = answerMain<Device>(
@@ -39,12 +43,16 @@ export const useAPISerialPortListeners = () => {
)
const unregisterDetachedListener = answerMain<Device>(
DeviceManagerMainEvent.DeviceDetached,
(properties) => {
async (properties) => {
const { id, deviceType } = properties
if (deviceType !== DeviceType.APIDevice) {
return
}
dispatch(detachDevice({ deviceId: id }))
dispatch(closeAllModals())
if (backupProcess) {
dispatch(setBackupProcessStatus("FAILED"))
}
}
)
return () => {
@@ -52,5 +60,5 @@ export const useAPISerialPortListeners = () => {
unregisterConnectListener()
unregisterFailListener()
}
}, [dispatch])
}, [backupProcess, dispatch])
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { useEffect } from "react"
import { useDispatch } from "react-redux"
import { answerMain, AppEvents } from "shared/utils"
import { Dispatch } from "Core/__deprecated__/renderer/store"
import { refreshBackupList } from "../backup/refresh-backup-list.action"
export const useAppEventsListeners = () => {
const dispatch = useDispatch<Dispatch>()
useEffect(() => {
const unregisterWindowFocusedListener = answerMain(
AppEvents.WindowFocused,
() => {
dispatch(refreshBackupList())
}
)
return () => {
unregisterWindowFocusedListener()
}
}, [dispatch])
}

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { Dispatch } from "Core/__deprecated__/renderer/store"
import { useEffect } from "react"
import { useDispatch } from "react-redux"
import { refreshBackupList } from "../backup/refresh-backup-list.action"
export const useBackupList = () => {
const dispatch = useDispatch<Dispatch>()
useEffect(() => {
const interval = setInterval(() => {
dispatch(refreshBackupList())
}, 5000)
return () => {
clearInterval(interval)
}
}, [dispatch])
return undefined
}

View File

@@ -31,3 +31,8 @@ export const useOutbox = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeDevice, lastRefreshTimestamp])
}
export const OutboxWrapper = () => {
useOutbox()
return null
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createSelector } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import { selectActiveDevice } from "./active-device"
export const selectActiveDeviceConfiguration = createSelector(
[
selectActiveDevice,
(state: ReduxRootState) => state.genericViews.devicesConfiguration,
],
(activeDevice, devicesConfiguration) => {
if (activeDevice) {
return devicesConfiguration[activeDevice]
}
return undefined
}
)

View File

@@ -4,18 +4,11 @@
*/
import { createSelector } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import { selectActiveDevice } from "./active-device"
import { selectActiveDeviceConfiguration } from "./active-device-configuration"
export const selectActiveDeviceFeatures = createSelector(
[
selectActiveDevice,
(state: ReduxRootState) => state.genericViews.devicesConfiguration,
],
(activeDevice, devicesConfiguration) => {
if (activeDevice) {
return devicesConfiguration[activeDevice].features
}
return undefined
selectActiveDeviceConfiguration,
(activeDeviceConfiguration) => {
return activeDeviceConfiguration?.features
}
)

View File

@@ -0,0 +1,18 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createSelector } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
import { selectActiveDeviceConfiguration } from "./active-device-configuration"
export const selectBackupLocation = createSelector(
selectActiveDeviceConfiguration,
(state: ReduxRootState) => state.settings.osBackupLocation,
(deviceConfiguration, location) => {
const { vendorId, productId } = deviceConfiguration?.apiConfig || {}
const deviceDirectory = `${vendorId}_${productId}`
return vendorId && productId ? `${location}/${deviceDirectory}` : location
}
)

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createSelector } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
export const selectBackupProcessStatus = createSelector(
(state: ReduxRootState) => state.genericBackups.backupProcess?.status,
(status) => status
)

View File

@@ -0,0 +1,40 @@
/**
* Copyright (c) Mudita sp. z o.o. All rights reserved.
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { createSelector } from "@reduxjs/toolkit"
import { ReduxRootState } from "Core/__deprecated__/renderer/store"
export const backupProgress = createSelector(
[(state: ReduxRootState) => state.genericBackups.backupProcess],
(backupProcess) => {
if (!backupProcess) {
return { progress: 0 }
}
if (backupProcess.status === "DONE") {
return { progress: 100 }
}
const features = Object.values(backupProcess.featureFilesTransfer)
const downloadedFilesCount = features.filter(
(item) => item.status === "DONE"
).length
if (features.length <= downloadedFilesCount) {
return { progress: 90 }
}
const [featureInProgress] =
Object.entries(backupProcess.featureFilesTransfer).find(
([, item]) => item.status === "IN_PROGRESS"
) ?? []
return {
progress: Math.floor(10 + (downloadedFilesCount / features.length) * 80),
featureInProgress,
}
}
)

View File

@@ -9,10 +9,10 @@ import { ReduxRootState } from "Core/__deprecated__/renderer/store"
export const selectDeviceBackups = createSelector(
selectActiveDevice,
(state: ReduxRootState) => state.genericBackups.files,
(state: ReduxRootState) => state.genericBackups.backups,
(activeDevice, backups) => {
return backups
.filter((backup) => backup.device.serialNumber === activeDevice)
.filter((backup) => backup.serialNumber === activeDevice)
.sort((a, b) => b.date.getTime() - a.date.getTime())
}
)

View File

@@ -12,3 +12,6 @@ export * from "./component"
export * from "./view"
export * from "./api-error"
export * from "./device-backups"
export * from "./backup-progress"
export * from "./backup-process-status"
export * from "./backup-location"

View File

@@ -50,6 +50,7 @@ interface GenericState {
lastResponse: unknown
lastRefresh?: number
activeDevice?: DeviceId
pendingDevice?: DeviceId
devicesConfiguration: Record<string, DeviceConfiguration>
apiErrors: Record<ApiError, boolean>
}
@@ -152,12 +153,17 @@ export const genericViewsReducer = createReducer(initialState, (builder) => {
: {}),
}
}
if (state.activeDevice === undefined && state.pendingDevice === deviceId) {
state.activeDevice = deviceId
state.pendingDevice = undefined
}
})
builder.addCase(activateDevice, (state, action) => {
const { deviceId } = action.payload
state.activeDevice = state.devicesConfiguration?.[deviceId]?.apiConfig
? deviceId
: undefined
state.pendingDevice = deviceId
})
builder.addCase(detachDevice, (state, action) => {
const { deviceId } = action.payload
@@ -167,6 +173,9 @@ export const genericViewsReducer = createReducer(initialState, (builder) => {
if (state.activeDevice === deviceId) {
state.activeDevice = undefined
}
if (state.pendingDevice === deviceId) {
state.pendingDevice = undefined
}
})
builder.addCase(getOutboxData.fulfilled, (state, action) => {
const { deviceId, timestamp } = action.payload

View File

@@ -13,4 +13,5 @@ export const color = {
grey4: "#D2D6DB",
grey5: "#F4F5F6",
grey6: "#FBFBFB",
red1: "#E96A6A",
} as const

View File

@@ -17,4 +17,6 @@ export const fontSize = {
buttonLink: "1.4rem",
buttonText: "1.2rem",
detailText: "1.2rem",
labelText: "1.2rem",
modalTitle: "2rem",
} as const

View File

@@ -58,5 +58,23 @@ const GlobalStyle = createGlobalStyle<{ theme: Theme }>`
box-shadow: 0 2rem 10rem 0 ${({ theme }) => theme.color.black + "26"};
display: flex;
flex-direction: column;
position: relative;
.modal-close-button:nth-child(2) {
display: none;
}
}
*::-webkit-scrollbar {
width: 0.5rem;
}
*::-webkit-scrollbar-track {
background-color: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: ${({ theme }) => theme.color.grey2};
border-radius: 0.25rem;
}
`

View File

@@ -17,4 +17,6 @@ export const lineHeight = {
buttonLink: "2.2rem",
buttonText: "2rem",
detailText: "2rem",
labelText: "2rem",
modalTitle: "3.2rem",
} as const

View File

@@ -13,7 +13,9 @@ import { buttons } from "./lib/buttons/buttons"
export { default as Icon } from "./lib/icon/icon"
export * from "./lib/api-connection-demo"
export * from "./lib/interactive/modal/modal-base"
export * from "./lib/interactive/modal/modal-helpers"
export * from "./lib/shared/shared"
export * from "./lib/predefined/backup/backup-error"
const apiComponents = {
...predefinedComponents,
@@ -27,4 +29,3 @@ const apiComponents = {
export default apiComponents
export type APIComponents = typeof apiComponents

View File

@@ -11,11 +11,12 @@ import { DefaultButton } from "../../shared/button"
interface Props extends HTMLAttributes<HTMLButtonElement> {
action: ButtonAction
viewKey?: string
disabled?: boolean
}
export const ButtonBase: FunctionComponent<Props> = ({ action, ...props }) => {
const callButtonAction = useButtonAction(props.viewKey as string)
const callAction = () => callButtonAction(action)
return <DefaultButton {...props} onClick={callAction} />
return <DefaultButton {...props} onClick={callAction} type={"button"} />
}

View File

@@ -7,20 +7,31 @@ import {
closeAllModals,
closeDomainModals,
closeModal,
createBackup,
openModal,
replaceModal,
selectActiveDevice,
sendFile,
useScreenTitle,
} from "generic-view/store"
import { ButtonAction } from "generic-view/utils"
import { useDispatch } from "react-redux"
import { useDispatch, useSelector } from "react-redux"
import { useHistory } from "react-router"
import { Dispatch, ReduxRootState } from "Core/__deprecated__/renderer/store"
import { getPaths } from "shared/app-state"
import { PayloadAction } from "@reduxjs/toolkit"
import { ResultObject } from "Core/core/builder"
import { platform } from "Core/__deprecated__/renderer/utils/platform"
export const useButtonAction = (viewKey: string) => {
const dispatch = useDispatch()
const dispatch = useDispatch<Dispatch>()
const navigate = useHistory()
const currentViewName = useScreenTitle(viewKey)
const restore = useButtonRestoreAction()
const backup = useButtonBackupAction()
return (action: ButtonAction) => {
return (action?: ButtonAction) => {
if (!action) return
switch (action.type) {
case "open-modal":
dispatch(
@@ -57,6 +68,60 @@ export const useButtonAction = (viewKey: string) => {
},
})
break
case "restore-data":
void restore()
break
case "backup-data":
void backup()
break
case "custom":
action.callback()
break
default:
break
}
}
}
const useButtonRestoreAction = () => {
const dispatch = useDispatch<Dispatch>()
const osBackupLocation = useSelector(
(state: ReduxRootState) => state.settings.osBackupLocation
)
const deviceId = useSelector(selectActiveDevice)
return async () => {
const { payload: getPathsPayload } = (await dispatch(
getPaths({
properties: ["openFile"],
defaultPath: osBackupLocation,
})
)) as PayloadAction<ResultObject<string[] | undefined>>
const location = getPathsPayload.ok && (getPathsPayload.data as string[])[0]
if (location && deviceId) {
const [fileName] = location
.split(platform.windows() ? "\\" : "/")
.reverse()
dispatch(
sendFile({
deviceId: deviceId,
filePath: location,
targetPath: `/storage/emulated/0/Documents/${fileName}`,
})
)
}
}
}
const useButtonBackupAction = () => {
const dispatch = useDispatch<Dispatch>()
const deviceId = useSelector(selectActiveDevice)
return async () => {
if (deviceId) {
await dispatch(
createBackup({ features: ["CONTACTS_LIST", "MESSAGES", "CALL_LOG"] })
)
}
}
}

View File

@@ -10,10 +10,11 @@ import { ButtonBase } from "./button-base/button-base"
import Icon from "../icon/icon"
import { withConfig } from "../utils/with-config"
interface Config {
export interface Config {
text: string
icon?: IconType
action: ButtonAction
disabled?: boolean
}
export const ButtonPrimary: APIFC<undefined, Config> = ({
@@ -22,7 +23,7 @@ export const ButtonPrimary: APIFC<undefined, Config> = ({
...props
}) => {
return (
<Button {...props} action={config!.action}>
<Button {...props} disabled={config?.disabled} action={config!.action}>
{config?.icon && <Icon data={{ type: config.icon }} />}
<span>{config?.text}</span>
</Button>

View File

@@ -5,15 +5,9 @@
import React from "react"
import styled from "styled-components"
import { APIFC, ButtonAction, IconType } from "generic-view/utils"
import { APIFC } from "generic-view/utils"
import { withConfig } from "../utils/with-config"
import { ButtonPrimary } from "./button-primary"
interface Config {
text: string
icon?: IconType
action: ButtonAction
}
import { ButtonPrimary, Config } from "./button-primary"
export const ButtonSecondary: APIFC<undefined, Config> = (props) => {
return <Button {...props} />

View File

@@ -25,12 +25,8 @@ export const ButtonText: APIFC<undefined, Config> = ({
...props
}) => {
return (
<Button
{...props}
action={config!.action}
$modifiers={config?.modifiers}
>
{config?.icon && <Icon data={{ type: config.icon }} />}
<Button {...props} action={config!.action} $modifiers={config?.modifiers}>
{config?.icon && <Icon className={"icon"} data={{ type: config.icon }} />}
<span>{config?.text}</span>
</Button>
)
@@ -39,30 +35,39 @@ export const ButtonText: APIFC<undefined, Config> = ({
export default withConfig(ButtonText)
const Button = styled(ButtonBase)<{ $modifiers?: ButtonModifiers[] }>`
color: ${({ theme }) => theme.color.grey1};
&:hover {
color: ${({ theme }) => theme.color.black};
}
span {
font-size: ${({ theme }) => theme.fontSize.buttonLink};
line-height: ${({ theme }) => theme.lineHeight.buttonLink};
color: ${({ theme }) => theme.color.grey1};
letter-spacing: 0.05em;
text-transform: unset;
transition: color 0.15s ease-in-out;
}
&:hover span {
color: ${({ theme }) => theme.color.black};
}
${({ $modifiers }) => $modifiers?.includes("link") && buttonLinkModifier};
${({ $modifiers }) =>
$modifiers?.includes("uppercase") && buttonUpperCaseModifier};
${({ $modifiers }) =>
$modifiers?.includes("hover-underline") && buttonHoverUnderlineModifier};
.icon {
width: 2.2rem;
height: 2.2rem;
svg * {
fill: currentColor;
}
}
`
const buttonLinkModifier = css`
span {
color: ${({ theme }) => theme.color.blue2};
}
&:hover span {
color: ${({ theme }) => theme.color.blue2};
&:hover {
color: ${({ theme }) => theme.color.blue2};
}
`
@@ -73,6 +78,7 @@ const buttonUpperCaseModifier = css`
font-size: ${({ theme }) => theme.fontSize.buttonText};
line-height: ${({ theme }) => theme.lineHeight.buttonText};
letter-spacing: 0.1em;
margin-top: 0.1rem;
}
`

View File

@@ -29,6 +29,15 @@ import CloseIcon from "Core/__deprecated__/renderer/svg/close.svg"
import Device from "Core/__deprecated__/renderer/svg/device.svg"
import Mudita from "Core/__deprecated__/renderer/svg/mudita.svg"
import Spinner from "Core/__deprecated__/renderer/svg/spinner.svg"
import Backup from "./svg/backup.svg"
import Settings from "./svg/settings.svg"
import PasswordShow from "./svg/password-show.svg"
import PasswordHide from "./svg/password-hide.svg"
import Success from "./svg/confirm.svg"
import Failure from "./svg/failed.svg"
import Folder from "./svg/folder.svg"
import { IconType } from "generic-view/utils"
const typeToIcon: Record<IconType, typeof BatteryHigh> = {
@@ -56,6 +65,13 @@ const typeToIcon: Record<IconType, typeof BatteryHigh> = {
[IconType.Device]: Device,
[IconType.Mudita]: Mudita,
[IconType.Spinner]: Spinner,
[IconType.Backup]: Backup,
[IconType.Settings]: Settings,
[IconType.PasswordShow]: PasswordShow,
[IconType.PasswordHide]: PasswordHide,
[IconType.Success]: Success,
[IconType.Failure]: Failure,
[IconType.Folder]: Folder
}
export const getIcon = (

View File

@@ -3,18 +3,17 @@
* For licensing, see https://github.com/mudita/mudita-center/blob/master/LICENSE.md
*/
import { IconType } from "generic-view/utils"
import { APIFC } from "generic-view/utils"
import { APIFC, IconType } from "generic-view/utils"
import React from "react"
import styled from "styled-components"
import { getIcon } from "./get-icon.helper"
interface IconProps {
interface Data {
type: IconType
}
const StyledIcon = styled.div`
color: pink;
color: inherit;
width: 3.2rem;
height: 3.2rem;
& > * {
@@ -23,7 +22,7 @@ const StyledIcon = styled.div`
}
`
const Icon: APIFC<IconProps> = ({ className, data, ...rest }) => {
const Icon: APIFC<Data> = ({ data, ...rest }) => {
if (!data) {
return null
}
@@ -31,11 +30,7 @@ const Icon: APIFC<IconProps> = ({ className, data, ...rest }) => {
const SVGToDisplay = getIcon(data.type)
return (
<StyledIcon
className={className}
data-testid={`icon-${data.type}`}
{...rest}
>
<StyledIcon data-testid={`icon-${data.type}`} {...rest}>
<SVGToDisplay />
</StyledIcon>
)

View File

@@ -0,0 +1,8 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_17419_28735" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="6" y="6" width="36" height="36">
<path d="M38 6H10C7.79086 6 6 7.79086 6 10V38C6 40.2091 7.79086 42 10 42H38C40.2091 42 42 40.2091 42 38V10C42 7.79086 40.2091 6 38 6Z" fill="white"/>
</mask>
<g mask="url(#mask0_17419_28735)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.0002 13C13.0002 12.4477 13.4479 12 14.0002 12H21.7425C22.0904 12 22.4133 12.1808 22.5951 12.4774L23.5284 14H32.6464C34.3033 14 35.6464 15.3431 35.6464 17V17.131C36.9849 17.5409 37.9108 18.8597 37.7473 20.3313L36.1917 34.3313C36.0229 35.8506 34.7387 37 33.2101 37H14.7902C13.2616 37 11.9774 35.8506 11.8086 34.3313L10.1297 19.2209C9.99802 18.0361 10.9254 17 12.1174 17H13.0002V13ZM15.0002 17H33.6464C33.6464 16.4477 33.1987 16 32.6464 16H22.9685C22.6206 16 22.2977 15.8192 22.1159 15.5226L21.1826 14H15.0002V17ZM12.1174 19L13.7964 34.1104C13.8526 34.6169 14.2807 35 14.7902 35H33.2101C33.7196 35 34.1477 34.6169 34.204 34.1104L35.7595 20.1104C35.8253 19.5181 35.3616 19 34.7656 19H12.1174Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More