Files

452 lines
18 KiB
C++

/*
* app_catalog.hpp
* The app catalog shared by the desktop and Files: installed app manifests
* (0:/apps/<dir>/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 <montauk/syscall.h>
#include <montauk/string.h>
#include <montauk/heap.h>
#include <montauk/toml.h>
#include <montauk/config.h>
#include <gui/desktop.hpp>
#include <gui/svg.hpp>
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/<dir>/<binary>
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
// <home>/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