Docs menuHow a shell works
On this page11 sections
Writing a shell
A shell is a Domicile desktop's UI: panels, window decorations, launcher,
wallpaper. It is a built web page. Domicile ships two shells (manganese and
simple); neither is special. This guide covers writing one in its own
repository.
- New here? Start with Quickstart: a React
shell in one file. Most shells use
@domicile-desktop/react. This guide's examples use thedomicileobjectShellis handed; in React,useDomicile()returns it. examples/minimal-shellis the worked example. It pins the SDK published from astablecommit and runs on that commit, and CI builds it. If this guide and the example disagree, the example is right.- A shell is trusted with the whole session. What that reaches, and what it doesn't: Security.
- To contribute to Domicile itself, see
/AGENTS.md.
More:
- The config file: the config file that describes a desktop.
- Browser windows: browser windows
(
<webview>) anddomicile.browser. - Extensions: Chrome extensions' toolbar buttons and popups.
- Displays, theme, tray, notifications: displays, theme, system tray and notifications.
- Idle and lock: blanking and the lock screen.
- Packaging: bundling and distributing a shell.
What a shell is
A JavaScript module whose Shell export is a function.
- Domicile serves the module, imports it into a page it writes, and calls
Shellwith the element to draw in. - Only
Shellis read; other exports are ignored. A config module's settings exports do not apply when it is loaded as a shell. - Importing the module may only install its stylesheet. A config module is
also imported under Bun, which has no DOM, so all DOM work goes in
Shell. - If the module fails to load, has no
Shell, orShellthrows, the page shows the error. - The page draws nothing until
Shellreturns, so draw your first frame before it does. With React,mountdoes that (With React). A shell that draws later fades in from an empty white page. - The contract is framework-agnostic.
runManganeseis manganese's React adapter. - Each Wayland window becomes an
<app>element in your page. Where you put the element is where the window is. Placing windows is the shell's job. - The compositor handles clients, input, outputs and pixels.
<app> and <webview> are tags built into the engine (Domicile's Chromium
fork). Nothing registers them. Write them as you would a <div>. <webview>
is a browser page; see Browser windows.
Style an <app> like a <div>. A window is a layer in your page's layer
tree, so CSS applies to it directly. z-index, transform, border-radius,
opacity, filter: blur() and mix-blend-mode render bit-exact against an
ordinary element (measurements).
Exceptions:
- A client that draws in shared memory instead of on the GPU is copied into a GPU buffer. Checked on NVIDIA's driver and on llvmpipe, untested on Mesa hardware drivers (AMD, Intel). With no GPU device to copy into, its window stays blank. A desktop that composites on the CPU shows it from memory (status).
backdrop-filterover an<app>renders but is not pixel-verified; see WINDOW-COMPOSITING.md.
Running it
domicile ./my-desktop/dist/shell.js- The module path is the whole interface. Name the file what you like.
- Domicile serves the directory the module is in, over
domicile://.
Dev loop: run vite build --watch next to domicile ./dist/shell.js.
Nothing reloads automatically (the page runs under --app, with no reload
button). To load a rebuilt bundle, run domicile load-shell ./dist/shell.js
in a terminal inside the desktop. Any shell works, and windows stay put
(THE-DOMICILE-BINARY.md).
If your page's renderer dies, the engine ends with a FATAL line saying
so, and the supervisor starts it again. That is a bug in Domicile, not
your shell's to handle: report it with the log
(THE-DOMICILE-BINARY.md).
Your own script errors are yours: catch them with try/catch,
window.onerror and unhandledrejection. A <webview> whose page dies
tells you instead
(Crashed pages).
The connection
Every example here is in React and in plain JavaScript: pick one with the tabs above it.
import { useDomicile } from "@domicile-desktop/react/domicile-provider";import { mount } from "@domicile-desktop/react/mount";const Desktop = () => { const domicile = useDomicile(); // the desktop // …};export const Shell = mount(Desktop);domicile, the second argument toShell, is the desktop: one typed object,Domicilefrom@domicile-desktop/sdk/shell, with a namespace per part (windows,displays,keyboard,session,portals,files,processesand more). There is nothing to configure.- It is the only copy. The engine runs your module and calls
Shellwith the desktop; there is nonavigator.domicileorwindow.domicile. Keep it however you like (a variable, or with React,<DomicileProvider>) and pass it to what needs it. - There is nothing to await and no version handshake. The browser process checks the protocol version and logs a mismatch. The first call binds the channel.
State is attributes on each namespace:
windows.list,windows.focused,displays.list,theme.current,session.locked,session.idleand more. Each namespace'son(type, listener)says when they change, and returns a function that stops listening. Read, then listen. In React, a hook does both:const displays = useDisplays(); // rerenders on each change- An attribute is
undefineduntil the compositor has sent it. - Moments (
windows.on("focusrequest"),keyboard.on("shortcut")) are events too. The engine holds each until its first listener exists, so a listener added in React's first effect misses none. - A call the compositor answers returns a promise of a
Result(@cprussin/option-result):Okwith the answer, orErrwith aSystemError.files.searchrejects an older search with anAbortErrorwhen a newer one starts. - Only Domicile calls a shell, so there is no plain-browser case to check for. Develop against a real desktop.
Errors: log to the console. Page logs reach the terminal Domicile started from. Invalid calls (an empty argv, a keycode of zero, a malformed chord) throw, because they are bugs in the page.
With React
@domicile-desktop/react, on npm, mounts a React shell and follows the
desktop with hooks:
import { mount } from "@domicile-desktop/react/mount";import { useWindows } from "@domicile-desktop/react/windows";import type { Shell as ShellModule } from "@domicile-desktop/sdk/shell";const Taskbar = () => ( <ul> {useWindows().map(({ appId, title }) => ( <li key={appId}>{title}</li> ))} </ul>);export const Shell: ShellModule = mount(Taskbar);mount(App)is aShell(or an app'smain). It drawsAppbefore returning, inStrictMode, under<DomicileProvider>, and shows the error in its place ifAppthrows.<DomicileProvider domicile>is whatmountrenders. Use it to mount yourself.useDomicile()is the desktop.useAppDomicile()is an app's./// <reference types="@domicile-desktop/react/elements" />types<app>and<webview>in JSX.- Hooks follow the desktop's state:
useWindows,useFocusedWindow,useDisplays,useAppearance,useIdle,useLocked, andusePortalRequestsand the other portal parts. Each rerenders on change. useWatch(namespace, type, read)follows any other attribute.- Tests: render under
<FakeDomicile fake={fake}>(@domicile-desktop/react/fake-domicile) and drivefake, aFakeDomicileHost.
See the package's README.
The SDK
@domicile-desktop/sdk, on npm, provides:
Domicile(the type of the desktopShellis handed) andShellbindKeysFakeDomicileHost(./fake-host), a desktop for your tests- types and event names for
<app>and<webview>
Versions are 0.0.0-alpha-<sha>, one per main commit, under the next
dist-tag; there is no semver release yet. Depend on the exact version whose <sha> matches the Domicile commit
you run, as the example does.
The desktop Shell is handed is the API; the SDK is its types plus helpers.
The engine builds it from the SDK's own src/desktop/ over the host its IDL
describes, in
packages/domicile-engine/src/third_party/blink/renderer/modules/domicile/.
The engine routes the pointer and keyboard over an <app> to its client, in
the client's own coordinates through every CSS transform, so no helper is
required.
Tests: hand FakeDomicileHost's domicile to your shell. set changes
attributes and says they changed, appear, change and close edit the
windows, dispatch fires a moment, and calls records every call the shell
made of the host underneath.
@domicile-desktop/component-library is the React and Panda CSS design
system this repo's shells use. Shells do not need it.
The smallest shell
/// <reference types="@domicile-desktop/react/elements" />import { mount } from "@domicile-desktop/react/mount";import { useWindows } from "@domicile-desktop/react/windows";const Desktop = () => useWindows().map(({ appId }) => <app app-id={appId} key={appId} />);export const Shell = mount(Desktop);This is a working desktop: every window full-screen, newest on top.
- Domicile imports the module and then calls
Shellonce. Do no work at import time except CSS. Other exports are ignored. A missing or throwingShellis reported on screen. - Remove the elements
windows.listno longer lists, or every closed window leaks an element. React does this for you. - Domicile writes the document: a charset, a viewport, and a
<body>with no margin that fills the window. You cannot supply one. - Only script files next to your module run: no inline scripts,
on…attributes oreval. See Packaging. rootis the empty<body>. Create your own container in it and render there, asmountdoes. Errors are reported by appending toroot, and a framework that ownsrootwould wipe them.- There is no
#rootelement.document.getElementById("root")returnsnull, and the only symptom is a white window. - Make the background transparent wherever a window should show. A background
painted over an
<app>hides it. - Set
document.titleif you want a title other than Domicile's. - This shell answers no portal requests, so an app's portal dialog, a bare
domicile screenshotanddomicile screen-recordwait until the desktop exits. Answer or refuse them asexamples/minimal-shelldoes; see Dialogs and portals.
Popups
A client's menus and tooltips are entries in windows.list with parent
set:
appId: the popup's own idparent: the window or popup (for a submenu) it belongs tox,y: CSS pixels from the top-left of the parent's box;width,height: its sizegrab:truefor a menu,falsefor a tooltip
To show one:
- Mount an
<app>for it at that position, above its parent. - Give it no frame and do not tile it. Draw it only where its window is drawn.
- Remove it when
windows.listdrops it. - The engine routes a click on it to its window's keyboard.
- The compositor closes a menu when the keyboard leaves its window.
- A popup placed near a screen edge is not moved back on screen.
Windows
Each entry in domicile.windows.list describes one client window.
- Identity:
appIdis the compositor's id for the window, which<app>takes.desktopIdis the client's Wayland app id (set_app_id), which names its desktop entry, for exampleorg.gnome.Nautilusfororg.gnome.Nautilus.desktop. It is""until the client sets one. Look up the entry'sIcon=to draw the app's icon. - Owner:
pid,uidandgidname the process that connected the window's client, as the kernel recorded it (SO_PEERCRED). The client cannot forge them;titleanddesktopIdare its own claims.pidfdInodeis the inode of that process's pidfd. No other process gets it until reboot, so it tells a reused pid apart.undefinedbefore Linux 6.9.- All of these are
undefinedfor a popup; read its parent's. - A process inside a container has its pid in the compositor's pid namespace.
- Sandbox:
sandboxedistruewhen the client connected through a sandbox engine'swp_security_context_v1listener. Such a client sees no data control or screen capture globals (Dialogs and portals).sandboxEngine,sandboxAppIdandsandboxInstanceIdare what the engine said, for exampleorg.flatpak, the Flatpak app id and its instance id.undefinedwhere it said nothing.- They come from
wp_security_context_v1, so they are claims by whichever client created the context. Any unsandboxed client can create one with any values. sandboxInstanceIdis passed through verbatim and is fixed for the client's life.- Don't base a security decision on them alone. Check them against
pid,uidandgid, for example the pid plus its start time orpidfdInode.
- Cursor:
cursoris the CSS cursor a client wants over its window. Set it withelement.style.cursor = cursor(in React,<app style={{ cursor }}>). - Size: the
<app>element's layout box is the window size. The engine sends it to the client. Resize a window by styling its element. Setresizingon the element while the user drags an edge; see <app> and <webview>. - On screen: listed means drawn, not shown. Wait for
domicile-presentedon the<app>before revealing a window, for example to start an open animation.domicile-suspendedanddomicile-resumedsay when its client stops and starts drawing. See <app> and <webview>. - Listed when drawn: a window joins
windows.listwith its first frame, so its title,desktopId, limits and hints are already set. - Drawn size:
widthandheightare the size the client drew at. The engine already uses them for pointer mapping, so you only need them for your own UI, such as sizing a dialog. - Dialogs:
dialogistruewhen the client gave the window a parent or a fixed size, as sway reads them.shell-manganesefloats such a window at its drawn size.dialogis fixed at the first frame. - Parent:
transientForis theappIdof the window this one is a dialog for (xdg_toplevel.set_parent), orundefined. It follows every change. When the parent closes, it names the parent's own parent, as xdg-shell says. - Anchor:
anchornames the place in your page the client asked to be laid out at (domicile_anchor_v1), or isundefined. It follows every change. The names are yours to define. Lay the window's<app>out inside your anchor element and it moves with your layout in the same frame. Treat a name you have no anchor for asundefined.shell-manganesedefines none. See Anchored windows. - Urgency:
urgentistruewhen the compositor dropped the window's focus request (see A client asking for focus) and the window has not had the keyboard since. It clears when the window gets the keyboard, and is never set on the window holding it. Draw it as an attention hint, such as a highlighted workspace or title. - Client frames:
clientFrameistruewhen the client asked to draw its own frame.shell-manganeseleaves its title bar off such a window while it floats. - Synced frames:
frameis the frame to set as the window's<app frame>, in the same render as the change that came with it, such as a popup's move. Calldomicile.windows.syncFrames()once a page renders it: only then does the compositor sync frames. It lasts until the page loads again. An<app>with noframeshows the newest, so a shell that ignores frames needs neither. See Synced frames. - Shortcut inhibitors:
inhibitsShortcutsistruewhile the client holdszwp_keyboard_shortcuts_inhibitor_v1, as a VM or remote desktop viewer does. A change fireswindows.on("change"). See Keybindings. - Limits:
minWidth,minHeight,maxWidthandmaxHeightgive the client's limits (undefinedfor none). Outside them, the client's frame is cut off or stretched.shell-manganesekeeps floating windows within them. - Multiple monitors: the page spans every monitor, so a window across two
monitors is one
<app>, and dragging across the edge is one drag.
Window states
Tell a client how its window is laid out with its xdg_toplevel states:
import { WindowState } from "@domicile-desktop/sdk/window-state";await domicile.windows.setStates(appId, [ WindowState.TiledLeft, WindowState.TiledRight, WindowState.TiledTop, WindowState.TiledBottom,]);- Tiled states say something touches that edge. A client that draws its own frame then draws no shadow, rounded corners or resize border there.
MaximizedandFullscreentell the client it fills its box. They do not size the window; the<app>box does.- Each call replaces the states the shell set and configures the client. The
compositor's own states (
activated,suspended) are kept. - States outlive a shell reload. Set them again for every window you lay out; setting the same states configures nothing.
- A client bound below
xdg_wm_baseversion 2 has no tiled states. Given any tiled state and noFullscreen, it getsmaximizedinstead, as in sway, so it drops its shadows and takes its box's size. A client asking to unmaximize stays maximized until the shell's states change. - An unknown
appIdfails withSystemErrorKind.NotFound.
A client asks to be maximized or fullscreen (a terminal's fullscreen key, a video player). The compositor answers with a configure of the states it has and reports the request. Nothing changes until the shell sets states.
domicile.windows.requests()streams each request as aWindowRequest:Maximize,Unmaximize,FullscreenorUnfullscreen, with itsappId.Fullscreencarries thedisplaythe client named, if any.- A shell that does not listen ignores every request.
shell-manganese tiles with all four tiled states, sets none on floats, and
makes a window fullscreen while its client asks to be.
Who gets the keyboard
The compositor delivers keys, but your shell decides where they go. With no handling, a click focuses the window under it and a client's focus request is ignored.
A click on a window
The engine fires a cancelable, bubbling domicile-focus-requested
(APP_FOCUS_REQUESTED_EVENT) on the clicked <app>. Unhandled, it focuses
that client. A popup's press asks for its window. A preventDefault() on the
pointerdown itself takes the whole press: the client gets none of it and
nothing is asked. To apply your own policy:
import type { AppFocusRequest } from "@domicile-desktop/sdk/app-element";import { APP_FOCUS_REQUESTED_EVENT } from "@domicile-desktop/sdk/app-element";const useFocusPolicy = () => { const domicile = useDomicile(); useEffect(() => { const asked = (event: Event) => { const { appId } = (event as CustomEvent<AppFocusRequest>).detail; event.preventDefault(); if (myPolicySays(appId)) { domicile.windows.focus(appId); } }; document.addEventListener(APP_FOCUS_REQUESTED_EVENT, asked); return () => document.removeEventListener(APP_FOCUS_REQUESTED_EVENT, asked); }, [domicile]);};Key events go to document, and the engine forwards them to the focused
client. domicile.windows.focus moves both the compositor's seat and where the
page's keys go.
Taking the keyboard for your own UI
Something always holds the keyboard. It leaves a window when another window takes it, when a click lands on the shell's own UI, or when you call:
domicile.windows.focusShell();- Call it when you open a panel to type into (launcher, switcher, palette). Otherwise the window underneath keeps receiving the keystrokes.
- Give focus back from whatever normally assigns it. In
shell-manganese, an effect inAppWindowasks for the keyboard whenever it is elsewhere, which also restores it when a panel closes.
A click on your window decorations
The engine treats a press outside every <app> as the page taking the
keyboard. That is wrong for a title bar or drag handle you drew for a window.
Before taking it, the engine fires a cancelable domicile-focus-release-requested on the
<app> that holds the keyboard. Cancel it to keep focus on the window:
import type { AppFocusReleaseRequest } from "@domicile-desktop/sdk/app-element";import { APP_FOCUS_RELEASE_REQUESTED_EVENT } from "@domicile-desktop/sdk/app-element";useEffect(() => { const asked = (event: Event) => { const { appId, pressed } = (event as CustomEvent<AppFocusReleaseRequest>) .detail; if (isChromeFor(appId, pressed)) { event.preventDefault(); } }; document.addEventListener(APP_FOCUS_RELEASE_REQUESTED_EVENT, asked); return () => document.removeEventListener(APP_FOCUS_RELEASE_REQUESTED_EVENT, asked);}, []);shell-manganese tags each floating window's bar and drag sheet with its
window and checks that here.
JSX: <app> has no hyphen, so React treats it as a plain HTML element and
ignores unknown on… props. Bind these events with addEventListener on a
ref. The same applies to <webview> events.
A client asking for focus
windows.on("focusrequest", (appId) => …) is xdg-activation: an app
asking to come forward (for example, a browser asked to open a link). The
compositor does not grant it. Call domicile.windows.focus(appId) to grant
it, or ignore it.
Only requests made since the keyboard last moved arrive. The compositor drops a
token whose serial is older than the keyboard's last enter, or has none: that
is a window taking focus back on its own, not something the user did. Such a
window is marked urgent instead. An app that holds the keyboard may pass it to
another of its windows with any token made for input, as Slack does when a
click raises its main window and then a huddle's.
A request that arrives is the shell's to decide, so the compositor marks no
window urgent for it: ignoring a request is silent, and the compositor cannot
tell it apart from a grant still on its way. A shell that ignores one and wants
it shown keeps its own mark.
The compositor mints its own tokens for what the user does in the shell:
processes.launch (as XDG_ACTIVATION_TOKEN) and a notification press (as
ActivationToken). So a singleton app launched again, or an app whose
notification was pressed, asks with focusrequest. Only the newest such
token is good, for 10 seconds.
Where focus is
domicile.windows.focused is the client that holds the keyboard, undefined
while the page holds it; windows.on("focuschange") reports changes. Use it,
not your own idea of the active window, to know where keys go. If a focused
client crashes, the compositor gives the keyboard to the page; windows.list
drops the window first, so you can focus another.
Focus follows the mouse
If focus follows the pointer, a keyboard focus change can be undone at once:
the old window is still under the pointer. Move the pointer with
domicile.pointer.warp(x, y), in page coordinates (clientX/clientY).
- Points outside the page are clamped.
- In a nested run inside another compositor, nothing moves.
shell-manganesewarps on its own focus changes (keyboard-driven, or a new window taking focus) when the pointer is not already over the window.
Window scale on several monitors
Call domicile.windows.setBounds(appId, { x, y, width, height }) with each
<app>'s box in page pixels whenever it moves or resizes. The client draws at
the scale of the monitor holding most of the box. A window you never report
draws for the densest monitor. shell-manganese reports from AppWindow.
Report a window you hide (display: none, another workspace) as
setBounds(appId, { height: 0, width: 0, x: 0, y: 0 }). Its client then draws about once a second:
the compositor holds its frame callbacks and marks it suspended until you
report a box again. A window being cast draws on. A theme change does not wait
for it. A new page starts with every window shown.
Keybindings
A shell's keys come from its own props. Each chord maps to a shell command or a binding mode, like sway:
import { bindKeys } from "@domicile-desktop/sdk/bind-keys";import { KeyAction } from "@domicile-desktop/sdk/key-action";const KEYS = { keybindings: { "Meta+l": KeyAction.SendShell(["focus", "right"]), "Meta+r": KeyAction.Mode("resize"), }, modes: { resize: { "Meta+l": KeyAction.SendShell(["grow", "right"]), "Meta+Escape": KeyAction.Mode("default"), }, },};const Keys = () => { const domicile = useDomicile(); const [mode, setMode] = useState("default"); useEffect( () => bindKeys(domicile, KEYS, { onCommand: (args) => run(args), // ["focus", "right"] onModeChanged: setMode, // "resize", "default" }).unbind, [domicile], ); return mode === "default" ? null : <p>{mode}</p>;};- A chord is modifiers (
Meta,Shift,Ctrl,Alt) plus an xkb keysym name, joined by+. - The engine finds the key each keysym is on in the configured layout, so
Meta+parenleftworks on any layout and follows layout changes. - A malformed chord throws before anything is grabbed. A keysym the keyboard cannot type is logged, and the other keys still work.
bindKeysgrabs every chord by name (domicile.keyboard.grab), handles eachshortcutby itschordwhether the press landed on the page or in a<webview>, handlesModeitself, and passes everySendShelltoonCommand.domicile send-shell <word>…from a terminal reachesonCommandtoo, on every page bound. A page that cannot listen callsreport, which logs by default.- It returns
{ setMode, unbind }.setModekeeps one mode across a desktop of several pages. - Command meanings are yours. Document them in your README, as
manganese does. Manganese takes keys
from
runManganese({ keybindings }), defaulting to sway's. - While bound, a key bound in any mode is taken from every client.
unbindgives the chords back (domicile.keyboard.ungrab). Eachgrabneeds its ownungrab. - A window with
inhibitsShortcutsgets grabbed chords as keys while it has the keyboard; noshortcutfires. Let keys through in your own listeners too. Design: KEYBINDINGS.md.
The configuration
The user describes the desktop (displays, keyboard, idle, lock, theme, extensions) in one config file. The compositor reloads it on every change. See The config file.
- Settings of your own shell go in
shell_options; see Options. - The shell owns any other user-facing settings: its own location, schema and names.
@domicile-desktop/sdkdoes not parse the config. Its schema is thedomicile-configcrate's, and there is no TypeScript parser for it.
Options
Options are settings a user changes without editing the shell's code. Declare
them, and the user sets them under shell_options in the config or on the
Settings app's Shell page (SETTINGS.md).
import { defineOptions, option } from "@domicile-desktop/sdk/options";export const options = defineOptions({ clock: { format: option.choice("Clock format", ["24h", "12h"], "24h"), seconds: option.toggle("Show seconds", true), }, gaps: option.number("Gap between windows", 8, { max: 64, min: 0 }),});{ "shell_options": { "clock": { "format": "12h" }, "gaps": 4 } }Read them with watchShellOptions:
import type { OptionValues } from "@domicile-desktop/sdk/options";import { watchShellOptions } from "@domicile-desktop/sdk/options";const useOptions = () => { const domicile = useDomicile(); const [values, setValues] = useState<OptionValues<typeof options>>(); useEffect(() => watchShellOptions(domicile, options, setValues), [domicile]); return values; // undefined until the first read, in the effect};- Kinds:
toggle,choice,number(with an optionalminandmax),textandlist(of text). A plain object is a group. Each option has a label and a default.defineOptionsthrows on a default that its own choices or limits refuse. - Export it from the shell's entry as
options. The builder writes it tooptions.jsonbesideshell.js. A prebuilt shell passes it toshellBuild(@domicile-desktop/component-library/vite-shell). A shell with nooptionsexport has no options. watchShellOptions(domicile, options, listener)callslistenerwith the values now and on every change. A change needs no rebuild or reload. manganese's React hook isuseShellOptions(@domicile-desktop/manganese-host/useShellOptions).- An option the config leaves out takes its default. An unknown key or a bad value is dropped and logged, so the shell starts whatever the file says.
- The Settings app refuses to write a value the declaration refuses. A config
module that computes
shell_optionsis read-only in the form, like its other settings. - Arguments in code win over options. Say in your docs which arguments shadow which options; the form cannot see them.
- On the wire: the compositor sends the JSON text in
shell_config, and the desktop exposes it asdomicile.shell.optionswith ashell.on("optionschange")event (CONTROL-CHANNEL.md).
Key decisions:
- Values in JSON over rewriting code. A JSON key can be written safely; a call's arguments cannot.
- A declaration over reading prop types. Types are gone at run time, and a form needs labels, defaults and limits.
- One opaque key over a section per shell. The compositor's schema stays closed and does not change when a shell adds an option.
- Drop bad values in the shell; refuse them in the host. The shell must start whatever the file says; the app should never write a bad value.