March 15, 2024

Web apps without browsers

Does the title sound like a contradiction?
Is that possible to so? Is it magic?
Why would you do that?
Will it work in offline?
Continue reading to find out.

Offline

Intro

To get started, yes it is possible to have the same web app running in browser and as a native app on windows, osx, ios and android.

It is of course not magic, while granted it might look like that. Getting your app running as a native app will require some effort, it is not turn-key automatic. We shall explore what needs be done in here.

Motivation

There are two main reasons why you would want to do this.
The first is more business related - having an app in stores will help the visibility of your product. Having an app on your users homescreen/desktop/dock make it more likely to be used.
The second is technical - a native app can do more things. While the gap between browser and native apps is closing, a native app can access standard OS api.

These days the top reason to turn your webapp into a native app is offline. That means your app starts without an internet connection, reads and writes data to the device storage.

The existing browser apis are fragmented and make the building of offline apps very complex. The big elephant in the room is also the fact, that very few users would even expect a webapp (eq a web site) to work offline.

Whether your motivation is business or technology lets see what we can do.

How does it work

The trick that makes this work is bundling your webapp (all html, js, css images) with a browser and making a single app of it.

In a nutshell.
When the user starts the app, the app launches a browser window (stripped down - no url, back button or tabs) and loads your webapp in the browser.
But not from your server online, but from local storage.

This technology is not new. In fact it has been around for the last 10 years, under different names. For building mobile apps you might heard about it as phonegap, Cordova or Capacitor. For native desktop apps there is Electronjs.
We shall call them wrappers.

Lets recap. A native web apps has these (logical) components:

  1. Copy of your webapp files
  2. WebView - A browser window (a stripped down renderer, no standard browser gui)
  3. Native components
  4. Custom native code
  5. Bridge that allows your js code to access native apis

While all wrappers have the same logical components, the implementation differs.

WebView

Mobile wrappers (capacitor, etc) use a WebView provided by the OS. Mobile Safari on iOS, mobile Chrome on Android. Capacitor will load your webapp using a custom capacitor:// scheme.

Electron - the desktop wrapper, uses a stripped down chrome browser that is bundled with the final app.
(Which means the app is quite large, more than 100mb.)
With electron it is your choice how to load your webapp. You can use a file:// scheme, or introduce your own scheme too. Using a file scheme is the most straightforward.

Native components

Capacitor (and other mobile wrappers) have a huge list of ready made packages that you can install with npm. They also provide many APIs out of the box. Because the native components are written in native code for the mobile os (c,objc,swift,java etc) they might work differently on ios than android or might not be available for some platforms. You know how they say, write once debug eveywhere.

On electron we have actually a nodejs process running the whole show. That means, you can use nodejs packages. Pure javascript packages will give you the least trouble building the app for distribution. But you can use packages with native code too (c/cpp etc).

Custom native components

Capacitor has an sdk for native components. Keep in mind they have to be written specifically for android and ios. Usually C makes code easiest to share between platforms. Or you’ll have to do it twice.

Electron uses nodejs as the host of the native components, thus you can easily create “native” components in pure js. Which makes them immediately portable between deskop platforms.

Bridge

With capacitor Exposing native code to your webapp works via the plugin api. Take a look here how it is done.

On Electron there have been historically many ways on how to do call native components from web app.
We shall see a way in the following section.

The nice thing about electron’s nodejs is that you don’t need to create any plugins or packages. You can create services that your webapp will consume in a single javascript file.

Getting started with electron

Basics

Out app file structure looks like this App

  • client
    • src
  • server
    • build
  • electron
    • package.json
    • main.js
    • preload.js

Client is a react (react router) app.
Client build step will deploy/copy the resulting files to the server's 'build' directory.

Server is a nodejs app. It servers the client webapp with express.static and provides a couple of /api endpoints.

In the electron dir we just need a simple package.json that descibes our native app.

{
  "name": "nativeapp",
  "version": "0.1.0",
  "private": true,
  "main": "main.js",
  "dependencies": {
	"node-sqlite3-wasm": "^0.8.11",
  }
}

The file main.js is the native app entry point.
In the appReady event we call createMainWindow. This will create a window with a WebView (Chromium browser).
And we load our debugging url localhost:3000.

const { app, BrowserWindow, screen: electronScreen, ipcMain, protocol } = require('electron');

const createMainWindow = () => {
	
	let mainWindow = new BrowserWindow({
		width: electronScreen.getPrimaryDisplay().workArea.width,
		height: electronScreen.getPrimaryDisplay().workArea.height,
		show: false,
		backgroundColor: 'white',
		webPreferences: {
			nodeIntegration: false,
			preload: path.join(__dirname, 'preload.js')
		}
	});

	const startURL = 'http://localhost:3000/';
	mainWindow.loadURL(startURL);

	mainWindow.once('ready-to-show', () => mainWindow.show());

	mainWindow.on('closed', () => {
		mainWindow = null;
	});
};

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

	createMainWindow();

	app.on('activate', () => {
		if (!BrowserWindow.getAllWindows().length) {
			createMainWindow();
		}
	});
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

We also have a preload.js which is executed within the webView when the webapp is first loaded.
This helps to setup the native vs webapp bridge.

Finally we need to install electron with npm install --save-dev electron.

Now start your webapp in debug mode. In our example it is running on localhost:3000.

In the electron folder run npx electron .

