Hello

Building Electron React apps

a hand-full of tips!

Tip #1

electron is an idea,

there are alternatives

getting set up with

(vanilla) Electron

npm init -y
npm install --save-dev electron
const { app, BrowserWindow } = require("electron")
const path = require("path")

const createWindow = () => {
    const mainWindow = new BrowserWindow({
        "width": 800,
        "height": 600,
    })

    mainWindow.loadFile("index.html")
}

app.whenReady().then(() => {
    createWindow()

    app.on("activate", () => {
        if (BrowserWindow.getAllWindows().length === 0) {
            createWindow()
        }
    })
})

app.on("window-all-closed", () => {
    if (process.platform !== "darwin") {
        app.quit()
    }
})
<!doctype html>
<html>
  <head>
      <meta charset="utf-8">
      <title>PixelRobot</title>
      <meta
          http-equiv="Content-Security-Policy"
          content="script-src 'self' 'unsafe-inline';" />
  </head>
  <body>
      ...UI goes here
  </body>
</html>
"scripts": {
    "start": "electron ."
}

building code into an executable

npm install --save-dev electron-builder
"build": {
    "appId": "io.assertchris.pixelrobot",
    "productName": "PixelRobot",
    "files": [
        "build/**/*",
        "main.js",
        "preload.js",
        "renderer.html"
    ],
    "mac": {
        "category": "public.app-category.developer-tools"
    }
}
"scripts": {
    "dist": "npm run build && electron-builder"
},

getting set up with capacitor

npm install @capacitor/core
npm install --save-dev @capacitor/cli
npx cap init

set up main.js + index.html

npm install @capacitor-community/electron
npx cap add @capacitor-community/electron
npx cap open @capacitor-community/electron

...but now you can use

capacitor plugins!

Tip #2

make a good workflow before

you get too deep in the woods

npm install --save-dev tailwindcss @tailwindcss/forms autoprefixer concurrently cssnano postcss postcss-cli
"scripts": {
    "start": "concurrently 'npm:watch' 'electron .'",
    "watch-css": "postcss source/index.pcss -o build/index.css -w",
    "build-css": "NODE_ENV=production postcss source/index.pcss -o build/index.css",
    "watch": "concurrently 'npm:watch-css'",
    "build": "concurrently 'npm:build-css'"
},
npm install --save-dev esbuild
"scripts": {
    ...
    "watch-js": "esbuild source/index.jsx --bundle --watch --outfile=build/index.js",
    "build-js": "NODE_ENV=production esbuild source/index.jsx --bundle --minify --outfile=build/index.js",
    "watch": "concurrently 'npm:watch-css' 'npm:watch-js'",
    "build": "concurrently 'npm:build-css' 'npm:build-js'",
},

you could combine them with a custom build script...

npm install --save-dev esbuild-plugin-postcss2
const esbuild = require('esbuild')
const postCssPlugin = require('esbuild-plugin-postcss2')
const autoprefixer = require('autoprefixer')
const tailwind = require('tailwindcss')

esbuild
    .build({
        entryPoints: ['source/index.jsx', 'source/index.css'],
        bundle: true,
        outdir: 'build',
        plugins: [
            postCssPlugin.default({
              	plugins: [autoprefixer, tailwind],
            }),
        ],
    })
    .catch(() => process.exit(1))

finally...

<!doctype html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta
            http-equiv="Content-Security-Policy"
            content="default-src 'self'; script-src 'self'; img-src 'self' data:"
        />
        <meta
            http-equiv="X-Content-Security-Policy"
            content="default-src 'self'; script-src 'self'; img-src 'self' data:"
        />
        <link rel="stylesheet" href="build/index.css" />
        <title>PixelRobot</title>
    </head>
    <body class="h-screen">
        <div id="app" class="flex w-full h-full text-gray-900"></div>
        <script src="build/index.js"></script>
    </body>
</html>

Tip #3

bridging between the

main and renderer processes

- send a ZIP file from renderer to main

- unzip ZIP file in main

- store image files in filesystem

- send image meta data to renderer

