Day for Electron developers

Electron renders a web interface using Chromium and Node.js. Day compiles Rust code into an app that uses native toolkit controls. Moving between them changes UI composition, process communication, dependencies, and packaging; this guide compares those areas with code examples.

Electron terms in Day

ElectronDay
BrowserWindowday::launch for the main window, open_window for more
main process / renderer / IPCone process; a platform call is a function call
preload.js + contextBridgenot needed; the app is one process and calls platform APIs directly
HTML + CSS (+ React/Vue)pieces + signals + modifiers
Menu.buildFromTemplateapp_menu — native menus in both, declared in Rust
new Notification({...})day-part-local-notify
electron-store, localStorageday::prefs (guide)
Node fsday-part-fs, or std::fs — it’s ordinary Rust
fetch / Node netday-part-http — each platform’s networking stack
<webview> / WebContentsViewday-piece-webview — the system webview
native modules (N-API, node-gyp)parts and pieces, compiled with the app
nodeIntegration / contextIsolation hardeningnot applicable; the UI is compiled Rust driving native widgets, so there is nothing to isolate
electron-builder / Forgeday pack -p <target>
autoUpdater (Squirrel)none yet; updates ship through stores and installers
Playwright / WebdriverIOdayscript, the same script on every target

The same counter in both

ElectronDay
<!-- index.html, loaded by a BrowserWindow -->
<div style="display:flex; flex-direction:column;
            gap:12px; padding:16px">
  <span id="count">0 clicks</span>
  <button id="inc">+</button>
</div>
<script>
  let count = 0;
  const label = document.getElementById('count');
  document.getElementById('inc').onclick = () => {
    count += 1;
    label.textContent = `${count} clicks`;
  };
</script>
use day::prelude::*;

fn counter() -> impl Piece {
    let count = Signal::new(0i64);
    column((
        label(move || format!("{} clicks", count.get())),
        button("+").action(move || count.update(|c| *c += 1)),
    ))
    .spacing(12.0)
    .padding(16.0)
}

The parts match (a container, a text node, a click handler). On the Day side, label is a native text widget, button a native button, and the closure inside label is a binding: when count changes, Day re-runs that one closure and calls one native setter. There is no document tree to restyle or reconcile. If your Electron app is React inside the renderer, one more thing changes: Day never re-runs your component function, so hooks rules and dependency arrays have no equivalent.

One process

In Electron a capability lives in the main process, so the renderer reaches it through ipcMain.handle, a preload bridge, and an invoke. In Day the capability is a crate and you call it.

ElectronDay
// main.js
ipcMain.handle('open-notes', async () => {
  const { canceled, filePaths } = await dialog.showOpenDialog({
    filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
  });
  if (canceled) return null;
  return fs.promises.readFile(filePaths[0], 'utf8');
});

// preload.js
contextBridge.exposeInMainWorld('api', {
  openNotes: () => ipcRenderer.invoke('open-notes'),
});

// renderer.js
const text = await window.api.openNotes();
if (text !== null) editor.value = text;
// anywhere in the app — same process, same language
button("Open…").action(move || {
    day::task(async move {
        if let Some(file) = open_file()
            .filter("Text", &["txt", "md"])
            .await
        {
            if let Ok(text) = file.read_to_string() {
                editor.set(text);
            }
        }
    });
})

The three-file round trip on the left is the cost of the process boundary: every capability needs a handler, a bridge entry, and a serializable payload. On the right the dialog is open_file(), the battery is day_part_battery::status(), the clipboard is day_part_clipboard::get_text(). All are plain functions, typed end to end. When a capability needs platform code Day doesn’t ship (your N-API module case), you write a part: the platform halves live in one crate and calls into it stay ordinary Rust.

Native form controls

Form controls bind two ways, and each one is the platform’s control, which supplies accessibility, keyboard traversal, right-to-left mirroring, and the user’s text size on its own:

let name = Signal::new(String::new());
let volume = Signal::new(40.0);
let subscribed = Signal::new(false);
let size = Signal::new(0usize);

column((
    text_field(name).placeholder("Your name"),
    slider(volume).range(0.0..=100.0),
    toggle(subscribed),
    picker(["Small", "Medium", "Large"], size).segmented(),
))
.spacing(12.0)

For long lists, list hands your rows to the platform’s recycling list widget (NSTableView-family, RecyclerView), so ten thousand rows build only the visible cells. Styling is modifier-based (.padding, .background, .corner_radius, semantic Font::Title), and Styling lists what you can restyle and what stays native: you adjust spacing, color, and typography, and the control chrome stays the platform’s. If the design brief is a custom skin on every pixel, a web renderer serves that better. For an app that should look and behave like standard platform controls, Day keeps the controls native.

Windows, menus, and the chrome

Electron’s menus are already native, and Menu.buildFromTemplate is the part of Electron closest to Day’s model. Day gives windows and toolbars the same treatment: windows are native windows you open directly, the Settings window is a one-call convention, and toolbars live in the title-bar chrome.

ElectronDay
const win = new BrowserWindow({
  width: 520, height: 420,
  webPreferences: { preload: PRELOAD },
});
win.loadFile('settings.html');

