Building desktop apps and PWAs

Build cross-platform desktop applications and Progressive Web Apps with Aurelia using Electron, Tauri, and modern PWA technologies.

Deploy your Aurelia application beyond the browser—as installable Progressive Web Apps (PWAs) that work offline, or as native desktop applications for Windows, macOS, and Linux using Electron or Tauri. This guide covers strategies for building, packaging, and distributing cross-platform applications.

Why This Is an Advanced Scenario

Desktop and PWA deployment involves:

  • Platform adaptation - Tailoring UX for desktop vs. web

  • Native integrations - File system, system tray, notifications

  • Distribution complexity - App stores, auto-updates, code signing

  • Security considerations - Sandbox escaping, IPC security

  • Performance optimization - Bundle size, startup time, memory usage

  • Offline capabilities - Service workers, local data, sync strategies

  • Multi-platform testing - Windows, macOS, Linux variations

Advanced topics:

  • Custom protocols and deep linking

  • Native module integration

  • Hardware access (USB, Bluetooth, Serial)

  • System-level permissions

  • Crash reporting and analytics

  • Auto-update mechanisms

Technology Overview

Progressive Web Apps (PWAs)

Web applications with native-like capabilities:

  • Pros: No installation friction, automatic updates, cross-platform

  • Cons: Limited system access, browser dependency

  • Best for: Customer-facing apps, content-heavy applications

Electron

Chromium + Node.js for desktop apps:

  • Pros: Full Node.js access, mature ecosystem, extensive APIs

  • Cons: Large bundle size (~150MB), slower startup

  • Best for: Feature-rich applications, enterprise software

Tauri

Rust + WebView for lightweight desktop apps:

  • Pros: Tiny bundles (~5MB), fast startup, memory efficient, secure

  • Cons: Younger ecosystem, less plugins

  • Best for: Performant apps, resource-conscious deployments


Part 1: Progressive Web Apps (PWAs)

Complete PWA Guide

For comprehensive PWA documentation including service workers, caching strategies, manifest configuration, and offline functionality:

See the complete guide: Progressive Web Apps (PWA)

PWA Quick Start

npm install -D vite-plugin-pwa
// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';

export default defineConfig({
  plugins: [
    aurelia(),
    VitePWA({
      registerType: 'autoUpdate',
      manifest: {
        name: 'Aurelia App',
        short_name: 'Aurelia',
        theme_color: '#814c9e',
        icons: [
          {
            src: 'icon-192.png',
            sizes: '192x192',
            type: 'image/png'
          },
          {
            src: 'icon-512.png',
            sizes: '512x512',
            type: 'image/png'
          }
        ]
      }
    })
  ]
});

PWA Best Practices

  1. Offline-First Architecture

  2. App-like Navigation - No URL bar, custom chrome

  3. Background Sync - Queue actions when offline

  4. Push Notifications - Re-engagement strategy

  5. Install Prompts - Strategic timing for install banners


Part 2: Electron Desktop Apps

Installing Electron

npm install --save-dev electron electron-builder
npm install --save-dev concurrently wait-on cross-env

Project Structure

my-aurelia-electron-app/
├── src/                    # Aurelia application
├── electron/
│   ├── main.js            # Electron main process
│   ├── preload.js         # Secure IPC bridge
│   └── menu.js            # Application menu
├── dist/                   # Built Aurelia app
├── build/                  # Electron build output
├── package.json
└── electron-builder.yml   # Distribution config

Main Process Setup

// electron/main.js
const { app, BrowserWindow, ipcMain } = require('electron');
const path = require('path');
const isDev = process.env.NODE_ENV === 'development';

let mainWindow;

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      contextIsolation: true,
      nodeIntegration: false, // Security best practice
      enableRemoteModule: false
    },
    title: 'Aurelia Desktop App',
    icon: path.join(__dirname, '../build/icon.png')
  });

  // Load Aurelia app
  if (isDev) {
    mainWindow.loadURL('http://localhost:3000');
    mainWindow.webContents.openDevTools();
  } else {
    mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
  }

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

app.whenReady().then(createWindow);

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

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

// IPC handlers
ipcMain.handle('get-app-version', () => {
  return app.getVersion();
});

ipcMain.handle('read-file', async (event, filePath) => {
  const fs = require('fs').promises;
  return await fs.readFile(filePath, 'utf-8');
});

Preload Script (Secure IPC)

// electron/preload.js
const { contextBridge, ipcRenderer } = require('electron');

// Expose safe APIs to renderer
contextBridge.exposeInMainWorld('electronAPI', {
  getAppVersion: () => ipcRenderer.invoke('get-app-version'),
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
  onUpdateAvailable: (callback) => ipcRenderer.on('update-available', callback),
  removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel)
});

Using Electron APIs in Aurelia

// src/services/electron-service.ts
export class ElectronService {
  private api: any;

