AirWorks 0.4

Build apps, not desktop chrome.

AirWorks is a framework-free web desktop and micro-frontend shell. It brings windows, a taskbar with previews, several webtops, folders, drag and drop, search, pop-outs, a login screen, roles and permissions and persistence. You register apps.

01 · Setup

Install

AirWorks is one ES module (airworks.js), one stylesheet (airworks.css) and an icons/ folder. Pick one of three ways to get it; the API is identical.

A · Package (git dependency)

npm install github:fgilde/AirWorks

npm clones the repository and builds the library during install (prepare). Node 22.18+ is required.

B · No install (CDN from GitHub Pages)

<link rel="stylesheet"
  href="https://fgilde.github.io/AirWorks/lib/airworks.css">
<script type="module">
  import { AirWorks } from
    "https://fgilde.github.io/AirWorks/lib/airworks.js";
</script>

Icons load from lib/icons/ next to the module automatically.

C · Repository checkout

git clone https://github.com/fgilde/AirWorks.git
cd AirWorks/src/airworks
npm install
npm run dev            # docs on http://127.0.0.1:4173, demo on /webtop.html
npm run build:package  # writes ../../package (airworks.js, airworks.css, icons, types)
Icons when bundling. AirWorks resolves icon names against assetBase. Its default is the icons/ folder next to airworks.js, which works for the CDN and for unbundled ESM. When a bundler (Vite, webpack …) inlines AirWorks into your own chunk, copy node_modules/@fgilde/airworks/package/icons into your public folder and set assetBase, as the Aspire sample does with publicDir.
02 · First app

Quick start

<air-desktop></air-desktop>
<script type="module">
  import { AirWorks } from "@fgilde/airworks";
  import "@fgilde/airworks/style.css";

  AirWorks.configure({
    brand: { title: "Contoso Desk", logo: "/img/logo.png" },
    initialShortcuts: ["hello"],
    initialApps: ["hello"],
  });
  AirWorks.registerApp({
    id: "hello",
    title: "Hello",
    icon: "fn-dashboard.png",          // a file in the icon folder, or any URL
    accent: "#00a4a6",
    width: 640, height: 420,
    render: () => "<h1>Hello AirWorks</h1>",
  });
</script>

<air-desktop> initializes in a microtask, so configure() and registrations in the same module always come first. If your setup is asynchronous (for example you await a configuration endpoint), add the element afterwards instead: document.body.append(document.createElement("air-desktop")). Apps, permissions and providers can be registered at any time; the desktop updates live.

03 · Configuration

Options

AirWorks.configure(options) merges options and can be called repeatedly. assetBase, storageKey, initialApps and initialShortcuts are read when the desktop initializes.

OptionPurpose
assetBaseBase URL of the icon folder. Default: icons/ next to the module.
locale"en" or "de" (or your own via addMessages). Default: browser language. A choice in Settings wins.
serverBaseUrlPrefix for AirWorks.request(path).
requestReplaces the built-in fetch transport completely.
loginHintText on the login screen, e.g. how to sign in. Providers can add their own hint.
requireLoginAs soon as an auth provider is registered, the desktop starts on the login screen and stays locked until someone signs in. Set false to allow guests.
accessProvider(session) => { roles, permissions }, e.g. from /api/me. Runs after every login.
identityStoreUsers/roles backend; enables the built-in "Users & roles" app.
brandYour product: { title, logo, startIcon, cornerLogo, description, version }. title and logo appear on the login screen, in About and in the logo effect; startIcon replaces the logo on the start button; cornerLogo is the image bottom right (false hides it).
defaultsSettings for users without a saved profile, e.g. { wallpaper: "dark", systemColor: "#00777a", taskbarPosition: "bottom", effect: "none" }.
wallpapersExtra wallpapers: [{ id, title, image }] or [{ id, title, background: "linear-gradient(…)" , foreground: "#fff" }].
docsUrlAdds a documentation button to the start menu; the documentation opens as a window.
webLinksThe built-in Web link app (default true). Set false when you only want your own apps.
storageKeyPrefix for saved profiles (default airworks; signed-in users get airworks.<user id>).
profileStoreWhere profiles (webtops, shortcuts, settings, open windows) are kept. Default: localStorage. See user profiles.
initialShortcuts / initialAppsApp ids placed on the first webtop / opened on the very first start (no saved workspace).
04 · Apps

