Develop

Writing an app

Source APP_HOWTO.md Synced 2026-10-07 · b38e70a

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#

  1. Open XIDE from the launcher.
  2. Ctrl+N and type a name (8.3 uppercase, e.g. MYAPP.C), or pick an existing file from the tree on the left.
  3. Write your app (structure below).
  4. F5 compiles it. Errors appear in the build panel; click one to jump to the line.
  5. 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.

  • xcc produces a correct static ELF64 at a fixed load address, with kernel references already bound to real addresses.
  • src/core/elf.c can load it (parses and copies PT_LOAD segments; host tested).
  • sh_run in core_glue.c is 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.

This page mirrors the file in the XerisOS repository. Changes go there. Edit on GitHub