Building Web, Desktop and Mobile apps from a single codebase using Angular

From monoliths to monorepos, every application architecture has its own advantages and disadvantages. Monorepo-style development is an approach where you develop multiple projects in the same repository.

Projects in a monorepo can depend on each other. This allows code-sharing between projects. For instance, you can define a single interface which the frontend and backend teams can use.

Code changes in one project do not necessarily force all other projects to be rebuilt. You only rebuild or retest the projects affected by the code change. As a result, your Continuous Integration pipeline is faster, giving teams working in a monorepo more independence.

This article will show how we can organize our code in a monorepo-style architecture and build a simple (yet extensible) Angular application that targets several platforms including iOS, Android and Desktop (Windows, Linux and macOS).

Why Angular?

Angular provides tooling which allows you to build features quickly with simple declarative templates. This article assumes basic knowledge of Angular and its best practices. If you have never used it before, I encourage you to try it out.

Much has been written about Angular and other JavaScript frameworks, however, I have had a great developer experience building applications using Angular. I have given a talk about why Angular has been my framework of choice; you can watch it here.

The following is an excerpt from the Angular.io docs – “Learn one way to build applications with Angular and reuse your code and abilities to build apps for any deployment target. For web, mobile web, native mobile and native desktop”. This pretty much sums up the intent of this article.

Why Electron?

Electron is a framework that enables developers to create desktop applications with JavaScript, HTML, and CSS. These applications can then be packaged to run directly on macOS, Windows, or Linux, or distributed via the Mac App Store or the Microsoft Store.

Typically, you create a desktop application for an operating system (OS) using each operating system’s specific native application frameworks. Electron makes it possible to write your application once using technologies that you already know.

Why Capacitor?

Capacitor is an open source native runtime for building web native apps. It allows you to create cross-platform iOS, Android, and Progressive Web Apps (PWAs) with JavaScript, HTML, and CSS. Also, it provides access to the full Native SDKs on each platform, so you can deploy to the App Stores while still able to target the web.

You may have heard of Cordova and wondering why we are not using it. The answer is simple, I wanted to try out a technology that I had never used before. Also, Capacitor supports Cordova plugins, so you are not limited in that sense. You can read more about the official differences according to Capacitor.js creators here.

Introduction to the sample application (and tools)

The sample application is a multi-project architecture Angular-CLI application. We will go through each step of weaving together all the target platforms starting with Electron, then Android and finally iOS. This might be useful if you have an existing Angular application as you will see where and how you can add support for other platforms in your application.

If you are starting a project from scratch or do not care about the details of how the different platforms are put together and just want to see the end result (or fork the project), here is the git repo.

In any case, I encourage every reader to go through the details as it might come in handy down the line when your app is all grown up and you have to debug it.

Why should I care?

Betty Eats Carrots And Uncle Sells Eggs – a mnemonic a first grader might be taught to memorize how to spell the word “because”. Prompts or memory aids can be handy especially when learning something new. The more cues you have for remembering things the better and faster you will acquire new knowledge.

In this article, I will show how we can use our Angular knowledge to build a game application using the Angular framework. As a default, you will be able to play the game on the web. We will add Electron to have the game installable on a computer. Lastly, we will enable our game to be deployable on a mobile device for maximum user reach. All this from a single (and simple) repository.

As an aside, if you are interested in game development with Angular, you can check out this article, which our game application is based on. I will not dwell in the details of how the game is built as the article goes in-depth and shows you how to build one from scratch. Our focus will be on how to stitch together disparate technologies to help us, not only to understand all the moving parts, but also enable us to target as many platforms as possible. With that said, let’s get started!

Setting up the project

First, let us start by globally installing Angular-CLI:

<>npm install -g @angular/cli

‌Since we plan to have multiple applications in the workspace, we create an empty workspace using ng new and set the –createApplication option to false:

<>ng new cross-platform-monorepo --createApplication=false

Then add the first Angular application to the workspace:

<>ng generate application tetris

The above allows a workspace name to be different from the initial app name, and ensures that all applications (and libraries) reside in the /projects subfolder, matching the workspace structure of the configuration file in angular.json.

Add the second Angular application (placeholder) to the workspace:

<>ng generate application tetris2

We want to reuse code in our applications, we can take advantage of the Angular multi-project architecture and create a library project which will hold the game engine logic.

<>ng generate library game-engine-lib

By separating the shell parts (apps) from the logic parts (libs), we ensure that our code is manageable, reusable and our projects can be easily extended and shared with other dev teams.‌‌

Now that we have the core structure of our game application, the next thing is to add the code for our game.‌‌

As mentioned above, the game is based on this article, which also contains a link to the GitHub repository which you can find here. I have modified the code a bit to demonstrate the use of native APIs (e.g. file system access) both on desktop and mobile apps. More on this later.‌‌

Using libraries in Angular apps‌‌

Angular framework allows us to easily build npm libraries. We do not have to publish a library to the npm package manager to use it in our own apps, however, we cannot use a library before it is built so let us do that now.‌‌

Note: I use the terms “lib” and “library” interchangeably – both refer to an Angular library as described here

In the terminal of your choice, run this command:

<>ng build game-engine-lib

If the operation was successful, you should see an output like the one below:

Graphical user interface, text  Description automatically generated

‌To make our lives a little easier, let’s add some scripts to the package.json file:

<>"scripts": {
    "ng": "ng",
    "start:tetris": "ng serve tetris -o",
    "build:tetris": "ng build tetris",
    "test:tetris": "ng test tetris",
    "lint:tetris": "ng lint tetris",
    "e2e:tetris": "ng e2e tetris",
    "start:tetris2": "ng serve tetris2 -o",
    "build:game-engine-lib": "ng build game-engine-lib --watch",
    "test:game-engine-lib": "ng test game-engine-lib",
    "lint:game-engine-lib": "ng lint game-engine-lib",
    "e2e:game-engine-lib": "ng e2e game-engine-lib"
  }

Lastly, we will use TypeScript path mapping for peer dependencies to reference our lib within our apps.‌‌

In the root tsconfig.json inside compilerOptions, modify the code as follows:

<>"paths": {
      "@game-engine-lib": ["dist/game-engine-lib"]
}

Note: I prefer to add “@” in front of the library name to easily distinguish it from local file imports.

In the game-engine-lib.service.ts file, add the following getter:

<>get testing(): string {
    return "GameEngineLibService works!";
  }

Each time we make changes to a lib, we need to rebuild it – alternatively, we can use the –watch flag to automatically do so on file save.‌‌

Let’s rebuild the lib using one of the scripts we have just added:

<>npm run build:game-engine-lib

Now let us test whether or not we are able to consume the exports specified in the public-api.ts file.‌‌

In the app.module.ts of the tetris app, import the lib to make it available tetris app-wide:

<>import {GameEngineLibModule} from "@game-engine-lib";

‌‌Then add the lib module to the imports array under @NgModule of the same file:

<>imports: [GameEngineLibModule]

Add the following code in app.component.ts of the tetris app:

<>constructor(private engineService: GameEngineLibService) {
    console.info(engineService.testing);
  }

Finally, in your terminal, serve the tetris app using one of the scripts we added earlier:

<>npm run start:tetris

After the app is compiled and the browser window opened, you should see the following:‌‌

Graphical user interface, text  Description automatically generated with medium confidence

‌‌‌Pat yourself on the back, stretch your legs and when you are ready, let us continue to the fun(ky) parts.‌‌‌

Note: The following section involves moving files from the tetris repo to our monorepo. If you feel lost, compare your file structure with that of the finished project.

Adding game code‌‌

Since we are working in a multi-project repository, we need to re-organize the game code a bit. The “utility” parts of the code will go into the library and the “shell” will be the application project (tetris folder). We will leave the tetris2 app as is for the time being. ‌‌

To keep our code well-organized, let’s create a components sub-folder inside the lib folder (i.e. projects/game-engine-lib/src/lib):

<>ng g c components/board // add --dry-run and ensure files are created in the correct folder

Next, in the same lib directory, create a piece folder and rename the GameEngineLibComponent classto Piece:‌‌

Copy the contents of board (.ts and .html) and piece (.ts) files from the tetris repo to the board and piece components of our monorepo respectively. Grab the constants.ts file and add it to projects/game-engine-lib/src/lib directory.