Register apps

An app is a plain object. It needs id, title, icon and exactly one way to produce content: render, url or entry.

AirWorks.registerApp({
  id: "orders",
  title: "Orders",
  icon: "fn-reporttable.png",
  accent: "#35496b",               // window, tile and taskbar color
  width: 900, height: 600,
  requiredPermissions: ["orders.read"],
  hidden: false,                   // true: not in start menu/search, still openable via API
  async render({ host, request, can, onClose, setTitle, session, access }) {
    host.innerHTML = "Loading …";
    const orders = await request("/orders");
    host.innerHTML = renderTable(orders);
    const timer = setInterval(refresh, 30_000);
    onClose(() => clearInterval(timer));     // cleanup when the window closes
  },
});

render

Return an HTML string, a DOM node, or nothing and write into context.host. May be async. AirWorks never re-renders a window body, so state, custom elements and framework roots (React, Vue, Svelte, Angular elements) stay alive until the window closes.

url

Loads the app in an iframe: full isolation for independently deployed micro frontends. The iframe handles its own authentication (single sign-on with the same identity provider works).

entry

An ES module URL that is imported on first open. Its default export (or exportName) is a render function or a partial app definition.

context

host, appId, windowId, session, access, can(permission), request(path, init), onClose(cb), setTitle(text), setAppearance({ title, accent, icon }), intent, onIntent(cb), setState(state, title) and link (the shortcut the window was opened from, with its data).

Every window gets the menu actions create/update shortcut, duplicate and open in browser (a pop-out that shows just this app via ?app=<id>). Apps add their own entries with menu: [{ id, title, icon, run(context) }]. Apps open as many windows as you like; singleInstance: true keeps one window (focused again on the next open, without duplicate), as the built-in settings do. In a pop-out (open in browser) the window fills the browser tab without move, resize or window buttons; its menu keeps update shortcut and open in browser for another pop-out. Built-in apps: airworks.settings, airworks.identity, airworks.weblink and airworks.docs. AirWorks.unregisterApp(id) removes an app.

Shortcuts remember where you were

An app reports its current location with setState, e.g. the selected report or key ratio, plus a title. Create shortcut proposes that title and stores the state, update shortcut saves the new state, and duplicate, open in browser and restored windows reopen exactly there. The state arrives as intent, the same way search results do.

render: ({ host, intent, onIntent, setState }) => {
  const show = (ratio) => { draw(ratio); setState({ ratio }, titleOf(ratio)); };
  onIntent((next) => show(next.ratio));
  show(intent?.ratio ?? "cash-ratio");
}

Web links

