March 28, 2024

From web to mobile

You want a mobile app.
And you have a great web app optimized for mobile web.
Or you are just planning your app strategy.
In both cases you should consider a single approach to web and mobile.
How? With Capacitorjs.

Mobile Apps

Motivation

In the previous blog post Native Apps we looked at why and how you can build native apps from web apps.
We talked about how Electronjs can turn your web app into apps for the desktop platforms, windows, osx and linux.

In this blog we will look at the same concept, but targeting mobile, iOS and Android.

How does it work

Let's recap how the magic of native app from web app works.

The mobile app we will build (and you can submit to the app stores) has these parts.

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

Let's look at each part in turn.

Native app project

These are project sources provided by capacitorjs and they are added automatically.
Since many of these files are auto-generated, please follow the documentation on how to customize them.

Why are they needed?
During the build process, capacitorjs will copy your web app files and plugins to the native project(s).
Afterwards you can simply open the native project in XCode or Android Studio and hit build to finish building the app.
Or debug the app on simulators/real phones.
There is a project directory for iOS and a separate directory for Android.

WebView

On iOS this is a WKWebView - mobile safari.
On Android this is a Chrome renderer.

Native Components

These are platform specific components. They live outside of the WebView and use the native OS api.
Therefore these components or plugins use the system native language (Swift/objc or Java/Kotlin).
Capacitor can automatically expose plugins into the WebView. The plugin author can also create custom javascript.

In contrast to Electron, you cannot create plugins in Javascript (yet?).

Here you can explore the available plugins.

As you can see in the list, the popular ones are those that provide tight platform integration.
Or introduce features not available in the mobile web world, such as

  • Push Notifications
  • Local Notifications
  • Dialog
  • Status Bar

Custom native code

In order to customize the native part of the app, you have two options.

  1. Create your own plugin
  2. Customize the native code code

Recipe

Now we are ready to look at some code.
In the following sections we will show the code needed to create a native iOS app from a web app.

Create web app

This section assumes nothing and we will start from scratch. That means we will create a brand new SPA web app using vite and react.
Then turn this webapp into a mobile app.

If you already have a web app ready, feel free to skip to the Add capacitor section.