Copy the game.service.ts contents into game-engine-lib.service.ts (rename GameService to GameEngineLibService)Adjust all imports accordingly and install the ng-zzfx npm package. ‌‌

We have a few more adjustments before we can test our game.‌‌

In the GameEngineLibModule of our libadd the following code:

<>import {CommonModule} from "@angular/common";

@NgModule({
  declarations: [BoardComponent],
  imports: [CommonModule], // Contains the basic Angular directives (i.e. NgIf, NgForOf etc) 
  exports: [BoardComponent],
})

Lastly, expose the Board component in the public API surface of our game-engine-lib so it can be consumed by the apps:

<>export * from "./lib/components/board/board.component"

Our code structure should now look like this:

Text  Description automatically generated

Now we are ready to use the game engine logic in the tetris app (or any other app you might decide to add at a later stage).‌‌

In the tetris app (i.e. /projects/tetris/src/app), replace the placeholder code with the following:

app.component.html:

<><game-board></game-board>

Do not forget to copy and paste the styles.scss file content to its equivalent as well.‌‌

Now let us use one of our scripts to build the lib one more time (if not already running with –watch flag) and test if everything works:

<>npm run build:game-engine-lib

Then fire up the tetris game (npm run start:tetris). If all worked out fine, you should see the below when your browser opens:

Chart, waterfall chart  Description automatically generated

I must admit, I did get carried away and spent a significant amount of time not writing this article but playing Tetris ☺‌‌

Unlike me, you are a serious software developer and do not easily get distracted. Very well then, let us move on to our first platform integration – Electron.js.‌‌

Integrating Electron into the workspace‌‌

If you were able to complete all the parts up to this point, it means you meet the requirements for installing Electron.js. The reason for this is that from a development perspective, an Electron application is essentially a node.js application. Angular requires node.js/npm to run. To have an in-depth idea of how to setup a standalone Electron app, have a look here.‌‌

For our tetris game app, we need to first install Electron.js

<>npm install --save-dev electron@11.0.5

An Electron app uses the package.json file as its main entry point (as any other node.js app). So let us modify the package.json file as follows:

<>{
...
"name": "cross-platform-monorepo",
 "version": "0.0.0",
 "description": "Cross-platform monorepo Angular app",
 "author": {       // author and description fields are required for packaging (electron-builder)
    "name": "your name",
    "email": "your@email.address"
  },
 "main": "main.js", // Electron entry-point
...
}

If you have written a lot of Angular code or worked in large codebases like I have, you would know how indispensable TypeScript (TS) is, so let us create a main.ts file instead of writing error-prone pure JavaScript (JS) code. When we build the tetris app, the main.ts code will be transpiled to JS code by the TS compiler (tsc). The output of this process will be the main.js file. This is what gets served to Electron.‌‌

Create the main.ts file and fill it with the following code:

<>import { app, BrowserWindow, screen } from "electron";
import * as path from "path";
import * as url from "url";

let win: BrowserWindow = null;
const args = process.argv.slice(1),
  serve = args.some((val) => val === "--serve");

function createWindow(): BrowserWindow {
  const electronScreen = screen;
  const size = electronScreen.getPrimaryDisplay().workAreaSize;

  // Create the browser window:
  win = new BrowserWindow({
    x: 0,
    y: 0,
    width: size.width,
    height: size.height,
    webPreferences: {
      nodeIntegration: true,
      allowRunningInsecureContent: serve ? true : false,
      contextIsolation: false, // false if you want to run e2e tests with Spectron
      enableRemoteModule: true, // true if you want to run e2e tests with Spectron or use remote module in renderer context (i.e. Angular apps)
    },
  });

  if (serve) {
    win.webContents.openDevTools();

    require("electron-reload")(__dirname, {
      electron: path.join(__dirname, "node_modules", ".bin", "electron"),
    });
    win.loadURL("http://localhost:4200");
  } else {
    win.loadURL(
      url.format({
        pathname: path.join(__dirname, "dist/index.html"),
        protocol: "file:",
        slashes: true,
      })
    );
  }

  // Emitted when the window is closed.
  win.on("closed", () => {
    // Deference from the window object, usually you would store window
    // in an array if your app supports multi windows, this is the time
    // when you should delete the corresponding element.
    win = null;
  });

  return win;
}

