From 1ba0d07757fc07aa434853a5a1ffc32d202e0b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Dalfors?= Date: Mon, 27 May 2024 10:45:18 +0200 Subject: [PATCH 1/5] feat: support set payload from file when publishing --- app/src/actions/Publish.ts | 44 ++++++++++++++++++- .../components/Sidebar/Publish/Publish.tsx | 21 ++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/app/src/actions/Publish.ts b/app/src/actions/Publish.ts index a752251..be161c1 100644 --- a/app/src/actions/Publish.ts +++ b/app/src/actions/Publish.ts @@ -2,7 +2,10 @@ import { Action, ActionTypes } from '../reducers/Publish' import { AppState } from '../reducers' import { Base64Message } from '../../../backend/src/Model/Base64Message' import { Dispatch } from 'redux' -import { MqttMessage, makePublishEvent, rendererEvents } from '../../../events' +import { promises as fsPromise } from 'fs' +import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc } from '../../../events' +import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest' +import { showError } from './Global' export const setTopic = (topic?: string): Action => { return { @@ -11,6 +14,45 @@ export const setTopic = (topic?: string): Action => { } } +export const openFile = () => async (dispatch: Dispatch, getState: () => AppState) => { + try { + const file = await getFileContent() + dispatch( + setPayload(Base64Message.fromBuffer(file.data).toUnicodeString() + )) + } catch (error) { + dispatch(showError(error)) + } + +} + +type FileParameters = { + name: string, + data: Buffer +} +async function getFileContent(): Promise { + const rejectReasons = { + noFileSelected: 'No file selected', + errorReadingFile: 'Error reading file' + } + + const openDialogReturnValue = await rendererRpc.call(makeOpenDialogRpc(), { + properties: ['openFile'], + securityScopedBookmarks: true, + }) + + const selectedFile = openDialogReturnValue.filePaths && openDialogReturnValue.filePaths[0] + if (!selectedFile) { + throw rejectReasons.noFileSelected + } + try { + const data = await fsPromise.readFile(selectedFile) + return { name: selectedFile, data } + } catch (error) { + throw rejectReasons.errorReadingFile + } +} + export const setPayload = (payload?: string): Action => { return { payload, diff --git a/app/src/components/Sidebar/Publish/Publish.tsx b/app/src/components/Sidebar/Publish/Publish.tsx index 20c080a..e9f96e4 100644 --- a/app/src/components/Sidebar/Publish/Publish.tsx +++ b/app/src/components/Sidebar/Publish/Publish.tsx @@ -1,5 +1,5 @@ import Editor from './Editor' -import FormatAlignLeft from '@material-ui/icons/FormatAlignLeft' +import { AttachFileOutlined, FormatAlignLeft } from '@material-ui/icons' import Message from './Model/Message' import Navigation from '@material-ui/icons/Navigation' import PublishHistory from './PublishHistory' @@ -116,6 +116,10 @@ const EditorMode = memo(function EditorMode(props: { props.actions.setEditorMode(value) }, []) + const openFile = useCallback(() => { + props.actions.openFile() + }, []) + const formatJson = useCallback(() => { if (props.payload) { try { @@ -132,6 +136,7 @@ const EditorMode = memo(function EditorMode(props: {
+
@@ -163,6 +168,20 @@ const FormatJsonButton = React.memo(function FormatJsonButton(props: { ) }) +const OpenFileButton = React.memo(function OpenFileButton(props: { editorMode: string; openFile: () => void }) { + return ( + + + + + + ) +}) + const PublishButton = memo(function PublishButton(props: { publish: () => void; focusEditor: () => void }) { const handleClickPublish = useCallback( (e: React.MouseEvent) => { From f17640c9dba48bb8ad5edf32641fa2da9c41f30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Dalfors?= Date: Mon, 27 May 2024 14:38:17 +0200 Subject: [PATCH 2/5] feat: save value to file --- app/src/actions/Publish.ts | 19 +++-- .../Sidebar/ValueRenderer/ValuePanel.tsx | 12 ++- app/src/components/helper/Save.tsx | 85 +++++++++++++++++++ events/OpenDialogRequest.ts | 8 +- src/electron.ts | 7 +- 5 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 app/src/components/helper/Save.tsx diff --git a/app/src/actions/Publish.ts b/app/src/actions/Publish.ts index be161c1..a7f73cf 100644 --- a/app/src/actions/Publish.ts +++ b/app/src/actions/Publish.ts @@ -17,31 +17,36 @@ export const setTopic = (topic?: string): Action => { export const openFile = () => async (dispatch: Dispatch, getState: () => AppState) => { try { const file = await getFileContent() - dispatch( - setPayload(Base64Message.fromBuffer(file.data).toUnicodeString() - )) + if (file) { + dispatch( + setPayload(Base64Message.fromBuffer(file.data).toUnicodeString() + )) + } } catch (error) { dispatch(showError(error)) } - } type FileParameters = { name: string, data: Buffer } -async function getFileContent(): Promise { +async function getFileContent(): Promise { const rejectReasons = { noFileSelected: 'No file selected', errorReadingFile: 'Error reading file' } - const openDialogReturnValue = await rendererRpc.call(makeOpenDialogRpc(), { + const { canceled, filePaths } = await rendererRpc.call(makeOpenDialogRpc(), { properties: ['openFile'], securityScopedBookmarks: true, }) - const selectedFile = openDialogReturnValue.filePaths && openDialogReturnValue.filePaths[0] + if (canceled) { + return + } + + const selectedFile = filePaths[0] if (!selectedFile) { throw rejectReasons.noFileSelected } diff --git a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx index 61c5b2f..fc07a51 100644 --- a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx +++ b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx @@ -1,6 +1,7 @@ import * as q from '../../../../../backend/src/Model' import ActionButtons from './ActionButtons' import Copy from '../../helper/Copy' +import Save from '../../helper/Save' import DateFormatter from '../../helper/DateFormatter' import MessageHistory from './MessageHistory' import Panel from '../Panel' @@ -59,6 +60,12 @@ function ValuePanel(props: Props) { return node?.message && decodeMessage(node.message)?.message?.toUnicodeString() }, [node, decodeMessage]) + const getBuffer = () => { + if (node?.message && node.message.payload) { + return node.message.payload.toBuffer() + } + } + function messageMetaInfo() { if (!props.node || !props.node.message) { return null @@ -93,10 +100,13 @@ function ValuePanel(props: Props) { const [value] = node && node.message && node.message.payload ? node.message.payload?.format(node.type) : [null, undefined] const copyValue = value ? : null + const saveValue = value ? : null return ( - Value {copyValue} + + Value {copyValue} {saveValue} + {renderViewOptions()}
diff --git a/app/src/components/helper/Save.tsx b/app/src/components/helper/Save.tsx new file mode 100644 index 0000000..d085786 --- /dev/null +++ b/app/src/components/helper/Save.tsx @@ -0,0 +1,85 @@ +import * as React from 'react' +import { connect } from 'react-redux' +import Check from '@material-ui/icons/Check' +import CustomIconButton from './CustomIconButton' +import { promises as fsPromise } from 'fs' +import { SaveAlt } from '@material-ui/icons' +import { bindActionCreators } from 'redux' +import { rendererRpc } from '../../../../events' +import { makeSaveDialogRpc } from '../../../../events/OpenDialogRequest' + +import { globalActions } from '../../actions' + +export async function saveToFile(buffer: Buffer | string): Promise { + const rejectReasons = { + errorWritingFile: 'Error writing file', + } + + const { canceled, filePath } = await rendererRpc.call(makeSaveDialogRpc(), { + securityScopedBookmarks: true, + }) + + if (!canceled && filePath !== undefined) { + try { + await fsPromise.writeFile(filePath, buffer) + return filePath + } catch (error) { + throw rejectReasons.errorWritingFile + } + } +} + +interface Props { + getBuffer: () => Buffer | undefined + actions: { + global: typeof globalActions + } +} + +interface State { + didSave: boolean +} + +class Save extends React.PureComponent { + constructor(props: Props) { + super(props) + this.state = { didSave: false } + } + + private handleClick = async (event: React.MouseEvent) => { + event.stopPropagation() + const buffer = this.props.getBuffer() + if (buffer !== undefined) { + const filename = await saveToFile(buffer) + this.props.actions.global.showNotification(`Saved to ${filename}`) + this.setState({ didSave: true }) + setTimeout(() => { + this.setState({ didSave: false }) + }, 1500) + } + } + + public render() { + const icon = !this.state.didSave ? ( + + ) : ( + + ) + + return ( + +
{icon}
+
+ ) + } +} + +const mapDispatchToProps = (dispatch: any) => { + return { + actions: { + global: bindActionCreators(globalActions, dispatch), + }, + } +} + +export default connect(undefined, mapDispatchToProps)(Save) diff --git a/events/OpenDialogRequest.ts b/events/OpenDialogRequest.ts index 6ef1254..fa79934 100644 --- a/events/OpenDialogRequest.ts +++ b/events/OpenDialogRequest.ts @@ -1,4 +1,4 @@ -import { OpenDialogOptions, OpenDialogReturnValue } from 'electron' +import { OpenDialogOptions, OpenDialogReturnValue, SaveDialogOptions, SaveDialogReturnValue } from 'electron' import { RpcEvent } from './EventSystem/Rpc' export function makeOpenDialogRpc(): RpcEvent { @@ -6,3 +6,9 @@ export function makeOpenDialogRpc(): RpcEvent { + return { + topic: 'saveDialog', + } +} \ No newline at end of file diff --git a/src/electron.ts b/src/electron.ts index 7b4cf77..6acd034 100644 --- a/src/electron.ts +++ b/src/electron.ts @@ -10,7 +10,7 @@ import buildOptions from './buildOptions' import { waitForDevServer, isDev, runningUiTestOnCi, loadDevTools } from './development' import { shouldAutoUpdate, handleAutoUpdate } from './autoUpdater' import { registerCrashReporter } from './registerCrashReporter' -import { makeOpenDialogRpc } from '../events/OpenDialogRequest' +import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest' import { backendRpc, getAppVersion } from '../events' registerCrashReporter() @@ -25,6 +25,11 @@ app.whenReady().then(() => { backendRpc.on(makeOpenDialogRpc(), async request => { return dialog.showOpenDialog(BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], request) }) + + backendRpc.on(makeSaveDialogRpc(), async request => { + return dialog.showSaveDialog(BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0], request) + }) + backendRpc.on(getAppVersion, async () => app.getVersion()) }) From 9d09ab2165237e7c0292ef9605e3a012e772f863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Dalfors?= Date: Mon, 27 May 2024 22:06:14 +0200 Subject: [PATCH 3/5] move filesystem operation to backend --- .../Sidebar/ValueRenderer/ValuePanel.tsx | 6 +++--- app/src/components/helper/Save.tsx | 16 ++++++++-------- events/Events.ts | 4 ++++ src/electron.ts | 7 ++++++- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx index fc07a51..e20bab7 100644 --- a/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx +++ b/app/src/components/Sidebar/ValueRenderer/ValuePanel.tsx @@ -60,9 +60,9 @@ function ValuePanel(props: Props) { return node?.message && decodeMessage(node.message)?.message?.toUnicodeString() }, [node, decodeMessage]) - const getBuffer = () => { + const getData = () => { if (node?.message && node.message.payload) { - return node.message.payload.toBuffer() + return node.message.payload.base64Message } } @@ -100,7 +100,7 @@ function ValuePanel(props: Props) { const [value] = node && node.message && node.message.payload ? node.message.payload?.format(node.type) : [null, undefined] const copyValue = value ? : null - const saveValue = value ? : null + const saveValue = value ? : null return ( diff --git a/app/src/components/helper/Save.tsx b/app/src/components/helper/Save.tsx index d085786..25a4cff 100644 --- a/app/src/components/helper/Save.tsx +++ b/app/src/components/helper/Save.tsx @@ -2,15 +2,15 @@ import * as React from 'react' import { connect } from 'react-redux' import Check from '@material-ui/icons/Check' import CustomIconButton from './CustomIconButton' -import { promises as fsPromise } from 'fs' + import { SaveAlt } from '@material-ui/icons' import { bindActionCreators } from 'redux' -import { rendererRpc } from '../../../../events' +import { rendererRpc, writeFile } from '../../../../events' import { makeSaveDialogRpc } from '../../../../events/OpenDialogRequest' import { globalActions } from '../../actions' -export async function saveToFile(buffer: Buffer | string): Promise { +export async function saveToFile(data: string): Promise { const rejectReasons = { errorWritingFile: 'Error writing file', } @@ -21,7 +21,7 @@ export async function saveToFile(buffer: Buffer | string): Promise Buffer | undefined + getData: () => string | undefined actions: { global: typeof globalActions } @@ -48,9 +48,9 @@ class Save extends React.PureComponent { private handleClick = async (event: React.MouseEvent) => { event.stopPropagation() - const buffer = this.props.getBuffer() - if (buffer !== undefined) { - const filename = await saveToFile(buffer) + const data = this.props.getData() + if (data != undefined) { + const filename = await saveToFile(data) this.props.actions.global.showNotification(`Saved to ${filename}`) this.setState({ didSave: true }) setTimeout(() => { diff --git a/events/Events.ts b/events/Events.ts index 418652b..a951724 100644 --- a/events/Events.ts +++ b/events/Events.ts @@ -54,3 +54,7 @@ export function makeConnectionMessageEvent(connectionId: string): Event = { topic: 'getAppVersion', } + +export const writeFile: RpcEvent<{ filePath: string, data: string }, void> = { + topic: 'writeFile', +} \ No newline at end of file diff --git a/src/electron.ts b/src/electron.ts index 6acd034..5df6ca2 100644 --- a/src/electron.ts +++ b/src/electron.ts @@ -4,6 +4,7 @@ import ConfigStorage from '../backend/src/ConfigStorage' import { app, BrowserWindow, Menu, dialog } from 'electron' import { autoUpdater } from 'electron-updater' import { ConnectionManager } from '../backend/src/index' +import { promises as fsPromise } from 'fs' // import { electronTelemetryFactory } from 'electron-telemetry' import { menuTemplate } from './MenuTemplate' import buildOptions from './buildOptions' @@ -11,7 +12,7 @@ import { waitForDevServer, isDev, runningUiTestOnCi, loadDevTools } from './deve import { shouldAutoUpdate, handleAutoUpdate } from './autoUpdater' import { registerCrashReporter } from './registerCrashReporter' import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest' -import { backendRpc, getAppVersion } from '../events' +import { backendRpc, getAppVersion, writeFile } from '../events' registerCrashReporter() @@ -31,6 +32,10 @@ app.whenReady().then(() => { }) backendRpc.on(getAppVersion, async () => app.getVersion()) + + backendRpc.on(writeFile, async ({ filePath, data }) => { + await fsPromise.writeFile(filePath, Buffer.from(data, 'base64')) + }) }) autoUpdater.logger = log From bd6a1a0d2daa3e28286b38efae4ecd8f4f4ecb78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Dalfors?= Date: Wed, 29 May 2024 10:01:32 +0200 Subject: [PATCH 4/5] Support specifying file encoding --- app/src/actions/Publish.ts | 19 +++++++++---------- app/src/components/helper/Save.tsx | 4 ++-- events/Events.ts | 6 +++++- src/electron.ts | 10 +++++++--- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/app/src/actions/Publish.ts b/app/src/actions/Publish.ts index a7f73cf..ab75050 100644 --- a/app/src/actions/Publish.ts +++ b/app/src/actions/Publish.ts @@ -2,10 +2,10 @@ import { Action, ActionTypes } from '../reducers/Publish' import { AppState } from '../reducers' import { Base64Message } from '../../../backend/src/Model/Base64Message' import { Dispatch } from 'redux' -import { promises as fsPromise } from 'fs' -import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc } from '../../../events' +import { MqttMessage, makePublishEvent, rendererEvents, rendererRpc, readFromFile } from '../../../events' import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest' import { showError } from './Global' +import { Base64 } from 'js-base64' export const setTopic = (topic?: string): Action => { return { @@ -14,13 +14,12 @@ export const setTopic = (topic?: string): Action => { } } -export const openFile = () => async (dispatch: Dispatch, getState: () => AppState) => { +export const openFile = (encoding: 'utf8' = 'utf8') => async (dispatch: Dispatch, getState: () => AppState) => { try { - const file = await getFileContent() + const file = await getFileContent(encoding) if (file) { dispatch( - setPayload(Base64Message.fromBuffer(file.data).toUnicodeString() - )) + setPayload(file.data)) } } catch (error) { dispatch(showError(error)) @@ -29,9 +28,9 @@ export const openFile = () => async (dispatch: Dispatch, getState: () => Ap type FileParameters = { name: string, - data: Buffer + data: string } -async function getFileContent(): Promise { +async function getFileContent(encoding: string): Promise { const rejectReasons = { noFileSelected: 'No file selected', errorReadingFile: 'Error reading file' @@ -51,8 +50,8 @@ async function getFileContent(): Promise { throw rejectReasons.noFileSelected } try { - const data = await fsPromise.readFile(selectedFile) - return { name: selectedFile, data } + const data = await rendererRpc.call(readFromFile, { filePath: selectedFile, encoding }) + return { name: selectedFile, data: data.toString(encoding) } } catch (error) { throw rejectReasons.errorReadingFile } diff --git a/app/src/components/helper/Save.tsx b/app/src/components/helper/Save.tsx index 25a4cff..cf54819 100644 --- a/app/src/components/helper/Save.tsx +++ b/app/src/components/helper/Save.tsx @@ -5,7 +5,7 @@ import CustomIconButton from './CustomIconButton' import { SaveAlt } from '@material-ui/icons' import { bindActionCreators } from 'redux' -import { rendererRpc, writeFile } from '../../../../events' +import { rendererRpc, writeToFile } from '../../../../events' import { makeSaveDialogRpc } from '../../../../events/OpenDialogRequest' import { globalActions } from '../../actions' @@ -21,7 +21,7 @@ export async function saveToFile(data: string): Promise { if (!canceled && filePath !== undefined) { try { - const filename = await rendererRpc.call(writeFile, { filePath, data }) + const filename = await rendererRpc.call(writeToFile, { filePath, data }) return filePath } catch (error) { throw rejectReasons.errorWritingFile diff --git a/events/Events.ts b/events/Events.ts index a951724..d10b666 100644 --- a/events/Events.ts +++ b/events/Events.ts @@ -55,6 +55,10 @@ export const getAppVersion: RpcEvent = { topic: 'getAppVersion', } -export const writeFile: RpcEvent<{ filePath: string, data: string }, void> = { +export const writeToFile: RpcEvent<{ filePath: string, data: string, encoding?: string }, void> = { topic: 'writeFile', +} + +export const readFromFile: RpcEvent<{ filePath: string, encoding?: string }, Buffer> = { + topic: 'readFromFile', } \ No newline at end of file diff --git a/src/electron.ts b/src/electron.ts index 5df6ca2..db66d85 100644 --- a/src/electron.ts +++ b/src/electron.ts @@ -12,7 +12,7 @@ import { waitForDevServer, isDev, runningUiTestOnCi, loadDevTools } from './deve import { shouldAutoUpdate, handleAutoUpdate } from './autoUpdater' import { registerCrashReporter } from './registerCrashReporter' import { makeOpenDialogRpc, makeSaveDialogRpc } from '../events/OpenDialogRequest' -import { backendRpc, getAppVersion, writeFile } from '../events' +import { backendRpc, getAppVersion, writeToFile, readFromFile } from '../events' registerCrashReporter() @@ -33,8 +33,12 @@ app.whenReady().then(() => { backendRpc.on(getAppVersion, async () => app.getVersion()) - backendRpc.on(writeFile, async ({ filePath, data }) => { - await fsPromise.writeFile(filePath, Buffer.from(data, 'base64')) + backendRpc.on(writeToFile, async ({ filePath, data, encoding }) => { + await fsPromise.writeFile(filePath, Buffer.from(data, 'base64'), { encoding }) + }) + + backendRpc.on(readFromFile, async ({ filePath, encoding }) => { + return fsPromise.readFile(filePath, { encoding }) }) }) From b4a619993696dede882209a5b5d0bcb855082c00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Dalfors?= Date: Wed, 29 May 2024 10:02:00 +0200 Subject: [PATCH 5/5] Move file operation to backend --- app/src/actions/ConnectionManager.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/actions/ConnectionManager.ts b/app/src/actions/ConnectionManager.ts index fce8cbe..f9924b3 100644 --- a/app/src/actions/ConnectionManager.ts +++ b/app/src/actions/ConnectionManager.ts @@ -9,12 +9,11 @@ import { import { default as persistentStorage, StorageIdentifier } from '../utils/PersistentStorage' import { Dispatch } from 'redux' import { showError } from './Global' -import { promises as fsPromise } from 'fs' import * as path from 'path' import { ActionTypes, Action } from '../reducers/ConnectionManager' import { Subscription } from '../../../backend/src/DataSource/MqttSource' import { connectionsMigrator } from './migrations/Connection' -import { rendererRpc } from '../../../events' +import { rendererRpc, readFromFile } from '../../../events' import { makeOpenDialogRpc } from '../../../events/OpenDialogRequest' export interface ConnectionDictionary { @@ -81,7 +80,7 @@ async function openCertificate(): Promise { throw rejectReasons.noCertificateSelected } - const data = await fsPromise.readFile(selectedFile) + const data = await rendererRpc.call(readFromFile, { filePath: selectedFile }) if (data.length > 16_384 || data.length < 64) { throw rejectReasons.certificateSizeDoesNotMatch }