Develop
Writing an app
How to write, compile, and install an app, using the on-box compiler. The working template is src/apps/example_counter.c — copy that and edit.
The 60-second version#
- Open XIDE from the launcher.
Ctrl+Nand type a name (8.3 uppercase, e.g.MYAPP.C), or pick an existing file from the tree on the left.- Write your app (structure below).
- F5 compiles it. Errors appear in the build panel; click one to jump to the line.
- F2 saves. Esc closes XIDE.
| Key | Does |
|---|---|
F5 / F6 |
build / build and run |
F2 |
save |
Ctrl+N |
new file (prompts for a name) |
Ctrl+E |
save as |
Ctrl+W |
close the current tab |
Ctrl+F |
switch focus between the file tree and the editor |
Ctrl+A |
ask Ari about the file, or about the last build error |
Esc |
close XIDE (warns once if anything is unsaved) |
In the file tree: arrows move, Enter opens, Backspace goes up a directory, the wheel scrolls, and a click selects — a second click on the same row opens it. Directories sort first, then names.
From the shell instead of the IDE:
cc /SRC/APPS/MYAPP.C -> writes /BIN/MYAPP.ELF
Status note: compiling works end to end today. Launching a freshly compiled app does not yet — see Running it at the bottom for exactly where that stands. Until then, a new app becomes real by being added to the launcher and included in the next kernel build.
Anatomy of an app#
Every XerisOS app is four things: state, a draw callback, an input handler, and a launch function.
#include "app.h"
static long g_count; /* 1. state */
static const char *g_title = "Counter";
static void counter_draw(window_t *w){ /* 2. draw */
ui_text_line(24, 52, 0x00E8E8E8u, "Count:");
}
void counter_key(window_t *w, unsigned c){ /* 3. input */
if (c == '+') g_count++;
needs_redraw = 1;
}
void launch_counter(void){ /* 4. launch */
window_t *w = create_window(140, 120, 300, 180, 0x000B1120u);
if (!w) return;
w->title_ptr = (void*)g_title;
w->draw_callback = (void*)counter_draw;
w->key_callback = (void*)app_key_shim;
w->custom_state = (void*)counter_key;
needs_redraw = 1;
}That is a complete app. There is no main().
The five things that trip people up#
1. Two coordinate systems#
This is the most common bug by a wide margin:
| Function | Coordinates | You must |
|---|---|---|
draw_rect, draw_line, draw_rounded_rect |
absolute screen | add w->x, w->y |
ui_text_line |
window-relative | add nothing |
draw_rect(w->x + 24, w->y + 104, 80, 26, COL_BTN); /* absolute */
ui_text_line(44, 112, COL_TEXT, "Reset"); /* relative */2. custom_state is not yours#
The shared keyboard trampoline app_key_shim uses custom_state to find your handler:
w->key_callback = (void*)app_key_shim;
w->custom_state = (void*)my_key_handler; /* the shim reads this */So app data lives in static globals, which is why apps written this way are single-instance. If you need custom_state for your own data, write your own key callback instead of using the shim.
3. There is no libc, and no floating point#
The kernel builds -ffreestanding -nostdlib -mno-sse -mgeneral-regs-only. No printf, no malloc, no strlen, no float. Bring your own helpers — example_counter.c has a num_to_text you can copy. Use integers and fixed-point where you would reach for a float.
xcc will reject float and double with a message saying exactly this, rather than miscompiling them.
4. Never do slow work in a draw callback#
This is the one that costs you the whole machine, not just your window.
draw_windows runs every window's draw_callback on the drawing core, between win_lock_take and win_lock_drop, with g_frame_busy raised to say the back buffer is still being composed. If your callback blocks — reading a file, fetching a URL, generating a world — you have parked the compositor inside it. It never reaches swap_buffer, so g_frame_busy is never lowered and nothing on the machine can present a frame again. It never reaches win_lock_drop either, so the BSP hangs on the next window that opens or closes. The user sees the desktop stop dead at the last frame that made it out, with a mouse pointer that still moves.
The disk would not even work: fs_pump runs only on the BSP, and the NVMe queue refuses an application core after about a millisecond rather than let it block the BSP. So the save fails and the display dies.
Three shipped apps had this — Software Update (Install), Xeris Wallet (its node queries, every 10 seconds) and Xeriscraft (new/load/save world). The shape that works is: the callback latches what was asked for, and a registered poll does the work from the main loop.
static int g_want; /* set by the click, read by the poll */
static void myapp_poll(void)
{
if (!g_want) return; /* cheap no-op is the common case */
g_want = 0;
do_the_slow_thing(); /* BSP, outside every lock */
needs_redraw = 1;
}
static void myapp_on_close(window_t *w){ (void)w; app_unregister_poll(myapp_poll); }
void launch_myapp(void)
{
window_t *w = create_window(...);
if (!w) return;
w->on_close = (void *)myapp_on_close;
app_register_poll(myapp_poll); /* the main loop calls it from now on */
}app_register_poll / app_unregister_poll are declared in app.h and implemented in applauncher.c. Register in launch_*; unregister in on_close. Unregistering is not optional for a loadable module — mcraft.o is not in KERNEL_OBJS, it is linked into XCRAFT.ELF against the kernel, so a poll left registered is a pointer into memory the kernel no longer owns.
Input callbacks (mouse_callback, key_callback) are a different matter: they only ever run on the BSP, because the drawing core's entry point skips input entirely. Blocking there is the normal cooperative design and is fine — the desktop keeps compositing on the other core while you work.
5. The outer 6px of your client area is reserved chrome#
Since W6 the window manager resizes from every edge, not just the old bottom-right grip. handle_window_click in src/core/idt.asm tests a RESIZE_BAND (6px) band inside the left, right and bottom edges before it offers the click to your mouse_callback. A click that lands in one of those bands starts a resize and never reaches your app.
Three bands, not four: there is also a RESIZE_TOP_BAND (4px), but it is measured down from w->y, which is the top of the title bar. Your callback is only reached once my >= WIN_TITLE_H, 25px lower, so the top band costs you nothing. Only left, right and bottom eat into the client area.
Both constants are in tokens.h (and tokens.inc) — use them, don't hardcode 6.
/* good: the control keeps clear of all three bands */
draw_rect(w->x + RESIZE_BAND + 2, w->y + WIN_TITLE_H + 8, 120, 24, COL_BTN);
/* bad: a 16px scrollbar flush to the right edge. Columns width-6 .. width-1
are the band, so the outer 6 of its 16 columns resize instead of scroll. */
draw_rect(w->x + w->width - 16, ..., 16, ..., COL_BAR);
/* fixed: hold it 6px off the edge (this is what xeris_web.asm now does) */
draw_rect(w->x + w->width - 16 - RESIZE_BAND, ..., 16, ..., COL_BAR);Painting into the band is fine — only clicks are intercepted, and terminal.c's scrollbar overlaps it by 3 columns harmlessly because its mouse_callback reads the wheel and ignores mx/my. What matters is anything the user has to hit: scrollbars, seek strips, the last column of a button grid. Those must start at least RESIZE_BAND px in.
If you need the full width for hit-testing, widen or inset the control rather than reaching for a way around the band — the band is what makes a window whose bottom-right corner is off-screen resizable at all.
Apps that lose a few pixels at an edge to the band but stay usable (the settings.c sidebar's left padding, files.c's footer strip) were left alone deliberately; only controls that lost visible, clickable area were moved.
What you can call#
Everything in src/apps/app.h:
window_t *create_window(long x, long y, long w, long h, u32 bg);
void draw_rect(long x, long y, long w, long h, u32 color);
void draw_rounded_rect(long x, long y, long w, long h, u32 color);
void draw_line(long x0, long y0, long x1, long y1, u32 color);
void ui_text_line(long relx, long rely, u32 color, const char *s);
extern unsigned char needs_redraw; /* set to 1 to request a repaint */
extern long mouse_dz; /* wheel delta; consume and zero it */
extern unsigned long uptime_secs;
unsigned long sh_mem_free(void);
void sh_time(char *out, int max); /* "YYYY-MM-DD HH:MM:SS" */Plus anything else the kernel exports. Files: #include "../core/fs.h" for fs_read_file / fs_write_file / fs_list. Clipboard: #include "../core/clipboard.h". The model: #include "../core/ari_service.h" for ari_generate_service().
How this links. Your app calls draw_rect, which lives in the kernel, and there is no linker here. It works because compiling inside the OS means the kernel is already loaded and its symbol addresses are known: xcc binds undefined references straight to live addresses from a table generated out of the linked kernel. The build panel reports how many it bound ("12 kernel symbols"). If you misspell one, you get:
'draw_rectt' is declared but not defined here, and the kernel has no
symbol by that name
Language support#
xcc is a C99 subset. In practice it compiles the OS's own app sources — 22 of 23 in src/apps — so if the existing apps do it, you can do it.
Supported: structs, unions, enums, typedefs, pointers, function pointers and callback tables, multi-dimensional arrays, all control flow including switch and goto, the full expression grammar, sizeof, casts, _Static_assert, __builtin_offsetof, string-literal concatenation, #include/#define/#ifdef/#if, up to 16 parameters, and calling variadic functions.
Not supported, each with a diagnostic that names the reason:
| Missing | Why |
|---|---|
float, double |
the kernel is -mno-sse; xcc is integer-only |
inline __asm__ |
xcc has no assembler — put asm in a .asm file |
va_list (defining a variadic function) |
calling one works, defining does not |
| VLAs, bitfields, designated initializers | not implemented |
One real limitation worth knowing: compound assignment evaluates its left side twice. x += 1 and a[i] |= m are fine; *p++ += 1 is not. Avoid side effects on the left of += and friends.
Installing it in the launcher#
Compiling produces an ELF, but for the app to appear on the desktop it has to be registered. In src/apps/applauncher.c:
extern void launch_counter(void); /* with the other externs */
static const tile_t g_tiles[] = {
...
{ "Counter", launch_counter, 0x1B2540u },
};Then add a build rule in the Makefile next to the other app objects:
$(BUILD_DIR)/example_counter.o: $(APPS_DIR)/example_counter.c $(APPS_DIR)/app.h
$(CC) $(CFLAGS) $< -o $@
and add $(BUILD_DIR)/example_counter.o to KERNEL_OBJS.
If the launcher's tile grid overflows, bump WH in applauncher.c — it is sized for a fixed number of rows.
Running it#
Compiling a new app works today. Launching one that was compiled on the box, without a kernel rebuild, does not — and it is worth being precise about why, because it is a single missing piece rather than a deep problem.
xccproduces a correct static ELF64 at a fixed load address, with kernel references already bound to real addresses.src/core/elf.ccan load it (parses and copiesPT_LOADsegments; host tested).sh_runincore_glue.cis still a stub. It reports:[stub] sh_run: ELF exec needs sched/user-mode activation (Stage 4).
So the loop is: write → compile → (gap) → run.
Closing it needs one decision — where a loaded module lives in memory. The kernel identity-maps 16 GB, and there is already a precedent for this pattern (the QuickJS module loads at 0x2000000 and is entered through a magic header). xcc currently targets CC_LOAD_BASE = 0x40000000 (1 GB), which is mapped, but whether that region is free depends on where the model weights are placed at boot — and those are 13 GB.
Until then, a new app ships the normal way: add it to applauncher.c and the Makefile, and rebuild.
Try the example#
src/apps/example_counter.c is a complete, commented app: a window, a number, keyboard handling (+ - [ ] r), a clickable Reset button, and a launch function. It compiles clean with both xcc and the cross-compiler.
Open it in XIDE and press F5.