452 lines
18 KiB
C++
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
|