try {
  // This method will be called when Electron has finished
  // initialization and is ready to create browser windows.
  // Some APIs can only be used after this event occurs.
  // Added 400ms to fix the black background issue while using a transparent window.
  app.on("ready", () => setTimeout(createWindow, 400));

  // Quit when all windows are closed.
  app.on("window-all-closed", () => {
    // On OS X it is common for applications and their menu bar
    // to stay active until the user quits explicitly with Cmd + Q
    if (process.platform !== "darwin") {
      app.quit();
    }
  });

  app.on("activate", () => {
    // On OS X it's common to re-create a window in the app when the
    // dock icon is clicked and there are no other windows open.
    if (win === null) {
      createWindow();
    }
  });
} catch (e) {
  // handle error
}

Let’s now add a couple more npm scripts to help us with the compilation and serving of the Electron app:

<>{
...
"start": "npm-run-all -p electron:serve start:tetris",
"electron:serve-tsc": "tsc -p tsconfig.serve.json",
"electron:serve": "wait-on tcp:4200 && npm run electron:serve-tsc && npx electron . --serve"
...
}

As you can see from the above scripts, there are a few files and packages we need to create for this to work properly. Let’s go ahead and do that now.‌‌

Firstly, add the following npm packages:

<>npm install wait-on // wait for resources (e.g. http) to become available before proceeding
npm install electron-reload // load contents of all active BrowserWindows when files change
npm install npm-run-all // run multiple npm-scripts in parallel

Then create a tsconfig.serve.json file in the root directory and top it up with this code:

<>{
  "compilerOptions": {
    "sourceMap": true,
    "declaration": false,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "target": "es5",
    "types": [
      "node"
    ],
    "lib": [
      "es2017",
      "es2016",
      "es2015",
      "dom"
    ]
  },
  "files": [
    "main.ts"
  ],
  "exclude": [
    "node_modules",
    "**/*.spec.ts"
  ]
}

Ok, that’s it – let us take it for another spin. If all is good, we should be able to play the tetris game, running on desktop.‌‌

Use the script we added earlier:

<>npm start

‌  

Chart, waterfall chart  Description automatically generated

Congratulations! We have an Electron desktop app running with hot module reload!‌‌

Before we jump into the next section, let us tidy up the code and create some helper services to give us a convenient way to communicate between Electron and Angular. Also, we want to package the game into an installable binary for the different operating systems. This is where electron-builder comes into play.‌‌

First, update the Electron main.ts file:

<>win.loadURL(
      url.format({
        pathname: path.join(__dirname, "dist/tetris/index.html"), // add “/tetris” in the path
        protocol: "file:",
        slashes: true,
      })
    );

Next, in the root directory, create a electron-builder.json file and add this content:

<>{
  "productName": "name-of-your-app",
  "directories": {
    "output": "release/"
  },
  "files": [
    "**/*",
    "!**/*.ts",
    "!*.code-workspace",
    "!LICENSE.md",
    "!package.json",
    "!package-lock.json",
    "!src/",
    "!e2e/",
    "!hooks/",
    "!angular.json",
    "!_config.yml",
    "!karma.conf.js",
    "!tsconfig.json",
    "!tslint.json"
  ],
  "win": {
    "icon": "dist/tetris/assets/icons",
    "target": ["portable"]
  },
  "mac": {
    "icon": " dist/tetris/assets/icons",
    "target": ["dmg"]
  },
  "linux": {
    "icon": " dist/tetris/assets/icons",
    "target": ["AppImage"]
  }
}

Now let’s install electron-builder using the terminal:

<>npm i electron-builder -D

In the package.json file, add the respective scripts for packaging the game:

<>{
...
"postinstall": "electron-builder install-app-deps", 
"build": "npm run electron:serve-tsc && ng build tetris --base-href ./",
"build:prod": "npm run build -- -c production",
"electron:package": "npm run build:prod && electron-builder build"
...
}