We will use vite, the bundler used by the cool kids these days.
Sadly CRA (create react app) is no longer maintained:(

npm create vite@latest

We chose react and typescript. Now typescript is not strictly required, feel free to stick with vanilla JS.
But, since we want the app to run locally, even with limited or no internet, we have to choose a true client-side framework.
Again, nothing wrong with plain JS. But you can use React, Vue or any other SPA framework.

Next, we will add a simple login screen and a list screen.
For this example we'll use the Inuko.net backend service. Feel free to replace with your favorite service.

Finally we'll use the name webapp for our new app.

Running our app is a single command npm run dev.
To create a package we can deploy, the command is npm run build.
Vite will, by default, build the app and put the results in to the dist folder. Check the webapp/dist folder for the generated files.

Add capacitor

In the webapp folder, we will add capacitor using npm again.

npm i @capacitor/core

For development we need the capacitor CLI (console).

npm i -D @capacitor/cli

To initialize our capacitor app, we just have to run init. Remember the webapp is built in the dist folder.

npx cap init

The next command will take the webapp dist files and copy them to the capacitor project.

npx cap sync

Now we are ready to add the mobile platforms we want to support.

npm install @capacitor/ios npx cap add ios

Now we need to open XCode to actually build and test our new iOS app!.

npx cap open ios

Finally in XCode hit the build or run buttons to see your app in action!

Debugging

A trully great feature of capacitor os the ability to serve the web app files from network.
Obviously you don't want this for the production build, but it will make your dev experience sooo much better.

Simply add the following to the capacitor config file capacitor.config.js bundledWebRuntime: false,

{
	"appId": "com.native.app",
  	"appName": "NativeApp",
  	"webDir": "webapp/dist",
	"server": {
    	"url": "http://localhost:3000/",
    	"cleartext": true
  	}
}

Adding Offline

To add a local database to your project is only a couple of steps.
Keeping the local copy in sync with your cloud database is a much more complicated.
Let us know if you need help in this area.

We will add SQLite with this command: npm install @capacitor-community/sqlite Then we will sync capacitor, to copy the plugin to the native project(s). npx cap sync

Now we can access SQLite in our webapp.

Some interface definitions:

export interface SqlParam {
	name: string;
	type: string;
	value: any;
}

export interface IQueryResult {
	values?: any[];
}

export interface IDbConnection {
	execute(cmd: string, inTransaction: boolean): Promise<any>;
	query(sql: string, values: any[]): Promise<IQueryResult>;
	delete(): Promise<any>;
}

Create the connection

import { CapacitorSQLite, SQLiteDBConnection, SQLiteConnection } from '@capacitor-community/sqlite';

export const createSQliteConnection = async (database: string) => {
	const sqlitePlugin: any = CapacitorSQLite;
	const sqlite = new SQLiteConnection(sqlitePlugin);
	const ret = await sqlite.checkConnectionsConsistency();
	const isConn = (await sqlite.isConnection(database, false)).result;
	var db: SQLiteDBConnection
	if (ret.result && isConn) {
		db = await sqlite.retrieveConnection(database, false);
	} else {
		db = await sqlite.createConnection(database, false, "no-encryption", 1, false);
	}
	if (db) {
		await db.open();
		return db;
	}
	throw new Error("Failed to open sql connection")
}

Database class:

export class DatabaseConnection {

	public async connect() {
		this.dbConnection = await createSQliteConnection("database.sqlite");
	}

	public async beginTransaction() {
		await this.dbConnection?.execute("BEGIN TRANSACTION;",false);
	}
	public async finishTransaction(commit: boolean) {
		await this.dbConnection?.execute(commit ? "COMMIT;" : "ROLLBACK;",false);
	}

	public async executeNonQuery(sql: string, values: SqlParam[]) {
		return this.dbConnection?.query(sql, values.map(v => v.value));
	}

	public async insertRow(tableName: string, values: SqlParam[]) {
		const sql = "INSERT INTO " + tableName + " ( " +
			values.map((v) => v.name).join(",") + " ) VALUES (" +
			values.map((v, i) => "?").join(",") + ")";
		return this.executeNonQuery(sql, values);
	}

	public async updateRowWithId(tableName: string, id: SqlParam[], values: SqlParam[]) {
		const sql = "UPDATE " + tableName + " SET " + values.map((v, i) => v.name + "=?").join(", ");
		return this.executeNonQueryWhere(sql, id, values);
	}

	public async deleteRowWithId(tableName: string, id: SqlParam[]) {
		const sql = "DELETE FROM " + tableName;
		return this.executeNonQueryWhere(sql, id, []);
	}

	private async executeNonQueryWhere(sql: string, conds: SqlParam[], values: SqlParam[]) {

		values = values.slice() || [];
		if (conds && conds.length > 0) {
			sql += " WHERE ";
			for (let i = 0; i < conds.length; i++){
				if (i > 0)
					sql += " AND "
				const id = conds[i];
				sql += id.name + "=?";
				values.push(id);
			}
		}
		return this.executeNonQuery(sql, values);
	}
}

Login screen

A trivial form that will ask the user for their instance name, email and password.
We use React, but I'm sure you can see what's going on.

import { useState } from "react";

export interface ILoginInfo {
	organization: string;
	email: string;
	password: string;
}

export const LoginForm = (props: { disabled?: boolean, onLogin: (info: ILoginInfo) => void }) => {

	const [login, setLogin] = useState({} as ILoginInfo);

	const updateLogin = (propName: string, e: React.ChangeEvent<HTMLInputElement>) => {
		setLogin(x => ({ ...x, [propName]: e.target.value }));
	}

	return <fieldset disabled={props.disabled} style={{display:"flex", flexDirection:"column"}}>
		<label>
			<span>organization</span>
			<input value={login.organization} onChange={e => updateLogin("organization", e)} />
		</label>
		<label>
			<span>email</span>
			<input value={login.email} onChange={e => updateLogin("email", e)} />
		</label>
		<label>
			<span>password</span>
			<input type="password" value={login.password} onChange={e => updateLogin("password", e)} />
		</label>
		<button disabled={!login.organization || !login.email || !login.password} onClick={_ => props.onLogin(login)}>
			{props.disabled ? "Signing you in..." : "Sign in"}
		</button>
	</fieldset>
}

Listing

The code below will simply load the list of accounts from the cloud and display them.
Again, we use React. The useEffect code will load the list of accounts from the cloud.
Then the accounts names are rendered within a bunch of DIVs.

import { useEffect, useState } from "react"
import { fetchJson } from "./services/fetchJson";


export const ListForm = () => {

	const [rows, setRows] = useState([] as any[]);

	useEffect(() => {
		const loader = async () => {
			try {
				const data = await fetchJson("/api/fetch", { entity: { name: "account", allattrs: true } });
				setRows(data);
			}
			catch (e) {
				alert(e);
			}
		}
		loader();
	}, []);

	return <div>
		<div>
			List of Accounts
		</div>
		<div>
		{rows.map(x => {
			return <div>{x.name}</div>
		})}
		</div>
	</div>
}

Server API

The last thing we need is a simple wrapper that will call the authenticated cloud api.


export class NetworkError extends Error {
	constructor(msg: string, status: number, statusText: string) {
		super(msg);
		this.status = status;
		this.statusText = statusText;
	}
	status: number;
	statusText: string;
}

const AUTHTOKEN = "x-accessptoken";
let BASE_URL = "https://inuko.net"
let authToken = "";

export const isAuthenticated = () => { return !!authToken };

export const fetchJson = async (url: string, q: any, parseResult: boolean = true) => {
	const opts = {
		headers: {
			[AUTHTOKEN]: authToken,
			'Accept': 'application/json',
			'Content-Type': 'application/json'
		},
		method: "GET"
	}
	if (q) {
		opts.method = "POST";
		(opts as any).body = JSON.stringify(q);
	}
	const resp = await fetch(BASE_URL + url, opts);
	if (!resp.ok) {
		let message = resp.statusText;
		try {
			message = (await resp.text()) || resp.statusText || "";
		}
		catch {}
		throw new NetworkError("Server Error: " + message, resp.status, message);
	} else {
		if (BASE_URL)
			authToken = resp.headers.get(AUTHTOKEN) || "";
		if (parseResult)
			return await resp.json();
		else
			return resp.text();
	}
}

export const loginToServer = async (organization: string, email: string, password: string) => {
	const data = new URLSearchParams();
	data.append("username", email);
	data.append("password", password);
	data.append("organization", organization);
	const resp = await fetch(BASE_URL + "/api/login", {
		method: "POST",
		body: data
	});
	if (resp.ok) {
		authToken = resp.headers.get(AUTHTOKEN) || "";
		// if (isElectron()) {
		// 	window.localStorage.setItem("electronLogin", JSON.stringify({ organization: organization, email: email, password: password }));
		// }
	}
	return resp;
}

Wrap up

I hope I conviced you that there is nothing mistical, magical or difficult about native apps.
At least technically.

How you integrate native apps into your larger solution?
How to tackle online vs offline experience?

These are the truly difficult questions.
Especially since there is no one size fits all solutuion.

But, we will gladly help you find your answers.

Happy hacking!