The Web link app turns any web page into an app: name, web address, color and icon (uploaded image or the site's favicon). It creates a shortcut and shows the page in a window; Edit web link in the window menu changes it later. Pages that forbid embedding (X-Frame-Options) stay empty; the window offers to open them in the browser. Disable it with webLinks: false.

Using a framework

// React
render: ({ host, onClose }) => {
  const root = createRoot(host);
  root.render(<Orders />);
  onClose(() => root.unmount());
}
05 · Composition

Micro frontends

Teams can ship apps independently. AirWorks.loadRemote(url) fetches a JSON manifest; relative entry, url and icon paths resolve against the manifest URL. Apps from entry load lazily on first open.

// https://team-a.example.com/airworks/manifest.json
{
  "permissions": [{ "id": "remote.hello", "title": "Use hello", "group": "Team A" }],
  "apps": [
    { "id": "remote-hello", "title": "Remote hello", "icon": "fn-ideaportal.png",
      "entry": "./hello.js", "requiredPermissions": ["remote.hello"] },
    { "id": "billing", "title": "Billing", "icon": "./billing.png", "url": "./billing/index.html" }
  ],
  "modules": ["./register-more.js"]   // modules that call AirWorks.* themselves
}

// hello.js
export default function render({ host, access, onClose }) {
  return `<h2>Hello ${access.identity?.displayName ?? "guest"}</h2>`;
}
await AirWorks.loadRemote("https://team-a.example.com/airworks/manifest.json");
await AirWorks.loadRemote({ apps: [/* inline manifest */] });

The client-only demo loads remote/manifest.json this way. Cross-origin modules need CORS headers on the remote host. A manifest can also be produced by your server per user.

06 · Desktop model

Webtops, folders and drag and drop

Webtops (up to 9) and folders are the same thing: paged grids. Webtops have 5 × 3 cells, folders 4 × 3 and as many pages as needed. Every rule applies to both:

Dragging uses pointer events with a floating ghost (mouse, pen and touch), so nothing re-renders while you drag. Windows slide aside while you drag icons. Windows resize from every edge and corner and snap like this: drag to the top to maximize, to the left/right/bottom edge for halves, into a corner for quarters; Ctrl+arrows move between these positions (top twice = upper half, bottom twice = minimize). Other features:, taskbar on any side with auto-hide, previews, search, pilot view (all windows and webtops; add/delete webtops), wallpapers, a custom system color and effects (bouncing logos, snow, starfield, warp, network, aurora). Shortcuts of apps the user may not use stay in place but are hidden.

07 · Identity

Authentication

Without an auth provider AirWorks runs without any login. Register one or more providers and the desktop starts on the login screen: the card lists every provider, shakes on a failed attempt and, after signing in, shrinks into the start button. Signing out returns to the login screen. Pass requireLogin: false to keep the desktop usable for guests and offer sign-in from the start menu.

Real login with a server (recommended)

  1. Run an identity provider (Keycloak, Entra ID, Auth0 …) and register a public client with the page URL as redirect URI and its origin as web origin.
  2. Register the provider in the browser with createOidcProvider (below). The user signs in at the identity provider; AirWorks receives tokens via PKCE.
  3. Your API validates the access token (JWT bearer) and decides what the user may do. AirWorks.request sends the token automatically.
  4. Let the API return the effective roles and permissions (GET /me) and plug it in as accessProvider; enforce the same permissions on every endpoint.
  5. Optionally store users, roles and profiles on the server: identityStore: httpIdentityStore(…), profileStore: httpProfileStore(…).
AirWorks.configure({
  serverBaseUrl: "/api",
  accessProvider: () => AirWorks.request("/me"),
  identityStore: httpIdentityStore(AirWorks.request, "/identity"),
  profileStore: httpProfileStore(AirWorks.request, "/profile"),
});
AirWorks.registerAuthProvider(createOidcProvider({ id: "keycloak", title: "Keycloak", authority, clientId: "airworks-web" }));
document.body.append(document.createElement("air-desktop"));

The .NET Aspire sample implements exactly this, including the API side.

OpenID Connect (Keycloak, Entra ID, Auth0, Okta, Zitadel …)

import { AirWorks, createOidcProvider } from "@fgilde/airworks";

AirWorks.registerAuthProvider(createOidcProvider({
  id: "keycloak",
  title: "Keycloak",
  authority: "https://sso.example.com/realms/airworks",
  clientId: "airworks-web",               // public client, PKCE S256
  // scope: "openid profile email",       // default
  // redirectUri: location.origin + location.pathname,
  // extraParams: { kc_idp_hint: "github" },
  // roleClaims: ["realm_access.roles", "roles", "groups"],
}));

AirWorks.registerAuthProvider(createOidcProvider({
  id: "entra", title: "Microsoft",
  authority: "https://login.microsoftonline.com/<tenant>/v2.0",
  clientId: "<spa-client-id>",
  scope: "openid profile email api://<api-client-id>/access",
}));

createOidcProvider implements the authorization code flow with PKCE without dependencies: discovery, redirect, code exchange, refresh with refresh tokens and RP-initiated logout. Register the page URL as redirect URI and its origin as allowed web origin (CORS) at the identity provider. Tokens live in sessionStorage. Roles are read from roleClaims; mapSession(claims, tokens) customizes the session.

Social logins. GitHub does not offer OIDC with CORS, and Google requires a client secret for web clients. Connect them through Keycloak identity brokering and add a button with extraParams: { kc_idp_hint: "github" }; the Aspire sample has this entry ready in appsettings.json.

Client only: local users (demos)

import { localAuthProvider, localIdentityStore } from "@fgilde/airworks";
const identity = localIdentityStore({ users: [...], roles: [...] });
AirWorks.registerAuthProvider(localAuthProvider(identity, { title: "Demo users" }));

A user switcher for demos and prototypes: the login screen asks for a user name and roles come from the local identity store. Everything lives in the browser, so anyone can change it in the developer tools. Use it to try the UI, never to protect data. The live demo works this way (users demo and admin).

Your own provider or flow

AirWorks.registerAuthProvider({
  id: "company", title: "Company login",
  fields: [{ name: "userName", label: "userName" }, { name: "password", label: "Password", type: "password" }],
  async login(values) { return await myLogin(values); },  // returns a Session
  async restore() { return mySavedSession(); },           // startup, redirect return and refresh
  logout(session) { /* … */ },
});
// or skip providers and set the session yourself:
await AirWorks.setSession({ token, identity: { id: "jdoe", displayName: "J. Doe" }, roles: ["user"] });

A Session is { token?, identity?, roles?, permissions?, expiresAt? }. With expiresAt, AirWorks calls the provider's restore() 30 seconds before expiry to refresh.

07b · Persistence

User profiles

Webtops, folders, shortcuts, settings and open windows form a profile. Without login there is one profile; with login every user has their own (key <storageKey>.<user id>), loaded on sign-in and saved automatically.

Browser (default)

AirWorks.configure({ profileStore: localProfileStore() });

Per browser and user.

Server

AirWorks.configure({
  profileStore: httpProfileStore(AirWorks.request, "/profile"),
});
// GET /profile?key=…  → profile JSON or 204
// PUT /profile?key=…  ← profile JSON

Follows the user to every device. Use the authenticated user on the server, not the key, to decide whose profile it is.

Any object with load(key) and save(key, profile) works as profileStore.

08 · Authorization

Roles & permissions

Permissions are strings. Apps declare requiredPermissions; apps without them are visible to everyone. Register permissions so admins can pick them (client side, server side or both; the admin app merges both lists):

AirWorks.registerPermissions([
  { id: "orders.read", title: "Read orders", group: "Orders" },
  { id: "orders.write", title: "Edit orders", group: "Orders", description: "Create and change" },
]);
AirWorks.can("orders.write");        // also available in the app context
AirWorks.access;                     // { identity, roles, permissions }

How permissions are resolved after login

  1. With accessProvider: its result (typically your server's /me). Recommended for real security.
  2. Otherwise: the session's permissions plus the permissions of its roles from the identityStore (session roles plus roles assigned to the user record with the same id or user name).

Call AirWorks.refreshAccess() after changing assignments elsewhere; the built-in admin app does this itself. Windows of apps the user loses access to close.

Users & roles app

Configure an identityStore and grant airworks.identity.manage (exported as MANAGE_IDENTITY) to administrators. The app edits users (roles), and roles (permissions).

Client only

localIdentityStore(seed?, key?)
// localStorage, for demos

Server

httpIdentityStore(AirWorks.request, "/identity")
GET    /identity/users     PUT/DELETE /identity/users/{id}
GET    /identity/roles     PUT/DELETE /identity/roles/{id}
GET    /identity/permissions

Or implement IdentityStore (listUsers, saveUser, deleteUser, listRoles, saveRole, deleteRole, listPermissions) for any backend.

Client checks are UX, not security. They hide what a user cannot use. Always enforce permissions on the server, as the Aspire sample does.
09 · Backend

Server integration

AirWorks.configure({
  serverBaseUrl: "/api",
  accessProvider: () => AirWorks.request("/me"),   // { identity?, roles, permissions }
});
const orders = await AirWorks.request("/orders");                     // adds "Authorization: Bearer <token>"
await AirWorks.request("/orders/7", { method: "PUT", body: JSON.stringify(order) });  // JSON content type added

request throws on non-2xx, returns JSON or text, and undefined for 204. Pass request in the options to use your own HTTP client. Server side (ASP.NET Core, from the sample):

api.MapGet("/me", (ClaimsPrincipal user, IdentityService identity) => identity.Describe(user));
api.MapGet("/reports", GetReports).RequirePermission("reports.read");
// RequirePermission: endpoint filter that maps the caller's roles to permissions and returns 403 otherwise.
10 · Full stack

.NET Aspire sample

samples/AirWorks.Demo is a complete, secured setup: Aspire AppHost, Keycloak (realm import), an ASP.NET Core API with JWT validation and permission checks, and a web host that serves the AirWorks client (consumed as package) and proxies /api.

Run with QuickRun

One click with QuickRun clones, builds and starts the sample from quickrun.yml. Or by hand:

cd samples/AirWorks.Demo
dotnet run --project AirWorks.Demo.AppHost   # needs Docker for Keycloak
11 · Lifecycle

Events

const off = AirWorks.on("login", (session) => console.log(session));
AirWorks.on("logout", () => location.reload());
AirWorks.on("access-changed", ({ roles, permissions }) => {});
AirWorks.on("app-opened", ({ appId, windowId }) => {});
AirWorks.on("access-denied", ({ appId, requiredPermissions }) => {});
off();

The desktop element also dispatches bubbling DOM events: airworks:login, airworks:logout, airworks:access-changed, airworks:app-opened, airworks:access-denied.

12 · Languages

Languages

The shell ships English and German; users switch in Settings → General. Add or override texts:

import { addMessages } from "@fgilde/airworks";
addMessages("fr", { close: "Fermer", settings: "Paramètres" });
AirWorks.configure({ locale: "fr" });

Missing keys fall back to English. App titles are yours to translate.

13 · Reference

API reference

MemberDescription
configure(options)See options.
registerApp(app), registerApps(apps), unregisterApp(id)Manage apps. Duplicate ids throw.
loadRemote(urlOrManifest)Load a micro-frontend manifest.
openApp(id, intent?)Open (or focus) an app; returns false if unknown or not permitted.
setBadge(id, value?)Count or short mark on the app's tiles, start menu entry and task buttons (unread items, pending approvals); empty or 0 removes it.
registerSearchProvider(p), search(query)Global content search.
registerPermission(s), can(id), access, refreshAccess()Permissions.
registerAuthProvider(p), login(id, values?), logout(), setSession(s), session, showLogin()Authentication.
request(path, init?)Authenticated fetch.
on(event, handler)Subscribe; returns unsubscribe.
ExportscreateOidcProvider, localAuthProvider, localIdentityStore, httpIdentityStore, MANAGE_IDENTITY, localProfileStore, httpProfileStore, addMessages, setLocale, AirDesktop, VERSION and all types.

Script-tag users find the same object on window.AirWorks.

14 · Contributing

Develop & deploy

src/airworks/src/
  index.ts        public API          runtime.ts   apps, options, remote loading
  desktop.ts      <air-desktop>       windows.ts   windows, taskbar, snapping
  workspace.ts    webtops & folders   grid.ts      pure grid model (npm run check)
  grid-view.ts    paged grid view     drag.ts      pointer drag engine
  access.ts       sessions, roles     oidc.ts      OIDC provider
  apps/           settings, identity  demo/        client-only demo (GitHub Pages)

npm run check runs the grid model self-test, npm run build builds demo and docs, npm run build:package the library. Every push to main deploys demo, docs and /lib to GitHub Pages via .github/workflows/pages.yml.