That’s it! Build and package the application using npm run electron:package command and depending on your operating system, you will get an installer (in the newly created /release folder) for Linux, Windows or macOS with “auto update” support out of the box!‌‌

This is what it looks like on macOS:‌‌

Angular-Electron Communication‌‌

We cannot directly access all of Electron’s APIs from the Angular app. To easily communicate between Electron and Angular, we need to make use of Inter-Process Communication (IPC). It is a mechanism the operating system provides so that two different processes (i.e. from main process to browser process and vice versa) can communicate with each other. ‌‌

Let’s create a service in the projects/tetris/src/app directory to facilitate this inter-process communication:

<>ng generate module core
ng generate service core/services/electron

Add the following code inside the newly created file (i.e. electron.service.ts):

<>import { Injectable } from "@angular/core";
import { ipcRenderer, webFrame, remote } from "electron";
import * as childProcess from "child_process";
import * as fs from "fs";

@Injectable({
  providedIn: "root",
})
export class ElectronService {
  ipcRenderer: typeof ipcRenderer;
  webFrame: typeof webFrame;
  remote: typeof remote;
  childProcess: typeof childProcess;
  fs: typeof fs;

  get isElectron(): boolean {
    return !!(window?.process?.type);
  }

  constructor() {
    if (this.isElectron) {
      this.ipcRenderer = window.require("electron").ipcRenderer;
      this.webFrame = window.require("electron").webFrame;

      // If you want to use remote object, set enableRemoteModule to true in main.ts
      // this.remote = window.require('electron').remote;

      this.childProcess = window.require("child_process");
      this.fs = window.require("fs");
    }
  }
}

Finally, pull in the above electron service in the app.module.ts file:

<>imports: [BrowserModule, GameEngineLibModule, CoreModule]

And consume it in the app.component.ts file (or any other file in the projects):

<>export class AppComponent {
  title = "tetris";
  constructor(private electronService: ElectronService) {

    if (electronService.isElectron) {
      console.log("Run in electron");
      console.log("Electron ipcRenderer", this.electronService.ipcRenderer);
      console.log("NodeJS childProcess", this.electronService.childProcess);
    } else {
      console.log("Run in browser");
    }
  }
}

Whoa that was a mouthful. Look back at what we have done – with the above setup, you have the power to go wild and use your Angular skills to build apps like VS Code, Slack, Twitch, Superpowers and all kinds of apps you can imagine, and distribute them to the most popular desktop platforms.‌‌

With that said, let us jump into the last platform integration – Mobile.‌‌

Integrating iOS and Android into the workspace‌‌

For integrating mobile platform support into our workspace, we are going to use Capacitor.js. It is an open source native runtime for building web native apps and creating cross-platform iOS, Android, and Progressive Web Apps using Angular or any other modern web framework or library.‌‌

Like many other node.js/npm based technologies, we first have to install the package. There are other pre-requisites that you should comply with before you can proceed.‌‌

Once you have installed the above dependencies, run the following script in the root directory:

<>npm install @capacitor/core@2.4.5 @capacitor/cli@2.4.5

Then, initialize Capacitor with our app data:

<>npx cap init // npx is a utility that executes local binaries or scripts to avoid global installs.

Follow the terminal prompts to completion. Once done, you should see the following:‌‌

Text  Description automatically generated

‌‌And a new file (i.e. capacitor.config.json) will be created with the following content:

<>{
  "appId": "com.tetris.game",
  "appName": "cross-platform-game",
  "bundledWebRuntime": false,
  "npmClient": "npm",
  "webDir": "www",
  "plugins": {
    "SplashScreen": {
      "launchShowDuration": 0
    }
  },
  "cordova": {}
}

Lastly, let us add the platforms of our choice. We will first target Android:

<>npx cap add android

If you run the above command, you will likely get the below error:

<>Error: Capacitor could not find the web assets directory "/path/to/your/root/repo/www"

To fix this, simply adjust capacitor.config.json as such:

<>{
...
"webDir": "www", // replace www with “dist/tetris”
...
}

Capacitor works on a three-step build process: First, your web code is built (if necessary). Next, the built web code is copied to each platform. Finally, the app is compiled using the platform-specific tooling. There is a recommended developer workflow that you should follow.‌‌

