diff --git a/electron/install.js b/electron/install.js index 5b9a88734..d30e914fd 100644 --- a/electron/install.js +++ b/electron/install.js @@ -370,4 +370,9 @@ module.exports = { OperateDirectory, OperateCmd, Env, + dirs: { + VersionFile, + LogFile, + OperateInstallationLog, + }, }; diff --git a/electron/main.js b/electron/main.js index b18c0d652..e3345909f 100644 --- a/electron/main.js +++ b/electron/main.js @@ -7,6 +7,8 @@ const { Menu, Notification, ipcMain, + dialog, + shell, } = require('electron'); const { spawn } = require('child_process'); const path = require('path'); @@ -14,6 +16,7 @@ const fs = require('fs'); const os = require('os'); const next = require('next'); const http = require('http'); +const AdmZip = require('adm-zip'); const { TRAY_ICONS, TRAY_ICONS_PATHS } = require('./icons'); const { @@ -23,6 +26,7 @@ const { OperateDirectory, startDocker, Env, + dirs, } = require('./install'); const { killProcesses } = require('./processes'); const { isPortAvailable, findAvailablePort } = require('./ports'); @@ -560,3 +564,99 @@ process.on('uncaughtException', (error) => { }); }); }); + +// OPEN PATH +ipcMain.on('open-path', (_, filePath) => { + shell.openPath(filePath); +}); + +function getSanitizedLogs({ name, filePath, data }) { + const logs = filePath ? fs.readFileSync(filePath, 'utf-8') : data; + const tempDir = os.tmpdir(); + + const usernameRegex = /\/Users\/([^/]+)/g; + const sanitizedData = logs.replace(usernameRegex, '/Users/*****'); + + const sanitizedLogsFilePath = path.join(tempDir, name); + fs.writeFileSync(sanitizedLogsFilePath, sanitizedData); + + return sanitizedLogsFilePath; +} + +// EXPORT LOGS +ipcMain.handle('save-logs', async (_, data) => { + // version.txt + const versionFile = dirs.VersionFile; + // logs.txt + const logFile = getSanitizedLogs({ name: 'log.txt', filePath: dirs.LogFile }); + // operate.log + const installationLog = getSanitizedLogs({ + name: 'installation_log.txt', + filePath: dirs.OperateInstallationLog, + }); + + const tempDir = os.tmpdir(); + + // OS info + const osInfo = ` + OS Type: ${os.type()} + OS Platform: ${os.platform()} + OS Arch: ${os.arch()} + OS Release: ${os.release()} + Total Memory: ${os.totalmem()} + Free Memory: ${os.freemem()} + `; + const osInfoFilePath = path.join(tempDir, 'os_info.txt'); + fs.writeFileSync(osInfoFilePath, osInfo); + + // Persistent store + let storeFilePath; + if (data.store) { + storeFilePath = path.join(tempDir, 'store.txt'); + fs.writeFileSync(storeFilePath, JSON.stringify(data.store, null, 2)); + } + + // Other debug data: balances, addresses, etc. + let debugDataFilePath; + if (data.debugData) { + debugDataFilePath = getSanitizedLogs({ + name: 'debug_data.txt', + data: JSON.stringify(data.debugData, null, 2), + }); + } + + // Create a zip archive + const zip = new AdmZip(); + zip.addLocalFile(versionFile); + zip.addLocalFile(logFile); + zip.addLocalFile(installationLog); + zip.addLocalFile(osInfoFilePath); + zip.addLocalFile(storeFilePath); + zip.addLocalFile(debugDataFilePath); + + // Show save dialog + const { filePath } = await dialog.showSaveDialog({ + title: 'Save Logs', + defaultPath: path.join(os.homedir(), 'pearl_logs.zip'), + filters: [{ name: 'Zip Files', extensions: ['zip'] }], + }); + + let result; + + if (filePath) { + // Write the zip file to the selected path + zip.writeZip(filePath); + result = { success: true, dirPath: path.dirname(filePath) }; + } else { + result = { success: false }; + } + + // Remove temporary files + fs.unlinkSync(logFile); + fs.unlinkSync(installationLog); + fs.unlinkSync(osInfoFilePath); + if (storeFilePath) fs.unlinkSync(storeFilePath); + if (debugDataFilePath) fs.unlinkSync(debugDataFilePath); + + return result; +}); diff --git a/electron/preload.js b/electron/preload.js index f37da09be..e67e7c85f 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -21,6 +21,8 @@ contextBridge.exposeInMainWorld('electronAPI', { setAppHeight: (height) => ipcRenderer.send('set-height', height), showNotification: (title, description) => ipcRenderer.send('show-notification', title, description), + saveLogs: (data) => ipcRenderer.invoke('save-logs', data), + openPath: (filePath) => ipcRenderer.send('open-path', filePath), // update downloads startDownload: () => ipcRenderer.send('start-download'), diff --git a/frontend/client/types.ts b/frontend/client/types.ts index b15841131..f4e6937bd 100644 --- a/frontend/client/types.ts +++ b/frontend/client/types.ts @@ -25,7 +25,7 @@ export type ChainData = { export type Service = { name: string; hash: string; - keys: ServiceKeys; + keys: ServiceKeys[]; readme?: string; ledger: LedgerConfig; chain_data: ChainData; diff --git a/frontend/components/HelpAndSupport/HelpAndSupport.tsx b/frontend/components/HelpAndSupport/HelpAndSupport.tsx new file mode 100644 index 000000000..431d1994e --- /dev/null +++ b/frontend/components/HelpAndSupport/HelpAndSupport.tsx @@ -0,0 +1,121 @@ +import { CloseOutlined, QuestionCircleOutlined } from '@ant-design/icons'; +import { Button, Card, Flex, message, Typography } from 'antd'; +import { useCallback, useEffect, useState } from 'react'; + +import { FAQ_URL, SUPPORT_URL } from '@/constants'; +import { UNICODE_SYMBOLS } from '@/constants/unicode'; +import { PageState } from '@/enums'; +import { useLogs, usePageState } from '@/hooks'; +import { useElectronApi } from '@/hooks/useElectronApi'; + +import { CardTitle } from '../common/CardTitle'; +import { CardSection } from '../styled/CardSection'; + +const { Title, Paragraph } = Typography; + +const SettingsTitle = () => ( + + + Help & support + + } + /> +); + +const LogsSavedMessage = ({ onClick }: { onClick: () => void }) => { + return ( + + Logs saved + + + ); +}; + +export const HelpAndSupport = () => { + const { goto } = usePageState(); + const { openPath, saveLogs } = useElectronApi(); + + const logs = useLogs(); + + const [isLoading, setIsLoading] = useState(false); + const [canSaveLogs, setCanSaveLogs] = useState(false); + + const onSaveLogs = useCallback(() => setCanSaveLogs(true), []); + + useEffect(() => { + if (canSaveLogs && logs && !isLoading) { + setIsLoading(true); + saveLogs?.(logs) + .then((result) => { + if (result.success) { + message.success({ + content: ( + openPath?.(result.dirPath)} /> + ), + duration: 10, + }); + } else { + message.error('Save logs failed or cancelled'); + } + }) + .finally(() => { + setIsLoading(false); + setCanSaveLogs(false); + }); + } + }, [canSaveLogs, isLoading, logs, openPath, saveLogs]); + + return ( + } + bordered={false} + extra={ + + + + ); +}; diff --git a/frontend/components/Main/Main.tsx b/frontend/components/Main/Main.tsx index 7e40e4689..41a4df228 100644 --- a/frontend/components/Main/Main.tsx +++ b/frontend/components/Main/Main.tsx @@ -1,4 +1,4 @@ -import { SettingOutlined } from '@ant-design/icons'; +import { QuestionCircleOutlined, SettingOutlined } from '@ant-design/icons'; import { Button, Card, Flex } from 'antd'; import { useEffect } from 'react'; @@ -32,13 +32,20 @@ export const Main = () => { } extra={ - + + + + {data ? ( + data.map((item) => ) + ) : ( + + + + )} + + + ); +}; diff --git a/frontend/components/Settings/Settings.tsx b/frontend/components/Settings/Settings.tsx index 080e04e8e..c92c4da82 100644 --- a/frontend/components/Settings/Settings.tsx +++ b/frontend/components/Settings/Settings.tsx @@ -13,6 +13,7 @@ import { useSettings } from '@/hooks/useSettings'; import { Alert } from '../common/Alert'; import { CardTitle } from '../common/CardTitle'; import { CardSection } from '../styled/CardSection'; +import { DebugInfoCard } from './DebugInfoCard'; import { SettingsAddBackupWallet } from './SettingsAddBackupWallet'; const { Text, Paragraph } = Typography; @@ -59,9 +60,11 @@ const SettingsMain = () => { title={} bordered={false} extra={ - +