Menu.setApplicationMenu(Menu.buildFromTemplate([
  { role: 'appMenu' },
  { label: 'File', submenu: [
    { label: 'New Note', accelerator: 'CmdOrCtrl+N',
      click: newNote },
  ]},
]));
day::open_window(
    "settings",
    WindowOptions { title: "Settings".into(),
                    size: Size::new(520.0, 420.0),
                    ..Default::default() },
    WindowKind::Preferences,
    settings_page,
);

app_menu((
    sub_menu("File", (
        menu_item("New Note").key("n").action(new_note),
        menu_role(MenuRole::Quit),
    )),
));

register_preferences_with goes further than the sample: registering a Settings page once puts the standard Settings… item (⌘, on macOS) in the right menu on every desktop, and platforms without windows (the same code on an iPhone) present a fullscreen cover instead. The desktop guide walks all three subsystems.

Binary size and process model

A Day app links the toolkit the OS already has, so the binary stays small: the Day Showcase (26 pages exercising every widget, chart, and part in this documentation) packs to a 5.7 MB notarized .dmg. Remote content goes through day-piece-webview, which hosts the system web view (WKWebView, WebView2, WebKitGTK).

Reusing web code

Two paths keep existing web code useful. web_view(url_signal) embeds existing web screens in the system web view while you migrate page by page. And the whole app also runs as a website: day build -p web-dom compiles the same crate to WebAssembly driving DOM elements. For a team that keeps writing TypeScript, day-lite runs JS/TS miniapps on top of Day’s native pieces; it hosts miniapps and is not a way to write the app itself.

Package configuration

ElectronDay
// package.json
{
  "name": "field-notes",
  "version": "1.2.0",
  "main": "main.js",
  "devDependencies": {
    "electron": "^43.0.0",
    "electron-builder": "^26.0.0"
  }
}
// plus electron-builder.yml: appId, mac.category,
// win.target, linux.target, files, asar…
# Cargo.toml — the package
[package]
name = "field-notes"
version = "1.2.0"
edition = "2024"

[dependencies]
day = { git = "https://github.com/daybrite/day.git" }

# Day.toml — the app manifest
[app]
id = "dev.example.fieldnotes"
title = "Field Notes"
build = 34
targets = ["macos-appkit", "windows-xaml", "linux-gtk",
           "ios-uikit", "android-mdc", "web-dom"]

Day.toml plays electron-builder.yml’s role (identity, targets, signing, permissions). Per-platform values are overrides in the same file ([app.macos-appkit]), and the same file covers the mobile targets.

Project structure

ElectronDay
field-notes/
├── package.json
├── electron-builder.yml
├── main.js               # the Node side
├── preload.js            # the bridge
├── src/                  # the web app
│   ├── index.html
│   ├── renderer.js
│   └── styles.css
└── node_modules/
field-notes/
├── Cargo.toml
├── Day.toml
├── src/
│   ├── lib.rs            # the app: pieces, signals, routes
│   └── main.rs           # desktop entry point
├── resource/             # assets/ images/ vectors/ fonts/ icons/ locales/
├── dayscript/            # UI test flows
├── platform/             # thin mobile hosts (no app logic)
└── build/day/            # everything generated

The main, preload, and renderer files become one src/ directory, because the app is one process. Resources are staged natively per platform and referenced through generated constants (image(res::images::logo)), so a renamed file is a compile error. Localization uses Fluent catalogs compiled to typed functions (Localization).

Building and shipping

TaskElectronDay
run in develectron .day launch -p macos-appkit
apply changesreload the windowday relaunch --all-running
macOS artifactelectron-builder → .dmg, notarize via configday pack -p macos-appkit → notarized, stapled .dmg
Windows artifactNSIS / Squirrelday pack -p windows-xaml → .msix + NSIS installer
Linux artifactAppImage / deb / snapday pack -p linux-gtk → .flatpak + .appimage
mobile—day pack -p ios-uikit / -p android-mdc → .ipa, .apk + .aab
webseparate web build of the renderer codeday build -p web-dom — the same crate
auto-updateautoUpdater + an update servernone yet; stores and installers carry updates
UI testsPlaywright per OSone dayscript, every target, in CI with screenshots
supply chainyour Chromium/Node rebuild cadencereproducible builds with SBOM + buildinfo sidecars per artifact (details)

Maturity differs by target, and Platform support lists what each target has today: every (OS, toolkit) pair carries a support tier, from Tier 1 (thoroughly tested, with shipping apps on it) down to Tier 4 (development combinations nobody ships).

What you give up

  • HTML, CSS, and the npm UI ecosystem are replaced by a small widget vocabulary styled through modifiers; custom visuals are drawn with canvas or a piece.
  • DevTools have no equivalent; debugging is Rust tooling plus dayscript assertions and screenshots.
  • Hot reload becomes an incremental compile and relaunch, seconds on desktop, with a script to put you back on the screen you were editing.
  • Electron’s autoUpdater has no Day equivalent yet; updates travel through the stores and installers day pack produces.
  • Day has no tray or global-shortcut API yet. Menus carry per-item accelerators, but there is no general key-event surface.
  • Your app will look like a Mac app on macOS and a GTK app on GNOME. If your brand requires one rendering on every OS, Electron’s single renderer serves it better.
  • The team writes Rust instead of JavaScript. Budget ramp-up time; the compiler catches at build time much of what you currently catch in DevTools.

Where to go next