You should now see a window with your webapp.

Login

Now lets build the webapp for release. In our case it is
npm run build
which runs
CI=false BUILD_PATH=../server/build react-scripts build

The packaged webapp is now in server/build.

Now open electron/main.js and change the url.

const url = "file:///index.html"
window.loadURL(url);

And add a protocol interceptor that will add the correct path in our case __dirPath/../server/build.

const webDir = path.join(__dirname, "../server/build/")

	protocol.interceptFileProtocol('file', (request, callback) => {
		const url = request.url.substr(7).split("#")[0];    /* all urls start with 'file://' */

		const filePath = path.normalize(`${webDir}/${url}`);
		//console.log("REDIRECT: " + url + " -> " + filePath);
		callback({ path: filePath });
	}, (err) => {
		if (err) console.error('Failed to register protocol')
	});

An alternative would be to build the webapp without a fixed prefix. In that case you can setup the correct startURL and ignore the interceptor.

Run npx electron .
This time the app is loaded from disk and not the server.

Loading Data

The next step is to enable our webapp running as native to fetch data from the server.

  1. Setup cors policy on your server.
  2. Authentication will have to switch to auth headers instead of cookies
  3. All fetch() calls will have to provide a full url

On our nodejs server we add cors express middleware

import cors from "cors";
app.use(cors({ exposedHeaders: ["x-access-token"], origin: "*" }));

In our webapp we create a utility function, for detecting we are running under electron.

const isElectron = () => {
	return /electron/i.test(navigator.userAgent);
}

Example: if you call
fetch("/api/get_accounts/")
You will have to change it to:
fetch(base-server-url+"/api/get_accounts/", { headers:{"x-access-token": your-auth-token}})

It is best to create a fetch method that will do the right in thing in the right situation (online / electron).

Offline

There are many strategies you could employ, for example storing data in flat files as json or xml. We can use standard nodejs api to work with files.

An advanced strategy is to use a local sqlite database. For this we need an external module. we shall use a sqlite module that is pure js, thus we dont need to care about platform specifics. npm i node-sqlite3-wasm

No lets expose sqlite to our webapp.
In main.js appReady event we will add simple handlers.

let sDb: any;
const dataDir = app.getPath("userData");
const dbPath = path.join(dataDir, "database.db");

const ensureDb = () => {
	if (!sDb) {
		sDb = new Database(dbPath);
	}
}

app.whenReady().then(() => {
	ipcMain.handle('query', async (x, sql, values) => {
		ensureDb();
		const result = sDb.all(sql, values);
		return { values: result }; // our webapp requires this extra step. remove if not needed
	})
	ipcMain.handle('execute', async (x, cmd, inTransaction) => {
		ensureDb();
		const result = sDb.run(cmd);
		return result;
	})
	ipcMain.handle('delete', async (x) => {
		if (sDb) {
			sDb.close();
			sDb = null;
		}
		await deleteFile(dbPath);
		return true;
	})
	//.... rest of the code...
}

And we will need a simple preload.js file to make the handlers callable.

const { contextBridge, ipcRenderer } = require('electron/renderer')

contextBridge.exposeInMainWorld('electronAPI', {
	query: (sql, values) => ipcRenderer.invoke('query', sql, values),
	execute: (sql, arg2) => ipcRenderer.invoke('execute', sql)
})

Our webapp can now store and retrieve data from a sqlite database running completely offline.

How to integrate offline mode into your app, depends on your goals.

You could have a separate offline vs online experience.

You can cache fetch results in the local database and if offline return those (or error).

Finally you can create an app that works and looks the same in online and offline. That means for each call where you retrieve or store data on the server, you need to have an offline counterpart. And you will have to create a synchronization mechanism that will cache (some) of the server data and upload it changed while offline.

The massive benefit of this, is that the rest of your webapp does not need to care whether it is online or offline.

How big of a project this can be depends on your app. Basically how complex your data layer is, and how much logic lives on the server (which is out of the picture while offline).

Really it depends on your goals and priorities. Of course even readonly access to some data can be a massive improvement over no app at all.

Packaging and Shipping

There are multiple ways in which you can ship your electron app.
The official documentation recommends electron forge.

If your app will mostly be used internally, the manual packaging is really not that difficult.
For osx:

  1. take the electron app from official github repo or just copy it from node_modules/electron/dist/Electron.app
  2. In finder right click and Show Package Contents
  3. Navigate to Contents/Resources and create an App folder
  4. Copy your main.js, preload.js, package.js and webapp build directory here
  5. In main.js adjust the webDir path to point to the right place const webDir = path.join(__dirname, "./build/");
  6. Copy required node_modules to node_modules. In our case it is only the node-sqlite3-wasm.

For windows and linux, we only need to adjust step 2. In the folder with the electron.exe, create the Resources/App folder and copy the files as before.

That's it, our quick and dirty app is ready.

Now for a proper application, there are further steps required, like digital signature and installer. The steps are well documented, so we'll not repeat them here. Please follow the documentation.

Comming up next

In a future blog we will take a look at Capacitor and mobile apps.
We shall also explore how sqlite could be used offline in a webapp in browser.

Wrap up

The topic of native apps is very large. There are many approaches and lot's of emotions.
We haven't talked about react-native or proton.
Both allow you to use javasript/typescript, however you'll have to redo parts of your UI code.

If there is an area you would like to know more, please let me know.

Happy hacking!