A · Package (git dependency)
npm install github:fgilde/AirWorksnpm clones the repository and builds the library during install (prepare). Node 22.18+ is required.
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.
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.
npm install github:fgilde/AirWorksnpm clones the repository and builds the library during install (prepare). Node 22.18+ is required.
<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.
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)
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.<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.
AirWorks.configure(options) merges options and can be called repeatedly. assetBase, storageKey, initialApps and initialShortcuts are read when the desktop initializes.
| Option | Purpose |
|---|---|
assetBase | Base 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. |
serverBaseUrl | Prefix for AirWorks.request(path). |
request | Replaces the built-in fetch transport completely. |
loginHint | Text on the login screen, e.g. how to sign in. Providers can add their own hint. |
requireLogin | As 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. |
identityStore | Users/roles backend; enables the built-in "Users & roles" app. |
brand | Your 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). |
defaults | Settings for users without a saved profile, e.g. { wallpaper: "dark", systemColor: "#00777a", taskbarPosition: "bottom", effect: "none" }. |
wallpapers | Extra wallpapers: [{ id, title, image }] or [{ id, title, background: "linear-gradient(…)" , foreground: "#fff" }]. |
docsUrl | Adds a documentation button to the start menu; the documentation opens as a window. |
webLinks | The built-in Web link app (default true). Set false when you only want your own apps. |
storageKey | Prefix for saved profiles (default airworks; signed-in users get airworks.<user id>). |
profileStore | Where profiles (webtops, shortcuts, settings, open windows) are kept. Default: localStorage. See user profiles. |
initialShortcuts / initialApps | App ids placed on the first webtop / opened on the very first start (no saved workspace). |
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
},
});
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.
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).
An ES module URL that is imported on first open. Its default export (or exportName) is a render function or a partial app definition.
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.
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");
}
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.
// React
render: ({ host, onClose }) => {
const root = createRoot(host);
root.render(<Orders />);
onClose(() => root.unmount());
}
The search in the taskbar finds apps and content. Apps contribute content with search; anything else (typically your server) registers a search provider. Results open their app with an intent describing what to show.
AirWorks.registerApp({
id: "orders", title: "Orders", icon: "fn-reporttable.png",
search: (query) => orders
.filter((order) => order.customer.toLowerCase().includes(query))
.map((order) => ({ title: order.number, description: order.customer, intent: { order: order.id } })),
render: ({ host, intent, onIntent }) => {
show(intent?.order); // opened from a search result
onIntent((next) => show(next.order)); // window already open, new result picked
},
});
AirWorks.registerSearchProvider({
id: "server",
search: (query, { request, signal }) => request(`/search?q=${encodeURIComponent(query)}`, { signal }),
}); // returns [{ title, description?, icon?, appId, intent? }]
AirWorks.openApp("orders", { order: "4711" }); // same intent from code
Search starts at two characters, waits 200 ms after typing and cancels outdated requests through signal. Results for apps the user may not open are dropped. The demo's key ratios and the Aspire sample's server search (reports, users) use this.
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.
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.
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.
createOidcProvider (below). The user signs in at the identity provider; AirWorks receives tokens via PKCE.AirWorks.request sends the token automatically.GET /me) and plug it in as accessProvider; enforce the same permissions on every endpoint.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.
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.
extraParams: { kc_idp_hint: "github" }; the Aspire sample has this entry ready in appsettings.json.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).
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.
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.
AirWorks.configure({ profileStore: localProfileStore() });Per browser and user.
AirWorks.configure({
profileStore: httpProfileStore(AirWorks.request, "/profile"),
});
// GET /profile?key=… → profile JSON or 204
// PUT /profile?key=… ← profile JSONFollows 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.
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 }
accessProvider: its result (typically your server's /me). Recommended for real security.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.
Configure an identityStore and grant airworks.identity.manage (exported as MANAGE_IDENTITY) to administrators. The app edits users (roles), and roles (permissions).
localIdentityStore(seed?, key?)
// localStorage, for demoshttpIdentityStore(AirWorks.request, "/identity")
GET /identity/users PUT/DELETE /identity/users/{id}
GET /identity/roles PUT/DELETE /identity/roles/{id}
GET /identity/permissionsOr implement IdentityStore (listUsers, saveUser, deleteUser, listRoles, saveRole, deleteRole, listPermissions) for any backend.
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.
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.
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
http://localhost:5177 and sign in with Keycloak: demo/demo or admin/admin (local development only).airworks-user and airworks-admin. The API maps roles to permissions (reports.read, reports.export, airworks.identity.manage) and exposes them on /api/me.controller to a user or grant the client-registered permission notes.use to a role, and the Notes app appears immediately.AirWorks:Providers in AirWorks.Demo.Web/appsettings.json; it contains disabled examples for GitHub (via Keycloak brokering) and Microsoft Entra ID./api/profile), so they follow the user to every browser.https://localhost:8080 with the ASP.NET Core development certificate (dotnet dev-certs https --trust). The sample keeps users and role mappings in memory.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.
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.
| Member | Description |
|---|---|
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. |
| Exports | createOidcProvider, localAuthProvider, localIdentityStore, httpIdentityStore, MANAGE_IDENTITY, localProfileStore, httpProfileStore, addMessages, setLocale, AirDesktop, VERSION and all types. |
Script-tag users find the same object on window.AirWorks.
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.