/* * app_catalog.hpp * The app catalog shared by the desktop and Files: installed app manifests * (0:/apps//manifest.toml) plus the desktop's built-in settings pages, * and the category, description and search keywords the Settings app * shows for each settings page * Copyright (c) 2026 Daniel Hammer */ #pragma once #include #include #include #include #include #include #include namespace gui { // Settings app categories, in display order. A manifest may name any other // [settings] category; the Settings app lists those after these, by name. inline constexpr const char* kSettingsCategories[] = { "Personalization", "Network", "Devices", "Time & Language", "Accounts", "Privacy & Security", "System", }; inline constexpr const char* kSettingsDefaultCategory = "System"; // Built-in settings pages the desktop implements itself. Other processes // list them by id and open them with a WIN_REQ_LAUNCH_ITEM request. struct CatalogBuiltinPage { const char* id; const char* label; const char* icon_path; const char* category; const char* description; const char* keywords; }; inline constexpr CatalogBuiltinPage kCatalogDesktopSettings = { "desktop-settings", "Desktop Settings", "0:/os/icons/preferences-desktop-apps.svg", "Personalization", "Wallpaper, accent color, panel and clock", "background wallpaper image accent color colour theme appearance panel " "shadows clock 24-hour scale" }; inline constexpr CatalogBuiltinPage kCatalogUserManagement = { "user-management", "User Management", "0:/os/icons/user-home.svg", "Accounts", "Add, remove and edit user accounts", "users accounts password login admin administrator role display name" }; inline constexpr const CatalogBuiltinPage* kCatalogBuiltinPages[] = { &kCatalogDesktopSettings, &kCatalogUserManagement, }; inline const CatalogBuiltinPage* app_catalog_builtin_page(const char* id) { for (const CatalogBuiltinPage* page : kCatalogBuiltinPages) { if (id && montauk::streq(page->id, id)) return page; } return nullptr; } // Copy a built-in page's Settings app metadata onto its catalog item. inline void app_catalog_apply_page_info(DesktopItem* item, const CatalogBuiltinPage* page) { if (!item || !page) return; montauk::strncpy(item->settings_category, page->category, sizeof(item->settings_category)); montauk::strncpy(item->description, page->description, sizeof(item->description)); montauk::strncpy(item->keywords, page->keywords, sizeof(item->keywords)); } // True if the Settings app lists the item: every settings-section item, plus // any other app whose manifest names a [settings] category. inline bool app_catalog_in_settings(const DesktopItem& item) { return item.section == DESKTOP_ITEM_SECTION_SETTINGS || item.settings_category[0]; } // out = a + b + c (+ d + e), truncated to out_max. inline void app_catalog_join(char* out, int out_max, const char* a, const char* b, const char* c, const char* d = "", const char* e = "") { const char* parts[5] = { a, b, c, d, e }; int n = 0; for (const char* part : parts) { for (int i = 0; part && part[i] && n < out_max - 1; i++) out[n++] = part[i]; } if (out_max > 0) out[n] = '\0'; } // Extract the basename from a readdir entry, tolerating both current // direct-child names and paths returned by older ramdisk kernels. inline void app_catalog_basename(char* out, int outSz, const char* entry) { // Strip trailing slash int len = montauk::slen(entry); while (len > 0 && entry[len - 1] == '/') len--; // Find last slash before that int last_slash = -1; for (int i = 0; i < len; i++) { if (entry[i] == '/') last_slash = i; } const char* base = (last_slash >= 0) ? entry + last_slash + 1 : entry; int base_len = len - (last_slash >= 0 ? last_slash + 1 : 0); if (base_len >= outSz) base_len = outSz - 1; montauk::memcpy(out, base, base_len); out[base_len] = '\0'; } // Return a slot for the next desktop item, doubling the array as needed. // Returns nullptr if allocation failed. inline DesktopItem* app_catalog_next_slot(DesktopState* ds) { if (ds->desktop_item_count >= ds->desktop_item_capacity) { int new_cap = ds->desktop_item_capacity ? ds->desktop_item_capacity * 2 : 32; auto* grown = (DesktopItem*)montauk::realloc( ds->desktop_items, (uint64_t)new_cap * sizeof(DesktopItem)); if (!grown) return nullptr; ds->desktop_items = grown; ds->desktop_item_capacity = new_cap; } return &ds->desktop_items[ds->desktop_item_count]; } // Append every valid app manifest under 0:/apps to ds->desktop_items. inline void app_catalog_add_manifest_apps(DesktopState* ds) { // Ensure the apps directory exists montauk::fmkdir("0:/apps"); // Page through the directory with readdir_at; a single readdir call // returns at most one batch and would silently drop apps past it. constexpr int BATCH = 128; const char* entries[BATCH]; int scanned = 0; int got; while ((got = montauk::readdir_at("0:/apps", entries, BATCH, scanned)) > 0) { scanned += got; for (int i = 0; i < got; i++) { // Strip the directory marker and tolerate old root-relative entries. char dirname[64]; app_catalog_basename(dirname, sizeof(dirname), entries[i]); if (dirname[0] == '\0') continue; // Try to open the manifest in this app directory char manifest_path[128]; app_catalog_join(manifest_path, sizeof(manifest_path), "0:/apps/", dirname, "/manifest.toml"); int fh = montauk::open(manifest_path); if (fh < 0) continue; uint64_t sz = montauk::getsize(fh); if (sz == 0 || sz > 4096) { montauk::close(fh); continue; } char* text = (char*)montauk::malloc(sz + 1); if (!text) { montauk::close(fh); continue; } int bytes_read = montauk::read(fh, (uint8_t*)text, 0, sz); montauk::close(fh); if (bytes_read != (int)sz) { montauk::mfree(text); continue; } text[sz] = '\0'; auto doc = montauk::toml::parse(text); montauk::mfree(text); // Read manifest fields const char* id = doc.get_string("app.id", dirname); const char* name = doc.get_string("app.name", "Unknown"); const char* binary = doc.get_string("app.binary", ""); const char* icon_file = doc.get_string("app.icon", ""); const char* category = doc.get_string("menu.category", "Productivity"); // "Applications" was the old catch-all; keep older manifests out of // a stray custom category. if (!category[0] || montauk::streq(category, "Applications")) category = "Productivity"; bool visible = doc.get_bool("menu.visible", true); bool launch_with_home = doc.get_bool("launch.pass_home_dir", true); const char* section_name = doc.get_string("desktop.section", "apps"); bool admin_only = doc.get_bool("desktop.admin_only", false); // How the Settings app files and finds the item (see // app_catalog_in_settings). const char* settings_category = doc.get_string("settings.category", ""); const char* description = doc.get_string("settings.description", ""); const char* keywords = doc.get_string("settings.keywords", ""); if (id[0] == '\0' || binary[0] == '\0') { montauk::print("app catalog: skipping app manifest with missing id or binary\n"); doc.destroy(); continue; } bool duplicate = false; for (int existing = 0; existing < ds->desktop_item_count; existing++) { if (montauk::streq(ds->desktop_items[existing].id, id)) { duplicate = true; break; } } if (duplicate) { montauk::print("app catalog: skipping app manifest with duplicate id\n"); doc.destroy(); continue; } DesktopItemSection section = DESKTOP_ITEM_SECTION_APPS; if (montauk::streq(section_name, "settings")) { section = DESKTOP_ITEM_SECTION_SETTINGS; } else if (montauk::streq(section_name, "hidden")) { section = DESKTOP_ITEM_SECTION_HIDDEN; } else if (!montauk::streq(section_name, "apps")) { montauk::print("app catalog: skipping app manifest with invalid desktop.section\n"); doc.destroy(); continue; } DesktopItem* item = app_catalog_next_slot(ds); if (!item) { doc.destroy(); return; } montauk::memset(item, 0, sizeof(*item)); montauk::strncpy(item->id, id, sizeof(item->id)); montauk::strncpy(item->name, name, sizeof(item->name)); montauk::strncpy(item->category, category, sizeof(item->category)); item->menu_visible = visible; item->launch_with_home = launch_with_home; item->admin_only = admin_only; item->launch_kind = DESKTOP_ITEM_LAUNCH_EXECUTABLE; item->builtin_id = -1; item->section = section; if (!settings_category[0] && section == DESKTOP_ITEM_SECTION_SETTINGS) settings_category = kSettingsDefaultCategory; montauk::strncpy(item->settings_category, settings_category, sizeof(item->settings_category)); montauk::strncpy(item->description, description, sizeof(item->description)); montauk::strncpy(item->keywords, keywords, sizeof(item->keywords)); // Build full binary path: 0:/apps// app_catalog_join(item->binary_path, sizeof(item->binary_path), "0:/apps/", dirname, "/", binary); // File types the app opens, used by Files for default apps and // "Open With". Stored lowercased as " ext1 ext2 " for whole-word // matching; entries that would overflow the buffer are dropped. if (auto* types = doc.get_array("files.types")) { int len = 0; item->file_types[len++] = ' '; for (int t = 0; t < types->array.count; t++) { auto* v = types->array.items[t]; if (v->type != montauk::toml::Type::String || !v->str[0]) continue; const char* ext = v->str[0] == '.' ? v->str + 1 : v->str; int elen = montauk::slen(ext); if (elen == 0 || len + elen + 2 > (int)sizeof(item->file_types)) continue; for (int k = 0; k < elen; k++) { char ch = ext[k]; item->file_types[len++] = (ch >= 'A' && ch <= 'Z') ? ch + 32 : ch; } item->file_types[len++] = ' '; } item->file_types[len < 2 ? 0 : len] = '\0'; } // Load icon from app directory item->icon = {}; item->icon_path[0] = '\0'; if (icon_file[0]) { app_catalog_join(item->icon_path, sizeof(item->icon_path), "0:/apps/", dirname, "/", icon_file); Color defColor = colors::ICON_COLOR; item->icon = svg_load(item->icon_path, 20, 20, defColor); } doc.destroy(); ds->desktop_item_count++; } } } // The built-in settings pages as catalog items, for processes other than the // desktop (which builds its own from its registry). icon_size 0 skips // loading icons. inline void app_catalog_add_builtin_pages(DesktopState* ds, int icon_size) { for (const CatalogBuiltinPage* page : kCatalogBuiltinPages) { DesktopItem* item = app_catalog_next_slot(ds); if (!item) return; montauk::memset(item, 0, sizeof(*item)); montauk::strncpy(item->id, page->id, sizeof(item->id)); montauk::strncpy(item->name, page->label, sizeof(item->name)); montauk::strncpy(item->icon_path, page->icon_path, sizeof(item->icon_path)); if (icon_size > 0) item->icon = svg_load(page->icon_path, icon_size, icon_size, colors::ICON_COLOR); item->section = DESKTOP_ITEM_SECTION_SETTINGS; item->launch_kind = DESKTOP_ITEM_LAUNCH_BUILTIN; item->builtin_id = -1; app_catalog_apply_page_info(item, page); ds->desktop_item_count++; } } inline int app_catalog_compare_ci(const char* a, const char* b) { while (*a && *b) { char ca = (*a >= 'A' && *a <= 'Z') ? (*a + 32) : *a; char cb = (*b >= 'A' && *b <= 'Z') ? (*b + 32) : *b; if (ca != cb) return ca - cb; a++; b++; } return (unsigned char)*a - (unsigned char)*b; } // Indices of the catalog items in `section` the session may see, sorted by // name. inline int app_catalog_list_indices(DesktopState* ds, DesktopItemSection section, int* out, int max_entries) { if (!ds || !out || max_entries <= 0) return 0; int written = 0; for (int i = 0; i < ds->desktop_item_count && written < max_entries; i++) { const DesktopItem& item = ds->desktop_items[i]; if (item.section != section) continue; if (item.admin_only && !ds->is_admin) continue; out[written++] = i; } for (int i = 1; i < written; i++) { int tmp = out[i]; int j = i - 1; while (j >= 0 && app_catalog_compare_ci(ds->desktop_items[tmp].name, ds->desktop_items[out[j]].name) < 0) { out[j + 1] = out[j]; j--; } out[j + 1] = tmp; } return written; } // ---- File associations ------------------------------------------------------ // // Which app opens a file: the user's "Always use" choice from // /config/associations.toml ([open_with] ext = "app id"), else the // first app (by name) whose manifest lists the extension in files.types. inline constexpr const char* kAssociationsConfig = "associations"; inline bool app_catalog_launchable(const DesktopState* ds, const DesktopItem& item) { if (item.launch_kind != DESKTOP_ITEM_LAUNCH_EXECUTABLE || !item.binary_path[0]) return false; return !(item.admin_only && !ds->is_admin); } // file_types is stored as " ext1 ext2 ", so a whole-word match is " ext ". inline bool app_catalog_claims(const DesktopItem& item, const char* ext) { if (!ext || !ext[0] || !item.file_types[0]) return false; int elen = montauk::slen(ext); for (const char* p = item.file_types; *p; p++) { if (*p != ' ') continue; int k = 0; while (k < elen && p[1 + k] == ext[k]) k++; if (k == elen && p[1 + elen] == ' ') return true; } return false; } // The config writer emits bare keys, so only extensions made of bare-key // characters can be remembered. inline bool app_catalog_ext_storable(const char* ext) { if (!ext || !ext[0] || montauk::slen(ext) > 15) return false; for (const char* p = ext; *p; p++) { char c = *p; bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; if (!ok) return false; } return true; } inline void app_catalog_assoc_key(char* out, int out_max, const char* ext) { app_catalog_join(out, out_max, "open_with.", ext, ""); } // Lowercased extension after the last '.', "" if none (".profile" has none). inline void app_catalog_extension(const char* name, char* out, int out_max) { if (!out || out_max <= 0) return; out[0] = '\0'; if (!name) return; const char* dot = nullptr; for (const char* p = name; *p; p++) { if (*p == '.') dot = p; if (*p == '/') dot = nullptr; } if (!dot || dot == name || dot[-1] == '/' || !dot[1]) return; int n = 0; for (const char* p = dot + 1; *p && n < out_max - 1; p++) { char ch = *p; out[n++] = (ch >= 'A' && ch <= 'Z') ? ch + 32 : ch; } out[n] = '\0'; } // Default app for ext as a desktop_items index, or -1. inline int app_catalog_default_for(DesktopState* ds, const char* ext) { if (!ds || !ext || !ext[0]) return -1; if (ds->current_user[0] && app_catalog_ext_storable(ext)) { auto doc = montauk::config::load_user(ds->current_user, kAssociationsConfig); char key[48]; app_catalog_assoc_key(key, sizeof(key), ext); const char* id = doc.get_string(key, ""); int found = -1; for (int i = 0; id[0] && i < ds->desktop_item_count; i++) { const DesktopItem& item = ds->desktop_items[i]; if (montauk::streq(item.id, id) && app_catalog_launchable(ds, item)) { found = i; break; } } doc.destroy(); if (found >= 0) return found; } // Several apps may claim an extension; pick by name so the choice does // not depend on directory enumeration order. int best = -1; for (int i = 0; i < ds->desktop_item_count; i++) { const DesktopItem& item = ds->desktop_items[i]; if (!app_catalog_launchable(ds, item) || !app_catalog_claims(item, ext)) continue; if (best < 0 || app_catalog_compare_ci(item.name, ds->desktop_items[best].name) < 0) best = i; } return best; } // True if the file looks like text (no NULs / stray control bytes in its // first KiB). Empty files count as text. inline bool app_catalog_file_looks_textual(const char* path) { int fd = montauk::open(path); if (fd < 0) return false; uint64_t size = montauk::getsize(fd); if (size == 0) { montauk::close(fd); return true; } uint8_t buf[1024]; uint64_t want = size < sizeof(buf) ? size : sizeof(buf); int got = montauk::read(fd, buf, 0, want); montauk::close(fd); if (got <= 0) return false; // NUL or C0 control bytes other than tab/newline/CR/form feed/escape mean // binary data. Bytes >= 0x80 pass: UTF-8 and the 1252 fallback are text. for (int i = 0; i < got; i++) { uint8_t b = buf[i]; if (b >= 0x20 || b == '\t' || b == '\n' || b == '\r' || b == 0x0C || b == 0x1B) continue; return false; } return true; } } // namespace gui