{
    "id": "controls-16",
    "label": "Controls 16",
    "sprites": [
        {
            "id": "analog-1-highlight",
            "label": "Highlight Analog Stick",
            "width": 16,
            "height": 16,
            "frames": 9,
            "fps": 9,
            "pixelated": true,
            "preview": {
                "still": 4,
                "loop": true,
                "attachment": "analog-1-highlight.png"
            },
            "variations": {
                "rose": {
                    "legend": "#f43f5e",
                    "#fef2f2": "#fff1f2",
                    "#fee2e2": "#ffe4e6",
                    "#fecaca": "#fecdd3",
                    ...
}

part 1: getting the file in renderer

const [dragEntered, setDragEntered] = useState(false)
const [droppedFiles, setDroppedFiles] = useState(undefined)

useEffect(() => {
    const stopPropagation = event => {
        event.preventDefault()
        event.stopPropagation()
    }

    const dragEnter = () => {
        setDragEntered(true)
    }

    const dragLeave = () => {
        setDragEntered(false)
    }

    const drop = event => {
        stopPropagation(event)
        setDroppedFiles(event.dataTransfer.files)
        setDragEntered(false)
    }

    document.addEventListener("dragover", stopPropagation)
    document.addEventListener("dragenter", dragEnter)
    document.addEventListener("dragleave", dragLeave)
    document.addEventListener("drop", drop)

    return () => {
        document.removeEventListener("dragover", stopPropagation)
        document.removeEventListener("dragenter", dragEnter)
        document.removeEventListener("dragleave", dragLeave)
        document.removeEventListener("drop", drop)
    }
}, [setDragEntered])
useEffect(() => {
    if (droppedFiles) {
        for (const file of droppedFiles) {
            const reader = new FileReader()

            reader.addEventListener("loadend", async () => {
                // ...send this somewhere
            })

            reader.readAsDataURL(file)
        }
    }
}, [droppedFiles])

part 2: unzipping the file in main

const attempt = require("@assertchris/attempt-promise")
const DecompressZip = require("decompress-zip")

const {
    promises: { writeFile, mkdir, readFile, readdir, rmdir },
} = require("fs")

const unzipPack = async folder => {
    return new Promise((resolve, reject) => {
        const unzipper = new DecompressZip(`${folder}.zip`)

        unzipper.on("error", error => {
            reject(error)
        })

        unzipper.on("extract", async () => {
            const [readFileError, readFileResult] = await attempt(
                readFile(`${folder}/index.json`, "utf8"),
            )

            if (readFileError) {
                reject(readFileError)
                return
            }

            const meta = JSON.parse(readFileResult)
            
            // store image files + send meta data back
        })
    })
}

part 3: figuring out how to connect

these things

const window = new BrowserWindow({
    width: 1280,
    height: 720,
    webPreferences: {
        preload: path.join(__dirname, "preload.js"),
    },
})
const { contextBridge, ipcRenderer } = require("electron")

contextBridge.exposeInMainWorld("api", {
    parsePackFile: async (packFileName, packFileData) => {
        return ipcRenderer.invoke("parsePackFile", packFileName, packFileData)
    },
    putAttachment: async (packId, attachmentId, attachmentData) => {
        return ipcRenderer.invoke("putAttachment", packId, attachmentId, attachmentData)
    },
    fetchAttachment: async (packId, attachmentId) => {
        return ipcRenderer.invoke("fetchAttachment", packId, attachmentId)
    },
    removeAttachments: async packId => {
        return ipcRenderer.invoke("removeAttachments", packId)
    },
    downloadAttachment: async (attachmentId, data) => {
        return ipcRenderer.invoke("downloadAttachment", attachmentId, data)
    },
})
reader.addEventListener("loadend", async () => {
    const { meta, attachments } = await window.api.parsePackFile(
        file.name,
        reader.result,
    )

    //...persist data in renderer
})
ipcMain.handle("parsePackFile", async (_, packFileName, packFileData) => {
    const temp = app.getPath("temp")
    const hash = crypto.createHash("md5").update(packFileName).digest("hex")
    const folder = path.join(temp, `pixelrobot-${hash}`)

    await mkdir(temp, { recursive: true })

    await writeFile(`${folder}.zip`, packFileData.replace("data:application/zip;base64", ""), {
        encoding: "base64",
    })

    await rmdir(folder, { recursive: true })

    return unzipPack(folder)
})

all the hard problems can

be solved in this way

Tip #4

you don't need

fancy state management

sorry, no time for a redux tutorial

import { initial, reducer } from "./reducer"

import actions from "./actions"
import { createContext } from "react"
import creators from "./creators"
import databases from "./databases"

const context = createContext(initial)

export { initial, reducer, actions, creators, context, databases }
import { context as Context, actions, creators, initial, reducer } from "./state"
import { Home, Sheet, Sprite } from "./screens"
import React, { useEffect, useState, useReducer } from "react"

const App = () => {
    const [state, dispatch] = useReducer(reducer, initial)
    
    // ...

    return (
        <Context.Provider value={{ state, dispatch }}>
            ...
        </Context.Provider>
    )
}

export { App }
const { state, dispatch } = useContext(context)
const [deleting, setDeleting] = useState(undefined)

const createNewSheet = () => {
    dispatch(creators[actions.sheets.create]({ title: "new sheet" }))
}

const deleteSheet = row => {
    setDeleting(row)
}

const confirmDeleteSheet = () => {
    dispatch(creators[actions.sheets.delete](deleting.doc))
    setDeleting(undefined)
}

const cancelDeleteSheet = () => {
    setDeleting(undefined)
}
import PouchDB from "pouchdb/dist/pouchdb"
import UpsertPlugin from "pouchdb-upsert/dist/pouchdb.upsert"

PouchDB.plugin(UpsertPlugin)

const packs = new PouchDB("packs")
const settings = new PouchDB("settings")
const sheets = new PouchDB("sheets")

export default { packs, settings, sheets }
const [error, result] = await attempt(
    databases.packs.post({
        ...meta,
        createdAt: DateTime.now().toISO(),
    }),
)

if (error) {
    throw error
}

for (const [name, data] of attachments) {
    const [error] = await attempt(window.api.putAttachment(result.id, name, data))

    if (error) {
        throw error
    }
}

Tip #5

don't forget about the

web tech you already know

lemme show you how

this malarkey works...

Building Electron React apps (August 2021)

By Christopher Pitt

Building Electron React apps (August 2021)

  • 830