Run the script again:

<>npx cap add android
Graphical user interface, text, application  Description automatically generated

‌‌After Android has been successfully added, you should see a bunch of android specific files (inside the newly created android folder). These files should be part of version control. So add and commit all of them to git.‌‌

Capacitor relies on each platform’s IDE of choice to run and test your app. ‌‌

Therefore, we need to launch Android Studio to test our game. To do so, simply run:

<>npx cap open android

Once Android Studio opens, you can build, emulate or run your app through the standard Android Studio workflow.‌

‌‌The details of Android Studio workflow and Android Studio are outside the scope of this article, however, there are many resources you can check out to assist you in that regard.‌‌

As a final point, let us do the same as above to add iOS platform support:

<>npx cap add ios
Text  Description automatically generated

‌‌Just like we saw when adding Android, after iOS has been successfully added, you should see a bunch of iOS specific files (inside the newly created ios folder). These files should be added to source control.‌‌

To start Xcode and build the app for the emulator, run:

<>npx cap open ios

After you build and run the app in Xcode, you should see the below:‌‌

A picture containing graphical user interface  Description automatically generated

‌‌Capacitor will package your app files and hand them over to Xcode. The rest of the development is up to you. ‌‌

The beauty of Capacitor is that it features a native iOS bridge that enables developers to communicate between JavaScript and Native Swift or Objective-C code. This means you have the freedom to author your code by using the various APIs available, Capacitor or Cordova plugins, or custom native code to build out the rest of your app.‌‌

As mentioned in the Capacitor developer workflow, each time we build a project, we need to copy the app assets into their respective mobile platform folders. Let us add some scripts to automate that:‌‌

package.json

<>{
...
"copy-android": "npx cap copy android",
"copy-ios": "npx cap copy ios",
"open:android-studio": "npx cap open android",
"open:xcode": "npx cap open ios",
"add-android": "npx cap add android",
"add-ios": "npx cap add ios"
...
}

Cleaning up‌‌

Keeping with the theme of simple and clean multi-project architecture, and since we now have another platform to maintain, it makes sense to create a new Angular library to hold all the services, components, directives, etc. that are common across the platforms:‌‌

Go ahead and create the lib:

<>ng g library shared-lib // use –dry-run to ensure your files are in the correct folder
Text  Description automatically generated

‌‌As with the previous lib, we have to build it before it can be used – add a script to do so and then run it:

<>{
...
"build:shared-lib": "ng build shared-lib --watch"
...
}
Graphical user interface, text, application  Description automatically generated

‌‌In tsconfig.json under compilerOptions, add:

<>"paths": {
      "@game-engine-lib": [ 
        "dist/game-engine-lib"
      ],
      "@shared-lib": [               // the newly created lib
        "dist/shared-lib"
      ]
    }

Move all services from projects/tetris/src/app/core/services to projects/shared-lib/src/lib/services and make sure to export the classes via the lib’s public API (i.e. public-api.ts)‌‌

Lastly, let’s add a new service which we will need in the next section:‌‌

Run the following command in projects/shared-lib/src/lib/services folder:

<>ng g s /capacitor/capacitorstorage

And add this code:

<>import { Injectable } from "@angular/core";
import { Plugins } from "@capacitor/core";

const { Storage } = Plugins;

@Injectable({
  providedIn: "root",
})
export class CapacitorStorageService {
  constructor() {}

  async set(key: string, value: any): Promise<void> {
    await Storage.set({
      key: key,
      value: JSON.stringify(value),
    });
  }

  async get(key: string): Promise<any> {
    const item = await Storage.get({ key: key });
    return JSON.parse(item.value);
  }

  async remove(key: string): Promise<void> {
    await Storage.remove({
      key: key,
    });
  }
}

With that done, we are now ready to use the shared-lib anywhere in the projects.‌‌

Warning: libs can import other libs, however, avoid importing services, modules, directives, etc. defined in the projects’ apps into libs. This often leads to circular dependencies which are hard to debug.‌‌

Tying it together‌‌

We are almost at the finish line. Let us import the CapacitorStorageService inside the game-engine-lib, specifically, inside the board.component.ts file:

<>import { CapacitorStorageService } from "@shared-lib";
  constructor(
    private capStorageService: CapacitorStorageService
  ) {}

We want to persist the highscore after a webpage refresh or when we reopen the app on mobile phones or desktop, so modify these methods as follows:

<>async ngOnInit() {
    const highscore = await this.localStorageGet("highscore");  // newly added
    highscore ? (this.highScore = highscore) : (this.highScore = 0);  // newly added
    this.initBoard();
    this.initSound();
    this.initNext();
    this.resetGame();
  }

gameOver() {
   …
    this.highScore = this.points > this.highScore ? this.points : this.highScore;
    this.localStorageSet("highscore", this.highScore);  // newly added
    this.ctx.fillStyle = "black";
   …
}

Also, add the localStorageSet and localStorageGet methods inside the board.component.ts file:

<> async localStorageGet(key: string): Promise<any> {
    return await this.capStorageService.get(key);
  }

  localStorageSet(key: string, value: any): void {
    this.capStorageService.set(key, value);
  }

LocalStorage is considered transient, meaning your app can expect that the data will be lost eventually. The same can be said for IndexedDB at least on iOS. On Android, the persisted storage API is available to mark IndexedDB as persisted.

Capacitor comes with a native Storage API that avoids the eviction issues above, but it is meant for key-value store of simple data. This API will fall back to using localStorage when not running on mobile. Hence localStorage works for our webApp, Electron as well as mobile platforms.‌‌

End of the road‌‌

The final workspace file structure and npm scripts should look like this – clean and simple:

Text  Description automatically generated

‌‌Here are all the platforms running at the same time:‌‌

Graphical user interface  Description automatically generated

‌Well done! You have made it to the end of the article. Take a deep breath and marvel at your creations ☺

Recap‌‌

We have seen why and how to create a monorepo-style workspace using Angular. We have also seen how effortless it is to add support for platforms other than the web. What started out as a seemingly insurmountable amount of work turned out to be quite an enjoyable journey. ‌‌

We have only scratched the surface of what the web can do today and by extension, as I have demonstrated in this article – what you as a developer can do to reach as many users as possible on many devices of their choice.‌‌

If you are new to software development, I hope this article has sparked your interest and makes you eager to go out there and discover more. If you are in the veteran club, I also hope this piece has inspired you and that you will share this newly acquired knowledge (and the article) with your fellow developers and teams.‌‌

Acknowledgements‌‌

Thank you for taking the time to go on this journey with me, I highly appreciate your feedback and comments. I would also like to thank my colleagues who continue to inspire me with their sheer technical skills as well as their humbleness. To the reviewers (Agnieszka, Andrej, Diana, Hartmut, Игорь Кацуба, Max, ‌‌Nikola, René, Stacy,  Torsten, Wiebke) of this article – a big shout out and thank you for all your input.‌‌

What is next?‌‌

Try out Nx devtools (out of the box tooling) for monorepos. There is also NestJS – a backend integration which works well with the current tech stack i.e. Angular + Nodejs. Remember we also created a tetris2 project placeholder? Go ahead and fill that out with the next version of tetris i.e. make it look “pretty” and playable, for example, using native key gestures – as they say, the sky is the proverbial limit.‌‌

About the article author‌‌

Richard Sithole is a passionate frontend developer at OPTIMAL SYSTEMS Berlin where he leads efforts to build, maintain and extend a feature-rich propriety Enterprise Content Management software called enaio® webclient. Previously he worked for one of the largest banks in Africa where he focused on full-stack development, application architecture, software developer hiring and mentoring. Say “hallo” to him on twitter @sliqric

Inspirational sources‌‌

  1. Bootstrap and package your project with Angular and Electron – Maxime Gris
  2. Desktop Apps with Electron and Angular – Jeff Delaney
  3. Why we’re using a single codebase for GitLab Community and Enterprise editions
  4. Angular and Electron – More than just a desktop app with Aristeidis Bampakos
  5. Give Your Angular App Unlimited Powers with Electron – Stephen Fluin
  6. Capacitor Workflow for iOS and Android Applications – Joshua Morony‌‌‌‌

Fin.‌‌