It’s Been A While
It’s been almost 3 weeks since the last post in this series, quite a lot has happened and I’m excited to share some of the learnings during this period of development.
Let’s do this!
Electron App Configuration
Since the early stage of the design, I’ve wanted this app to be desktop-based. But for ease of development, I’ve chosen to use Electron to package the Express.js app to run on desktops. Given that this is a desktop app, I thought that giving the user some configuration options will be a good UX.
The first roadblock I faced when building the configuration screen is, how do I allow the user to choose where to load the data files from? I read the the Electron documentation, and found a few features that allow me to achieve that.
Inter-Process Communication
First of all, the configuration screen is just another web page, which I’ve built with plain HTML and Javascript without using Vue. When the Electron app is launched, the server app is not yet started, because I want to allow the user to choose a port to start the server through the configuration screen. Once the user has input the port and data directory, I will write these options to file, and the Electron main process will start the server app.
The interaction between the configuration screen and Electron main process is done through IPC.
This is how the interaction between User, Electron main process (main.js), configuration screen (renderer.js), server (server.js) and client (client.js) apps looks like:
%%{ init: { 'theme': 'base', 'themeVariables': { "primaryColor": "#f4e3d7", "primaryTextColor": "#502d16", "primaryBorderColor": "#784421", "lineColor": "#784421", "secondaryColor": "#a05a2c", "tertiaryColor": "#c87137" } } }%% sequenceDiagram participant User participant main.js participant renderer.js participant server.js participant client.js User->>+main.js: Launch Triple Tee App main.js->>+renderer.js: Populate configs from file renderer.js-->>User: Render config screen Note right of renderer.js: Wait for user input User->>renderer.js: Input config options renderer.js-->>-main.js: Callback with user input main.js->>+main.js: Write configs to file main.js->>+server.js: Launch Server at chosen port server.js-->>-main.js: Listen at chosen port main.js->>+client.js: Load Client to BrowserWindow client.js-->>-main.js: Return Vue App main.js-->>-User: Render Triple Tee App
The IPC is implemented using the contextBridge
and ipcRenderer
modules.
Since the configuration screen has no access to native Node.js APIs, eg. accessing the file system, we need to use the
preload script to load and expose the default configs from file. Then, we expose the API for the configuration screen
to call back when we receive user’s input, which internally sends the set-app-config
message.
In the configuration screen, we will setup the usual form inputs, and the user will click a button the confirm the configuration. On button click, we then call the API setup earlier in the preload script to send the input values to the main process.
In the Electron main process, we will listen for the set-app-config
message, and on receiving it, write the configs
to file, load the server app and request for the client app at the chosen port.
Finally, we allow the server app to read the port from the config file when it is loaded by the Electron main process.
IPC allows for user interaction with the app before the server is launched.
Web Demo Build
Although this app is primarily a desktop app, I also want to provide a web demo on the app’s landing page (yes, I will build a dedicated landing page for the app soon), which allows the prospective user to try out the app without downloading it. This is quite conveniently done, because the client app is already written as a Vue app and can be easily deployed as a SPA on my target, eg. Github Pages. The only question is whether I want to host the server on the cloud also to serve the business logic and data for the demo app.
In the end, I’ve decided to not host the server app, for a few reasons. First, I want to save on hosting cost, since I intend to offer the app for free, it’ll be economically unsound if I had to spend more money on it on top of my own time and effort to build the app. Second, I don’t want the user to end up using the web demo like an actual app, and start sending sensitive personal data to my hosted server, which can be viewed by other users of the demo since there’s no user access control built in yet.
Abstract Data Access Layer
To solve the issue, I decided to make the client app’s data access layer abstracted, and can choose between accessing logic and data via APIs or through client-side stores at build time.
First, the abstracted data access layer can choose between using API access or client-side data access during build
time. I assign the web demo as staging
environment, so when I build for staging
, it will use client-side data
access.
Then, my client-side data access will implement the CRUD APIs as per the server-side data access layer.
But instead of reading from data files, the client-side data store will read from the browser’s localStorage
. It will
also implement data cache as per server-side implementation, so that the behaviour is more consistent between the
actual Electron app and the web demo.
Custom Business Logic
There’re some custom logic APIs beyond the usual CRUD actions, and I need to implement them accordingly in the web demo too.
First, I have a store of custom functions, and allow for registering new functions and retrieving functions for a given model class. This is kind of similar to how we have routes registration in the Express.js app.
Then, the web data access will act like the route handler, where it will receive the data access request, and try to lookup the corresponding function and invoke accordingly.
For example, I have a custom logic in the Invoices module that allows the user to preview an invoice. This is done by aggregating data from various models, so it’s not a simple Read API.
Now, in my ViewInvoice component, I can call the data access layer to preview invoice, and underlying it will lookup the correct custom function if this is a web demo.
In this case, if I’m building for the web demo, it will go through the web data access layer to lookup the custom function
and invoke the logic. If this is the Electron app, then it will go through the API data access layer and send a request
to the server at /api/invoices/:id/preview_invoice
.
Exact same functionalities as the desktop, except using browser’s local storage.
PDF Generation
One of the main features of this app is the ability to generate invoices or receipts from user-defined templates. Since this app has a client-side only version and a Node.js-backed desktop version, I implemented 2 different ways of generating PDFs depending on the mode.
Server-side Generation
Let’s start with server-side generation. After some search online, I found that there’s a library called node-html2pdf
which allows for server-side HTML to PDF generation. How it works is by launching a headless browser using puppeteer
,
render the markup accordingly, then print the page as PDF.
At first, I’ve managed to install it and use it as-is and it works fine in development mode. But when I built it into
the Electron app, the PDF generation stopped working. After some debugging, I realised that it is probably because the
transitive dependency puppeteer
is not built into the Electron app, so it can’t be used to run the headless browser
to generate the PDF. In the end, I’ve decided to instead install puppeteer
as a direct dependency, and borrow some
of the original library’s code and customise to suit my use case.
Then, whenever my API requires PDF generation, I will register the route to use the downloadPdf
handler.
Client-side Generation
On the other hand, HTML to PDF conversion is easier for the client, because there’s no need to use a headless browser
to render the markup first before printing the page as a PDF. In this case, I simply just use the html2pdf.js
library
to do the job. It works slightly differently from the node-html2pdf
library. Instead of printing the page to PDF,
the client-side library generates a screenshot of the page and save it to PDF format.
Then, I implement the web data access version accordingly.
User can define custom templates and generate invoices in PDF.
That’s All For Now
This has been quite a long and technical read, I hope that it is useful information for you! The app is probably about 80% done now, the remaining work involves implementing a recurring task runner, charts, and a lot of UI/UX polishing.
For now, you can already have a glimpse of the web demo (link in References). Play around with it and let me know what you think! UI/UX feedback is very much welcome as I’m in the stage of polishing the app.
That’s all for now, stay tuned for more updates!