  constructor() {
    this.api = (window as any).electronAPI;
  }

  get isElectron(): boolean {
    return !!this.api;
  }

  async getVersion(): Promise<string> {
    if (!this.isElectron) return 'web';
    return await this.api.getAppVersion();
  }

  async readFile(path: string): Promise<string> {
    if (!this.isElectron) {
      throw new Error('File system access requires Electron');
    }
    return await this.api.readFile(path);
  }
}
// src/my-component.ts
import { resolve } from '@aurelia/kernel';
import { ElectronService } from './services/electron-service';

export class MyComponent {
  private electron = resolve(ElectronService);
  version = '';

  async attached() {
    if (this.electron.isElectron) {
      this.version = await this.electron.getVersion();
    }
  }
}

Package.json Scripts

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "electron": "electron .",
    "electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:3000 && cross-env NODE_ENV=development electron .\"",
    "electron:build": "npm run build && electron-builder"
  }
}

Electron Builder Configuration

# electron-builder.yml
appId: com.yourcompany.aureliaapp
productName: Aurelia Desktop App
directories:
  output: release
  buildResources: build

files:
  - dist/**/*
  - electron/**/*
  - package.json

mac:
  category: public.app-category.productivity
  icon: build/icon.icns
  target:
    - dmg
    - zip

win:
  icon: build/icon.ico
  target:
    - nsis
    - portable

linux:
  icon: build/icon.png
  category: Office
  target:
    - AppImage
    - deb
    - rpm

Auto-Updates

// electron/main.js
const { autoUpdater } = require('electron-updater');

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

  // Check for updates
  autoUpdater.checkForUpdatesAndNotify();

  autoUpdater.on('update-available', () => {
    mainWindow.webContents.send('update-available');
  });

  autoUpdater.on('update-downloaded', () => {
    mainWindow.webContents.send('update-ready');
  });
});

ipcMain.handle('install-update', () => {
  autoUpdater.quitAndInstall();
});

Part 3: Tauri Desktop Apps

Installing Tauri

npm install --save-dev @tauri-apps/cli
npm install @tauri-apps/api

Initialize Tauri:

npx tauri init

Project Structure

my-aurelia-tauri-app/
├── src/                    # Aurelia application
├── src-tauri/
│   ├── src/
│   │   └── main.rs        # Rust backend
│   ├── tauri.conf.json    # Tauri configuration
│   ├── Cargo.toml         # Rust dependencies
│   └── icons/             # App icons
├── dist/                   # Built Aurelia app
└── package.json

Tauri Configuration

// src-tauri/tauri.conf.json
{
  "build": {
    "distDir": "../dist",
    "devPath": "http://localhost:3000",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "package": {
    "productName": "Aurelia Desktop App",
    "version": "1.0.0"
  },
  "tauri": {
    "allowlist": {
      "all": false,
      "fs": {
        "readFile": true,
        "writeFile": true,
        "scope": ["$APPDATA/*"]
      },
      "dialog": {
        "open": true,
        "save": true
      },
      "shell": {
        "open": true
      }
    },
    "windows": [
      {
        "title": "Aurelia Desktop App",
        "width": 1200,
        "height": 800,
        "resizable": true,
        "fullscreen": false
      }
    ]
  }
}

Rust Backend (main.rs)

// src-tauri/src/main.rs
#![cfg_attr(
  all(not(debug_assertions), target_os = "windows"),
  windows_subsystem = "windows"
)]

use tauri::Manager;

#[tauri::command]
fn greet(name: &str) -> String {
  format!("Hello, {}!", name)
}

#[tauri::command]
async fn process_data(data: Vec<i32>) -> Result<Vec<i32>, String> {
  // Expensive computation in Rust
  Ok(data.iter().map(|x| x * 2).collect())
}

fn main() {
  tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![greet, process_data])
    .run(tauri::generate_context!())
    .expect("error while running tauri application");
}

Using Tauri APIs in Aurelia

// src/services/tauri-service.ts
import { invoke } from '@tauri-apps/api/tauri';
import { open } from '@tauri-apps/api/dialog';
import { writeTextFile, readTextFile } from '@tauri-apps/api/fs';

export class TauriService {
  async greet(name: string): Promise<string> {
    return await invoke('greet', { name });
  }

  async processData(data: number[]): Promise<number[]> {
    return await invoke('process_data', { data });
  }

  async openFile(): Promise<string | null> {
    const selected = await open({
      multiple: false,
      filters: [{
        name: 'Text',
        extensions: ['txt', 'md']
      }]
    });

    if (selected && typeof selected === 'string') {
      return await readTextFile(selected);
    }
    return null;
  }

  async saveFile(content: string, path: string): Promise<void> {
    await writeTextFile(path, content);
  }
}
// src/my-component.ts
import { resolve } from '@aurelia/kernel';
import { TauriService } from './services/tauri-service';

