+1 (415) 843-4662

Shipping your Ionic app on the desktop: Electron, Tauri and one codebase for web, mobile and desktop

"The app works on phones — can we give the back-office team a desktop version?" That request arrives on most of our Ionic engagements eventually, usually from a customer whose staff live in a warehouse, a clinic or a call centre and do not want a browser tab. The good news is that an Ionic app is already a web app, so desktop is a packaging problem rather than a rewrite. The bad news is that the packaging problem has three plausible answers and the wrong one costs you months.

This tutorial walks the decision, then builds the shared-code structure, auto-update and signing pipeline we use for real desktop releases.

1. Pick the shell: PWA, Electron or Tauri

Installable PWAElectron (Capacitor Electron)Tauri 2
Installer sizenone~90–150 MB~5–15 MB
Rendering engineuser's browserbundled Chromium — identical everywhereOS WebView (WebView2 / WKWebView)
Native accessweb APIs onlyfull Node.js + any npm native moduleRust commands, strict capability model
Reuses Capacitor pluginsweb fallbacks onlyyes, via the Electron platformno — needs Tauri plugins or your own
Enterprise deployment (MSI/MDM)awkwardyesyes

Our rule of thumb:

  • Needs nothing native beyond storage, camera and printing? Ship the PWA first. It costs a day, and @angular/pwa or the Vite PWA plugin gets you there. Do not build a desktop binary for a feature list the browser covers.
  • Needs serial/USB hardware, a local database file, deep OS integration, or already leans on Capacitor plugins? Electron. You keep one plugin API across iOS, Android and desktop and you control the Chromium version, which matters when your app must render identically on a 2018 Windows box.
  • Size, memory and startup matter more than plugin reuse (kiosks, thin clients, consumer downloads)? Tauri 2. Expect to re-implement the handful of native calls you use as Rust commands.

The rest of this tutorial does Electron in detail, because it is the path that reuses the most of an existing Ionic codebase, then shows the Tauri variant of the same abstraction.

2. Add the Electron platform

npm i -D @capacitor-community/electron
npm run build                 # produce www/ or dist/ first
npx cap add @capacitor-community/electron
npx cap sync @capacitor-community/electron
npx cap open @capacitor-community/electron

That creates an electron/ folder next to ios/ and android/, with its own package.json, a main process in electron/src/, and an electron/capacitor.config.json.

Harden the window before you do anything else. The defaults in older templates are friendlier than they should be:

// electron/src/setup.ts
const win = new BrowserWindow({
  width: 1280,
  height: 800,
  minWidth: 960,
  show: false,
  titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
  webPreferences: {
    nodeIntegration: false,        // never true for renderer code you ship
    contextIsolation: true,
    sandbox: true,
    preload: join(__dirname, 'preload.js'),
  },
});

win.once('ready-to-show', () => win.show());   // no white flash on cold start

Everything privileged goes through the preload bridge, not the renderer:

// electron/src/preload.ts
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('desktop', {
  printLabel: (zpl: string) => ipcRenderer.invoke('print-label', zpl),
  pickFolder: () => ipcRenderer.invoke('pick-folder'),
  onUpdateReady: (cb: () => void) => ipcRenderer.on('update-ready', cb),
});

Expose named, typed operations. Never expose ipcRenderer.invoke itself or a generic exec — that hands any injected script the whole machine.

3. One abstraction, three platforms

The mistake we fix most often is if (isElectron) scattered through components. Put the branch in one place and let the rest of the app depend on an interface.

// src/platform/files.ts
import { Capacitor } from '@capacitor/core';
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';

export interface FileService {
  exportCsv(name: string, csv: string): Promise<string>;   // returns a user-facing location
}

const webFiles: FileService = {
  async exportCsv(name, csv) {
    const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
    Object.assign(document.createElement('a'), { href: url, download: name }).click();
    URL.revokeObjectURL(url);
    return 'Downloads';
  },
};

const nativeFiles: FileService = {
  async exportCsv(name, csv) {
    const res = await Filesystem.writeFile({
      path: name,
      data: csv,
      directory: Directory.Documents,
      encoding: Encoding.UTF8,
    });
    return res.uri;
  },
};

const desktopFiles: FileService = {
  async exportCsv(name, csv) {
    const dir = await (window as any).desktop.pickFolder();
    if (!dir) throw new Error('cancelled');
    return (window as any).desktop.writeFile(`${dir}/${name}`, csv);
  },
};

export const files: FileService =
  Capacitor.getPlatform() === 'electron' ? desktopFiles
  : Capacitor.isNativePlatform() ? nativeFiles
  : webFiles;

Capacitor.getPlatform() returns 'electron' under the community platform, so your existing isNativePlatform() checks keep working for iOS and Android while desktop gets its own branch. For Tauri, swap the desktop implementation for invoke('write_file', { path, contents }) from @tauri-apps/api — the components never change.

4. Make it feel like a desktop app, not a stretched phone

An Ionic layout that is correct on a tablet is usually 80% correct on a 1440px monitor. The remaining 20% is what reviewers notice:

  • Split panes. ion-split-pane with when="lg" gives you a persistent sidebar instead of a hamburger menu. Desktop users expect navigation to stay put.
  • Cap your content width. ion-content { --padding-inline: max(16px, (100vw - 1200px) / 2); } stops forms from stretching to 2000px.
  • Keyboard first. Register real accelerators in the Electron menu (CmdOrCtrl+F, CmdOrCtrl+N) and forward them over IPC to the same actions your buttons call. Make sure every modal closes on Escape and the tab order through ion-inputs is sane.
  • Hover states. Mobile Ionic themes often disable hover. Re-enable it behind @media (hover: hover) and (pointer: fine).
  • Right-click menus where a long-press exists on mobile.
  • Window state. Persist size and position, and restore it on launch; it is a ten-line feature that users notice immediately.