export class MyComponent {
  private tauri = resolve(TauriService);
  greeting = '';

  async attached() {
    this.greeting = await this.tauri.greet('Aurelia User');
  }

  async handleOpenFile() {
    const content = await this.tauri.openFile();
    if (content) {
      console.log('File content:', content);
    }
  }
}

Package.json Scripts

{
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "tauri": "tauri",
    "tauri:dev": "tauri dev",
    "tauri:build": "tauri build"
  }
}

Building for Distribution

# Development
npm run tauri:dev

# Production builds
npm run tauri:build

Outputs:

  • Windows: .exe installer, .msi

  • macOS: .app, .dmg

  • Linux: .AppImage, .deb, .rpm


Comparison: Electron vs. Tauri

Feature
Electron
Tauri

Bundle Size

~150MB

~5MB

Memory

100-200MB base

30-50MB base

Startup Time

1-3 seconds

<1 second

Backend Language

Node.js (JavaScript)

Rust

Maturity

Very mature

Growing

Plugin Ecosystem

Extensive

Growing

Security

Good (with care)

Excellent

Auto-Updates

Built-in

Third-party

Learning Curve

JavaScript only

Requires Rust


Common Patterns

Environment Detection

export class PlatformService {
  get isElectron(): boolean {
    return !!(window as any).electronAPI;
  }

  get isTauri(): boolean {
    return !!(window as any).__TAURI__;
  }

  get isPWA(): boolean {
    return window.matchMedia('(display-mode: standalone)').matches;
  }

  get isWeb(): boolean {
    return !this.isElectron && !this.isTauri && !this.isPWA;
  }

  get platform(): 'electron' | 'tauri' | 'pwa' | 'web' {
    if (this.isElectron) return 'electron';
    if (this.isTauri) return 'tauri';
    if (this.isPWA) return 'pwa';
    return 'web';
  }
}

Cross-Platform File Operations

export class FileService {
  private platform = resolve(PlatformService);
  private electron = resolve(ElectronService);
  private tauri = resolve(TauriService);

  async readFile(path: string): Promise<string> {
    switch (this.platform.platform) {
      case 'electron':
        return this.electron.readFile(path);
      case 'tauri':
        return this.tauri.readTextFile(path);
      case 'pwa':
      case 'web':
        throw new Error('File system access not available in browser');
    }
  }
}

System Notifications

// Works across all platforms
export class NotificationService {
  async notify(title: string, body: string) {
    if ('Notification' in window && Notification.permission === 'granted') {
      new Notification(title, { body });
    } else if ('Notification' in window) {
      const permission = await Notification.requestPermission();
      if (permission === 'granted') {
        new Notification(title, { body });
      }
    }
  }
}

Distribution & Deployment

Code Signing

macOS:

# Electron
electron-builder --mac --publish never

Windows:

# Requires code signing certificate
electron-builder --win --publish never

App Stores

  • Microsoft Store: Package as MSIX

  • Mac App Store: Notarization required

  • Snap Store (Linux): Snapcraft packaging

Auto-Updates

Electron + electron-updater:

const server = 'https://your-update-server.com';
const feed = `${server}/update/${process.platform}/${app.getVersion()}`;
autoUpdater.setFeedURL(feed);

Tauri: Use external update servers or GitHub releases


Security Best Practices

  1. Disable Node Integration (Electron)

  2. Enable Context Isolation (Electron)

  3. Use Preload Scripts for IPC

  4. Validate All IPC Messages

  5. Allowlist Required APIs (Tauri)

  6. Keep Dependencies Updated

  7. Implement CSP Headers

  8. Code Sign All Releases


Performance Optimization

Bundle Size

  • Tree-shake unused code

  • Code split large dependencies

  • Compress assets

  • Use native modules sparingly

Startup Time

  • Lazy load heavy components

  • Defer non-critical initialization

  • Cache computed values

  • Optimize Electron/Tauri window creation

Memory Usage

  • Dispose subscriptions properly

  • Use virtual scrolling for lists

  • Implement pagination

  • Profile with DevTools


Testing Desktop Apps

// src/services/__tests__/electron-service.spec.ts
import { ElectronService } from '../electron-service';

describe('ElectronService', () => {
  let service: ElectronService;

  beforeEach(() => {
    // Mock Electron API
    (window as any).electronAPI = {
      getAppVersion: () => Promise.resolve('1.0.0'),
      readFile: (path: string) => Promise.resolve('mock content')
    };
    service = new ElectronService();
  });

  it('detects Electron environment', () => {
    expect(service.isElectron).toBe(true);
  });

  it('gets app version', async () => {
    const version = await service.getVersion();
    expect(version).toBe('1.0.0');
  });
});

Resources

PWA

Electron

Tauri

Deployment

Last updated

Was this helpful?