5. Auto-update

Desktop has no app store forcing upgrades, so an update channel is mandatory — otherwise you will be supporting an 18-month-old build forever.

// electron/src/updater.ts
import { autoUpdater } from 'electron-updater';
import log from 'electron-log';

autoUpdater.logger = log;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.channel = process.env.RELEASE_CHANNEL ?? 'latest';  // latest | beta

export function initUpdates(win: Electron.BrowserWindow) {
  autoUpdater.on('update-downloaded', () => win.webContents.send('update-ready'));
  autoUpdater.on('error', (e) => log.error('update failed', e));
  setInterval(() => autoUpdater.checkForUpdates(), 6 * 60 * 60 * 1000);
  autoUpdater.checkForUpdates();
}

In the renderer, listen for update-ready and show an ion-toast with a "Restart now" button rather than restarting under someone's data entry.

Publish targets go in electron-builder.config.js. An S3 bucket or a GitHub release both work; what matters is that the latest.yml / latest-mac.yml manifests are served next to the installers and that they are signed.

Note the difference from mobile: this ships new native code, so unlike the Live-Updates style web-asset push you may use on iOS and Android, a desktop update is a full installer swap. Keep your web assets and your Electron main process versioned together.

6. Code signing and notarization — the part that slips releases

Budget a week for this the first time. Unsigned desktop binaries get SmartScreen warnings on Windows and a flat refusal on macOS.

macOS. You need a Developer ID Application certificate, hardened runtime, and notarization:

// electron-builder.config.js
module.exports = {
  appId: 'com.example.app',
  mac: {
    category: 'public.app-category.business',
    hardenedRuntime: true,
    gatekeeperAssess: false,
    entitlements: 'build/entitlements.mac.plist',
    notarize: { teamId: process.env.APPLE_TEAM_ID },
  },
  win: {
    target: ['nsis', 'msi'],
    signtoolOptions: { signingHashAlgorithms: ['sha256'] },
  },
  publish: [{ provider: 's3', bucket: 'example-desktop-releases' }],
};

Set APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD and APPLE_TEAM_ID in CI. Entitlements almost always need com.apple.security.cs.allow-jit for the renderer.

Windows. Since June 2023 OV code-signing certificates must live on an HSM or in a cloud signing service (Azure Trusted Signing, DigiCert KeyLocker, SSL.com eSigner). You cannot copy a .pfx into CI any more. Plan for a cloud signing integration and a signtool hook in electron-builder; also plan for the reputation ramp — fresh OV certificates still trigger SmartScreen for a while, EV certificates do not.

Enterprise distribution. If IT deploys via Intune or SCCM, ship an MSI in addition to the NSIS installer and support a per-machine silent install (msiexec /i app.msi /qn). Ask early; retrofitting MSI at the end of a project is a bad week.

7. CI: build all three platforms

macOS builds must run on macOS runners, Windows builds on Windows runners. A matrix keeps it simple:

jobs:
  desktop:
    strategy:
      matrix:
        os: [macos-14, windows-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci && npm run build
      - run: npx cap sync @capacitor-community/electron
      - run: npm --prefix electron ci
      - run: npx electron-builder --publish always
        working-directory: electron
        env:
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

If you already run the Capacitor CI pipeline we described in our GitHub Actions + fastlane post, this is one more job in the same workflow, gated on the same version tag.

8. The Tauri 2 variant, briefly

npm i -D @tauri-apps/cli
npx tauri init          # frontendDist: ../www (or ../dist), devUrl: http://localhost:8100
npx tauri dev

Four things to know before you commit to it:

  1. Capacitor plugins do not work. Your abstraction layer from section 3 is what makes this tolerable; each desktop implementation becomes a Rust command exposed through invoke().
  2. Capabilities are explicit. Every filesystem path, shell command and HTTP host must be declared in src-tauri/capabilities/*.json. This is a real security win and a real source of "works in dev, blocked in prod" bugs.
  3. You inherit the OS WebView. WebView2 on Windows is evergreen Chromium, but macOS ships WKWebView tied to the OS version — test on the oldest macOS you support before promising CSS features.
  4. The updater is built in (tauri-plugin-updater) and requires a signed manifest with your own keypair.

For a kiosk app or an internal tool with two native calls, Tauri's 8 MB installer is worth the rewrite. For an app already using six Capacitor plugins, it usually is not.

A checklist before you promise a desktop build

  • Confirmed the requirement is not satisfied by an installable PWA
  • Native needs listed, and mapped to Electron plugins or Tauri commands
  • Platform abstraction in place; zero if (electron) in components
  • contextIsolation: true, nodeIntegration: false, sandboxed renderer
  • Split-pane layout, keyboard accelerators, window state persistence
  • Auto-update channel live, with a user-controlled restart
  • macOS Developer ID + notarization working in CI
  • Windows cloud/HSM signing arranged (this has the longest lead time)
  • MSI + silent install if IT deploys it
  • Crash reporting wired for the main and renderer process

Desktop is the cheapest platform to add to a healthy Ionic codebase and the most expensive to bolt onto an unhealthy one. If your app already isolates native access behind services, you are two weeks from a signed installer.

If you want a second pair of eyes on that decision — or a team that has shipped signed Electron and Tauri builds from Ionic codebases before — get in touch. Our senior Ionic consultants can scope the desktop track alongside your mobile roadmap.