1062 lines
42 KiB
C++
1062 lines
42 KiB
C++
/*
|
|
* main.cpp
|
|
* MontaukOS Desktop Environment - initialization, external window polling, run loop
|
|
* Copyright (c) 2026 Daniel Hammer
|
|
*/
|
|
|
|
#include "desktop_internal.hpp"
|
|
#include <montauk/config.h>
|
|
#include <montauk/user.h>
|
|
#include <gui/clipboard.hpp>
|
|
#include <gui/dialogs.hpp>
|
|
|
|
// ============================================================================
|
|
// App Manifest Scanning
|
|
// ============================================================================
|
|
|
|
// Extract the basename from a readdir entry, tolerating both current
|
|
// direct-child names and paths returned by older ramdisk kernels.
|
|
static void extract_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.
|
|
static DesktopItem* next_desktop_item_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];
|
|
}
|
|
|
|
static void desktop_add_builtin_catalog_items(DesktopState* ds) {
|
|
int builtin_count = 0;
|
|
const DesktopBuiltinEntry* builtins = desktop_builtin_registry(&builtin_count);
|
|
for (int i = 0; i < builtin_count; i++) {
|
|
if (!builtins[i].system_configuration_id) continue;
|
|
|
|
DesktopItem* item = next_desktop_item_slot(ds);
|
|
if (!item) return;
|
|
montauk::memset(item, 0, sizeof(*item));
|
|
montauk::strncpy(item->id, builtins[i].system_configuration_id, sizeof(item->id));
|
|
montauk::strncpy(item->name, builtins[i].label, sizeof(item->name));
|
|
if (builtins[i].icon_path) {
|
|
montauk::strncpy(item->icon_path, builtins[i].icon_path, sizeof(item->icon_path));
|
|
}
|
|
SvgIcon* icon = desktop_builtin_menu_icon(ds, builtins[i].id);
|
|
if (icon) item->icon = *icon;
|
|
item->section = DESKTOP_ITEM_SECTION_SETTINGS;
|
|
item->launch_kind = DESKTOP_ITEM_LAUNCH_BUILTIN;
|
|
item->builtin_id = (int)builtins[i].id;
|
|
ds->desktop_item_count++;
|
|
}
|
|
}
|
|
|
|
void desktop_scan_apps(DesktopState* ds) {
|
|
ds->desktop_item_count = 0;
|
|
desktop_add_builtin_catalog_items(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];
|
|
extract_basename(dirname, sizeof(dirname), entries[i]);
|
|
if (dirname[0] == '\0') continue;
|
|
|
|
// Try to open the manifest in this app directory
|
|
char manifest_path[128];
|
|
snprintf(manifest_path, sizeof(manifest_path), "0:/apps/%s/manifest.toml", dirname);
|
|
|
|
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);
|
|
if (id[0] == '\0' || binary[0] == '\0') {
|
|
montauk::print("desktop: 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("desktop: 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("desktop: skipping app manifest with invalid desktop.section\n");
|
|
doc.destroy();
|
|
continue;
|
|
}
|
|
|
|
DesktopItem* item = next_desktop_item_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;
|
|
|
|
// Build full binary path: 0:/apps/<dir>/<binary>
|
|
snprintf(item->binary_path, sizeof(item->binary_path),
|
|
"0:/apps/%s/%s", 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]) {
|
|
snprintf(item->icon_path, sizeof(item->icon_path),
|
|
"0:/apps/%s/%s", dirname, icon_file);
|
|
Color defColor = colors::ICON_COLOR;
|
|
item->icon = svg_load(item->icon_path, 20, 20, defColor);
|
|
}
|
|
|
|
doc.destroy();
|
|
ds->desktop_item_count++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Menu Builder
|
|
// ============================================================================
|
|
|
|
// Predefined categories and their ordering. Manifests may name any other
|
|
// category; those are appended after these, sorted by name.
|
|
// Builtins reference these by index (DesktopBuiltinEntry::launcher_category).
|
|
static const char* CATEGORY_NAMES[] = {
|
|
"Productivity", "Media", "Internet",
|
|
"Utilities", "Games", "Hardware", "System",
|
|
};
|
|
static constexpr int NUM_CATEGORIES = sizeof(CATEGORY_NAMES) / sizeof(CATEGORY_NAMES[0]);
|
|
|
|
static bool category_eq(const char* a, const char* b) {
|
|
for (;; a++, b++) {
|
|
char ca = (*a >= 'A' && *a <= 'Z') ? *a + 32 : *a;
|
|
char cb = (*b >= 'A' && *b <= 'Z') ? *b + 32 : *b;
|
|
if (ca != cb) return false;
|
|
if (!ca) return true;
|
|
}
|
|
}
|
|
|
|
static int category_cmp(const char* a, const char* b) {
|
|
for (;; a++, b++) {
|
|
char ca = (*a >= 'A' && *a <= 'Z') ? *a + 32 : *a;
|
|
char cb = (*b >= 'A' && *b <= 'Z') ? *b + 32 : *b;
|
|
if (ca != cb || !ca) return (int)(unsigned char)ca - (int)(unsigned char)cb;
|
|
}
|
|
}
|
|
|
|
static bool menu_item_shown(DesktopState* ds, const DesktopItem* item) {
|
|
return item->menu_visible && !(item->admin_only && !ds->is_admin);
|
|
}
|
|
|
|
static void menu_add_manifest_items(DesktopState* ds, const char* category) {
|
|
for (int x = 0; x < ds->desktop_item_count; x++) {
|
|
DesktopItem* item = &ds->desktop_items[x];
|
|
if (!menu_item_shown(ds, item)) continue;
|
|
if (category_eq(item->category, category)) {
|
|
menu_add_external(item->name, item->binary_path,
|
|
&item->icon, item->launch_with_home);
|
|
}
|
|
}
|
|
}
|
|
|
|
void desktop_build_menu(DesktopState* ds) {
|
|
menu_row_count = 0;
|
|
int builtin_count = 0;
|
|
const DesktopBuiltinEntry* builtins = desktop_builtin_registry(&builtin_count);
|
|
|
|
// Build each predefined category
|
|
for (int cat = 0; cat < NUM_CATEGORIES; cat++) {
|
|
int header = menu_add_category(CATEGORY_NAMES[cat]);
|
|
|
|
// Add desktop builtins in this category
|
|
for (int b = 0; b < builtin_count; b++) {
|
|
if (builtins[b].launcher_category == cat) {
|
|
menu_add_embedded(builtins[b].label, builtins[b].id,
|
|
desktop_builtin_menu_icon(ds, builtins[b].id));
|
|
}
|
|
}
|
|
|
|
menu_add_manifest_items(ds, CATEGORY_NAMES[cat]);
|
|
|
|
// Drop the header again if nothing landed under it.
|
|
if (header >= 0 && menu_row_count == header + 1) menu_row_count = header;
|
|
}
|
|
|
|
// Custom categories: the first item of each distinct, non-predefined
|
|
// category (case-insensitive) represents it; emit them in name order.
|
|
const DesktopItem** custom = ds->desktop_item_count > 0
|
|
? (const DesktopItem**)montauk::malloc(
|
|
(uint64_t)ds->desktop_item_count * sizeof(DesktopItem*))
|
|
: nullptr;
|
|
int custom_count = 0;
|
|
if (custom) {
|
|
for (int x = 0; x < ds->desktop_item_count; x++) {
|
|
const DesktopItem* item = &ds->desktop_items[x];
|
|
if (!menu_item_shown(ds, item)) continue;
|
|
|
|
bool known = false;
|
|
for (int c = 0; c < NUM_CATEGORIES && !known; c++)
|
|
known = category_eq(item->category, CATEGORY_NAMES[c]);
|
|
for (int c = 0; c < custom_count && !known; c++)
|
|
known = category_eq(item->category, custom[c]->category);
|
|
if (known) continue;
|
|
|
|
int pos = custom_count;
|
|
while (pos > 0 && category_cmp(custom[pos - 1]->category, item->category) > 0) {
|
|
custom[pos] = custom[pos - 1];
|
|
pos--;
|
|
}
|
|
custom[pos] = item;
|
|
custom_count++;
|
|
}
|
|
for (int c = 0; c < custom_count; c++) {
|
|
menu_add_category(custom[c]->category);
|
|
menu_add_manifest_items(ds, custom[c]->category);
|
|
}
|
|
montauk::mfree(custom);
|
|
}
|
|
|
|
// Divider + always-visible entries
|
|
menu_add_category(""); // divider (always expanded)
|
|
for (int b = 0; b < builtin_count; b++) {
|
|
if (builtins[b].launcher_footer) {
|
|
menu_add_embedded(builtins[b].label, builtins[b].id,
|
|
desktop_builtin_menu_icon(ds, builtins[b].id));
|
|
}
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Initialization
|
|
// ============================================================================
|
|
|
|
void gui::desktop_init(DesktopState* ds) {
|
|
ds->screen_w = ds->fb.width();
|
|
ds->screen_h = ds->fb.height();
|
|
ds->background_cache = nullptr;
|
|
ds->background_cache_pitch = ds->fb.pitch();
|
|
ds->background_cache_dirty = true;
|
|
|
|
// Immediately clear the screen to hide flanterm boot text
|
|
ds->fb.clear(colors::DESKTOP_BG);
|
|
ds->fb.flip();
|
|
|
|
// Load TrueType fonts
|
|
fonts::init();
|
|
|
|
ds->window_count = 0;
|
|
ds->focused_window = -1;
|
|
ds->prev_buttons = 0;
|
|
ds->app_menu_open = false;
|
|
desktop_init_launcher(ds);
|
|
|
|
montauk::memset(&ds->mouse, 0, sizeof(montauk::abi::MouseState));
|
|
montauk::set_mouse_bounds(ds->screen_w - 1, ds->screen_h - 1);
|
|
gui::clipboard_clear();
|
|
|
|
// Load SVG icons — scalable (colorful) for app menu, symbolic for toolbar/panel
|
|
Color defColor = colors::ICON_COLOR;
|
|
ds->icon_terminal = svg_load("0:/os/icons/utilities-terminal.svg", 20, 20, defColor);
|
|
ds->icon_filemanager = svg_load("0:/os/icons/system-file-manager.svg", 20, 20, defColor);
|
|
ds->icon_appmenu = svg_load("0:/os/icons/view-app-grid-symbolic.svg", 20, 20, colors::PANEL_TEXT);
|
|
ds->icon_folder = svg_load("0:/os/icons/folder.svg", 16, 16, defColor);
|
|
ds->icon_file = svg_load("0:/os/icons/text-x-generic.svg", 16, 16, defColor);
|
|
ds->icon_network = svg_load("0:/os/icons/network-wired-symbolic.svg", 16, 16, colors::PANEL_TEXT);
|
|
ds->icon_wifi = svg_load("0:/os/icons/network-wireless-symbolic.svg", 16, 16, colors::PANEL_TEXT);
|
|
ds->icon_go_up = svg_load("0:/os/icons/go-up-symbolic.svg", 16, 16, defColor);
|
|
ds->icon_go_back = svg_load("0:/os/icons/go-previous-symbolic.svg", 16, 16, defColor);
|
|
ds->icon_go_forward = svg_load("0:/os/icons/go-next-symbolic.svg", 16, 16, defColor);
|
|
ds->icon_home = svg_load("0:/os/icons/user-home.svg", 16, 16, defColor);
|
|
ds->icon_refresh = svg_load("0:/os/icons/view-refresh-symbolic.svg", 16, 16, defColor);
|
|
ds->icon_exec = svg_load("0:/os/icons/utilities-terminal.svg", 16, 16, defColor);
|
|
|
|
ds->icon_folder_lg = svg_load("0:/os/icons/folder.svg", 48, 48, defColor);
|
|
ds->icon_file_lg = svg_load("0:/os/icons/text-x-generic.svg", 48, 48, defColor);
|
|
ds->icon_exec_lg = svg_load("0:/os/icons/utilities-terminal.svg", 48, 48, defColor);
|
|
// drive icons loaded lazily by file manager to reduce startup heap pressure
|
|
|
|
ds->icon_settings = svg_load("0:/os/icons/preferences-desktop-apps.svg", 20, 20, defColor);
|
|
ds->icon_reboot = svg_load("0:/os/icons/system-reboot.svg", 20, 20, defColor);
|
|
ds->icon_shutdown = svg_load("0:/os/icons/system-shutdown.svg", 20, 20, defColor);
|
|
ds->icon_sleep = svg_load("0:/os/icons/sleep.svg", 20, 20, defColor);
|
|
ds->icon_logout = svg_load("0:/os/icons/gnome-logout.svg", 20, 20, defColor);
|
|
|
|
ds->icon_volume = svg_load("0:/os/icons/audio-volume-high-symbolic.svg", 16, 16, colors::PANEL_TEXT);
|
|
ds->icon_lock = svg_load("0:/os/icons/lock.svg", 20, 20, defColor);
|
|
|
|
// Scan 0:/apps/ for external app manifests and build the menu
|
|
desktop_scan_apps(ds);
|
|
desktop_build_menu(ds);
|
|
|
|
// Settings defaults
|
|
ds->settings.bg_gradient = true;
|
|
ds->settings.bg_image = false;
|
|
ds->settings.bg_grad_top = Color::from_rgb(0xD0, 0xD8, 0xE8);
|
|
ds->settings.bg_grad_bottom = Color::from_rgb(0xA0, 0xA8, 0xB8);
|
|
ds->settings.bg_solid = Color::from_rgb(0xD0, 0xD8, 0xE8);
|
|
ds->settings.bg_image_path[0] = '\0';
|
|
ds->settings.bg_wallpaper = nullptr;
|
|
ds->settings.bg_wallpaper_w = 0;
|
|
ds->settings.bg_wallpaper_h = 0;
|
|
ds->settings.panel_color = Color::from_rgb(0x10, 0x10, 0x10);
|
|
ds->settings.accent_color = colors::ACCENT;
|
|
ds->settings.show_shadows = true;
|
|
ds->settings.clock_24h = true;
|
|
ds->settings.ui_scale = 1;
|
|
ds->settings.tz_offset_minutes = 60; // default CET (UTC+1)
|
|
|
|
// Load per-user desktop settings
|
|
{
|
|
auto doc = montauk::config::load_user(ds->current_user, "desktop");
|
|
|
|
const char* wp = doc.get_string("wallpaper.path", "");
|
|
if (wp[0] != '\0') {
|
|
wallpaper_load(&ds->settings, wp, ds->screen_w, ds->screen_h);
|
|
} else {
|
|
// Fall back to system-wide default wallpaper from 0:/config/desktop.toml
|
|
auto sys = montauk::config::load("desktop");
|
|
const char* def_wp = sys.get_string("wallpaper.path", "");
|
|
if (def_wp[0] != '\0') {
|
|
wallpaper_load(&ds->settings, def_wp, ds->screen_w, ds->screen_h);
|
|
}
|
|
sys.destroy();
|
|
}
|
|
|
|
// Restore other settings from user config
|
|
const char* bg_mode = doc.get_string("background.mode", "");
|
|
if (montauk::streq(bg_mode, "solid")) {
|
|
ds->settings.bg_gradient = false;
|
|
ds->settings.bg_image = false;
|
|
} else if (montauk::streq(bg_mode, "gradient")) {
|
|
ds->settings.bg_gradient = true;
|
|
ds->settings.bg_image = false;
|
|
}
|
|
// (image mode is set by wallpaper_load above)
|
|
|
|
// Background colors
|
|
int64_t solid = doc.get_int("background.solid_color", -1);
|
|
if (solid >= 0) ds->settings.bg_solid = Color::from_rgb(
|
|
(uint8_t)((solid >> 16) & 0xFF), (uint8_t)((solid >> 8) & 0xFF), (uint8_t)(solid & 0xFF));
|
|
int64_t gtop = doc.get_int("background.grad_top", -1);
|
|
if (gtop >= 0) ds->settings.bg_grad_top = Color::from_rgb(
|
|
(uint8_t)((gtop >> 16) & 0xFF), (uint8_t)((gtop >> 8) & 0xFF), (uint8_t)(gtop & 0xFF));
|
|
int64_t gbot = doc.get_int("background.grad_bottom", -1);
|
|
if (gbot >= 0) ds->settings.bg_grad_bottom = Color::from_rgb(
|
|
(uint8_t)((gbot >> 16) & 0xFF), (uint8_t)((gbot >> 8) & 0xFF), (uint8_t)(gbot & 0xFF));
|
|
|
|
// Appearance colors
|
|
int64_t panel = doc.get_int("appearance.panel_color", -1);
|
|
if (panel >= 0) ds->settings.panel_color = Color::from_rgb(
|
|
(uint8_t)((panel >> 16) & 0xFF), (uint8_t)((panel >> 8) & 0xFF), (uint8_t)(panel & 0xFF));
|
|
int64_t accent = doc.get_int("appearance.accent_color", -1);
|
|
if (accent >= 0) ds->settings.accent_color = Color::from_rgb(
|
|
(uint8_t)((accent >> 16) & 0xFF), (uint8_t)((accent >> 8) & 0xFF), (uint8_t)(accent & 0xFF));
|
|
|
|
int64_t scale = doc.get_int("display.ui_scale", 1);
|
|
ds->settings.ui_scale = (int)scale;
|
|
ds->settings.clock_24h = doc.get_bool("display.clock_24h", true);
|
|
ds->settings.show_shadows = doc.get_bool("display.show_shadows", true);
|
|
|
|
doc.destroy();
|
|
}
|
|
|
|
// Load timezone config
|
|
{
|
|
auto tz = montauk::config::load("timezone");
|
|
ds->settings.tz_offset_minutes = (int)tz.get_int("timezone.offset_minutes", 60);
|
|
tz.destroy();
|
|
}
|
|
montauk::settz(ds->settings.tz_offset_minutes);
|
|
|
|
montauk::win_setscale(ds->settings.ui_scale);
|
|
|
|
gui::mtk::context_menu_reset(ds->ctx_menu);
|
|
|
|
ds->net_popup_open = false;
|
|
montauk::get_netcfg(&ds->cached_net_cfg);
|
|
ds->net_cfg_last_poll = montauk::get_milliseconds();
|
|
ds->net_icon_rect = {0, 0, 0, 0};
|
|
|
|
desktop_refresh_netifs(ds);
|
|
desktop_wifi_init(ds);
|
|
desktop_battery_init(ds);
|
|
desktop_brightness_init(ds);
|
|
desktop_consent_init(ds);
|
|
|
|
ds->vol_popup_open = false;
|
|
ds->vol_icon_rect = {0, 0, 0, 0};
|
|
int vol = montauk::audio_get_master_volume();
|
|
ds->vol_level = vol >= 0 ? vol : 80;
|
|
ds->vol_muted = montauk::audio_get_master_mute() == 1;
|
|
ds->vol_pre_mute = ds->vol_level;
|
|
ds->vol_dragging = false;
|
|
ds->vol_last_poll = montauk::get_milliseconds();
|
|
ds->vol_serial = montauk::audio_wait(0, 0);
|
|
ds->vol_output = montauk::audio_get_output(-1);
|
|
ds->vol_bt_status = montauk::audio_bt_status(-1);
|
|
|
|
ds->keyboard = montauk::keyboard::load_user(ds->current_user);
|
|
ds->keyboard_layout_rect = {0, 0, 0, 0};
|
|
ds->keyboard_last_poll = montauk::get_milliseconds();
|
|
|
|
ds->closing_ext_count = 0;
|
|
|
|
ds->screen_locked = false;
|
|
ds->lock_password[0] = '\0';
|
|
ds->lock_password_len = 0;
|
|
ds->lock_error[0] = '\0';
|
|
ds->lock_show_error = false;
|
|
}
|
|
|
|
// ============================================================================
|
|
// External Window Polling
|
|
// ============================================================================
|
|
|
|
static uint64_t desktop_clock_token() {
|
|
montauk::abi::DateTime dt;
|
|
montauk::gettime(&dt);
|
|
return ((uint64_t)dt.Year << 32)
|
|
| ((uint64_t)dt.Month << 24)
|
|
| ((uint64_t)dt.Day << 16)
|
|
| ((uint64_t)dt.Hour << 8)
|
|
| (uint64_t)dt.Minute;
|
|
}
|
|
|
|
static bool desktop_has_visible_dirty_window(const DesktopState* ds) {
|
|
for (int i = 0; i < ds->window_count; i++) {
|
|
const Window& win = ds->windows[i];
|
|
if (win.state == WIN_CLOSED || win.state == WIN_MINIMIZED) continue;
|
|
if (win.dirty) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool desktop_has_active_interaction(const DesktopState* ds) {
|
|
if (ds->vol_dragging) return true;
|
|
if (desktop_brightness_dragging(ds)) return true;
|
|
|
|
for (int i = 0; i < ds->window_count; i++) {
|
|
const Window& win = ds->windows[i];
|
|
if (win.state == WIN_CLOSED || win.state == WIN_MINIMIZED) continue;
|
|
if (win.dragging || win.resizing) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static void desktop_clear_window_dirty(DesktopState* ds) {
|
|
for (int i = 0; i < ds->window_count; i++) {
|
|
ds->windows[i].dirty = false;
|
|
}
|
|
}
|
|
|
|
static bool desktop_netcfg_equal(const montauk::abi::NetCfg& a, const montauk::abi::NetCfg& b) {
|
|
if (a.ipAddress != b.ipAddress || a.subnetMask != b.subnetMask ||
|
|
a.gateway != b.gateway || a.dnsServer != b.dnsServer) {
|
|
return false;
|
|
}
|
|
for (int i = 0; i < 6; i++) {
|
|
if (a.macAddress[i] != b.macAddress[i]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Re-read the interface registry. Cheap, and it is the only way to tell the
|
|
// wired and wireless halves of the panel apart.
|
|
void desktop_refresh_netifs(DesktopState* ds) {
|
|
int n = montauk::net_interfaces(ds->netifs, DesktopState::MAX_NETIFS);
|
|
ds->netif_count = n > 0 ? n : 0;
|
|
ds->eth_present = false;
|
|
for (int i = 0; i < ds->netif_count; i++) {
|
|
if (ds->netifs[i].kind == montauk::abi::NETIF_KIND_ETHERNET)
|
|
ds->eth_present = true;
|
|
}
|
|
}
|
|
|
|
static bool desktop_netifs_equal(const montauk::abi::NetIfInfo* a, int an,
|
|
const montauk::abi::NetIfInfo* b, int bn) {
|
|
if (an != bn) return false;
|
|
for (int i = 0; i < an; i++) {
|
|
if (a[i].linkUp != b[i].linkUp || a[i].active != b[i].active ||
|
|
a[i].kind != b[i].kind) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static bool desktop_refresh_panel_state(DesktopState* ds, uint64_t now) {
|
|
if (ds->screen_locked) return false;
|
|
|
|
bool changed = false;
|
|
if (now - ds->net_cfg_last_poll > 5000) {
|
|
montauk::abi::NetCfg next;
|
|
montauk::get_netcfg(&next);
|
|
if (!desktop_netcfg_equal(next, ds->cached_net_cfg)) {
|
|
ds->cached_net_cfg = next;
|
|
changed = true;
|
|
}
|
|
|
|
montauk::abi::NetIfInfo ifaces[DesktopState::MAX_NETIFS];
|
|
int count = montauk::net_interfaces(ifaces, DesktopState::MAX_NETIFS);
|
|
if (count < 0) count = 0;
|
|
if (!desktop_netifs_equal(ifaces, count, ds->netifs, ds->netif_count)) {
|
|
desktop_refresh_netifs(ds);
|
|
changed = true;
|
|
}
|
|
|
|
ds->net_cfg_last_poll = now;
|
|
}
|
|
|
|
if (desktop_wifi_poll(ds, now)) changed = true;
|
|
if (desktop_battery_poll(ds, now)) changed = true;
|
|
if (desktop_brightness_poll(ds, now)) changed = true;
|
|
|
|
// Event-driven sync: the kernel mixer bumps a serial on every state
|
|
// change. Reading it is one cheap syscall; refresh only when the serial
|
|
// moved since we last looked. The audio app (and any other client) will
|
|
// bump this serial when they change volume/mute, so the popup and the
|
|
// panel speaker icon stay in sync without polling them blindly.
|
|
//
|
|
// Skip the refresh while our own slider is being dragged so a kernel
|
|
// snapshot mid-drag can't clobber the in-flight value.
|
|
if (!ds->vol_dragging) {
|
|
uint64_t serial = montauk::audio_wait(ds->vol_serial, 0);
|
|
if (serial != ds->vol_serial) {
|
|
ds->vol_serial = serial;
|
|
int v = montauk::audio_get_master_volume();
|
|
bool muted = montauk::audio_get_master_mute() == 1;
|
|
int output = montauk::audio_get_output(-1);
|
|
int btStatus = montauk::audio_bt_status(-1);
|
|
if (v >= 0 && v != ds->vol_level) {
|
|
ds->vol_level = v;
|
|
changed = true;
|
|
}
|
|
if (muted != ds->vol_muted) {
|
|
ds->vol_muted = muted;
|
|
changed = true;
|
|
}
|
|
if (output >= 0 && output != ds->vol_output) {
|
|
ds->vol_output = output;
|
|
changed = true;
|
|
}
|
|
if (btStatus != ds->vol_bt_status) {
|
|
ds->vol_bt_status = btStatus;
|
|
changed = true;
|
|
}
|
|
}
|
|
ds->vol_last_poll = now;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
bool desktop_poll_external_windows(DesktopState* ds) {
|
|
bool changed = false;
|
|
montauk::abi::WinInfo extWins[MAX_WINDOWS];
|
|
int extCount = montauk::win_enumerate(extWins, MAX_WINDOWS);
|
|
|
|
// Check for new external windows and map them
|
|
for (int e = 0; e < extCount; e++) {
|
|
int extId = extWins[e].id;
|
|
|
|
// Check if we already have this window
|
|
bool found = false;
|
|
for (int i = 0; i < ds->window_count; i++) {
|
|
if (ds->windows[i].external && ds->windows[i].ext_win_id == extId) {
|
|
found = true;
|
|
bool remapRequested = false;
|
|
bool wasFullscreen = desktop_window_fullscreen(&ds->windows[i]);
|
|
uint64_t currentVa = (uint64_t)(uintptr_t)ds->windows[i].content;
|
|
if (ds->windows[i].content_w != extWins[e].width ||
|
|
ds->windows[i].content_h != extWins[e].height) {
|
|
ds->windows[i].content_w = extWins[e].width;
|
|
ds->windows[i].content_h = extWins[e].height;
|
|
ds->windows[i].dirty = true;
|
|
remapRequested = true;
|
|
if (ds->windows[i].state != WIN_MINIMIZED && ds->windows[i].state != WIN_CLOSED) {
|
|
changed = true;
|
|
}
|
|
}
|
|
if (!montauk::streq(ds->windows[i].title, extWins[e].title)) {
|
|
montauk::strncpy(ds->windows[i].title, extWins[e].title, MAX_TITLE_LEN);
|
|
if (ds->windows[i].state != WIN_MINIMIZED && ds->windows[i].state != WIN_CLOSED) {
|
|
changed = true;
|
|
}
|
|
}
|
|
if (ds->windows[i].ext_flags != extWins[e].flags) {
|
|
ds->windows[i].ext_flags = extWins[e].flags;
|
|
bool isFullscreen = desktop_window_fullscreen(&ds->windows[i]);
|
|
if (isFullscreen) {
|
|
if (!wasFullscreen) ds->windows[i].saved_frame = ds->windows[i].frame;
|
|
ds->windows[i].frame = {0, 0, ds->screen_w, ds->screen_h};
|
|
ds->windows[i].state = WIN_NORMAL;
|
|
} else if (wasFullscreen) {
|
|
ds->windows[i].frame = ds->windows[i].saved_frame;
|
|
ds->windows[i].state = WIN_NORMAL;
|
|
}
|
|
changed = true;
|
|
}
|
|
if (desktop_window_fullscreen(&ds->windows[i]) &&
|
|
(ds->windows[i].frame.x != 0 || ds->windows[i].frame.y != 0 ||
|
|
ds->windows[i].frame.w != ds->screen_w || ds->windows[i].frame.h != ds->screen_h)) {
|
|
ds->windows[i].frame = {0, 0, ds->screen_w, ds->screen_h};
|
|
ds->windows[i].state = WIN_NORMAL;
|
|
changed = true;
|
|
}
|
|
// Update dirty flag and cursor
|
|
if (extWins[e].dirty) {
|
|
ds->windows[i].dirty = true;
|
|
if (ds->windows[i].state != WIN_MINIMIZED && ds->windows[i].state != WIN_CLOSED) {
|
|
changed = true;
|
|
}
|
|
}
|
|
if (ds->windows[i].ext_cursor != extWins[e].cursor) {
|
|
changed = true;
|
|
}
|
|
ds->windows[i].ext_cursor = extWins[e].cursor;
|
|
|
|
if (remapRequested && currentVa != 0) {
|
|
montauk::win_unmap(extId);
|
|
ds->windows[i].content = nullptr;
|
|
currentVa = 0;
|
|
}
|
|
|
|
uint64_t va = currentVa;
|
|
if (remapRequested || currentVa == 0) {
|
|
va = montauk::win_map(extId);
|
|
}
|
|
|
|
if (va != 0 && va != currentVa) {
|
|
ds->windows[i].content = (uint32_t*)va;
|
|
ds->windows[i].content_w = extWins[e].width;
|
|
ds->windows[i].content_h = extWins[e].height;
|
|
ds->windows[i].dirty = true;
|
|
changed = true;
|
|
// Clear from closing list if the slot was reused
|
|
for (int c = 0; c < ds->closing_ext_count; c++) {
|
|
if (ds->closing_ext_ids[c] == extId) {
|
|
ds->closing_ext_ids[c] = ds->closing_ext_ids[--ds->closing_ext_count];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!found && ds->window_count < MAX_WINDOWS) {
|
|
// Skip windows we've already sent a close event to — the owning
|
|
// process hasn't destroyed them yet, so they still appear in
|
|
// win_enumerate. Re-creating them would place them at the
|
|
// default position instead of where the user dragged them.
|
|
bool closing = false;
|
|
for (int c = 0; c < ds->closing_ext_count; c++) {
|
|
if (ds->closing_ext_ids[c] == extId) { closing = true; break; }
|
|
}
|
|
if (closing) continue;
|
|
|
|
// Map the pixel buffer into our address space
|
|
uint64_t va = montauk::win_map(extId);
|
|
if (va == 0) continue;
|
|
|
|
int idx = ds->window_count;
|
|
Window* win = &ds->windows[idx];
|
|
montauk::memset(win, 0, sizeof(Window));
|
|
|
|
montauk::strncpy(win->title, extWins[e].title, MAX_TITLE_LEN);
|
|
int w = extWins[e].width;
|
|
int h = extWins[e].height;
|
|
// Position the window centered-ish
|
|
int wx = (ds->screen_w - w - 2 * BORDER_WIDTH) / 2 + idx * 30;
|
|
int wy = PANEL_HEIGHT + 20 + idx * 30;
|
|
win->frame = {wx, wy, w + 2 * BORDER_WIDTH, h + TITLEBAR_HEIGHT + BORDER_WIDTH};
|
|
win->state = WIN_NORMAL;
|
|
win->z_order = idx;
|
|
win->focused = true;
|
|
win->dirty = true;
|
|
win->dragging = false;
|
|
win->resizing = false;
|
|
win->saved_frame = win->frame;
|
|
|
|
// Point content to the shared pixel buffer
|
|
win->content = (uint32_t*)va;
|
|
win->content_w = w;
|
|
win->content_h = h;
|
|
|
|
win->on_draw = nullptr;
|
|
win->on_mouse = nullptr;
|
|
win->on_key = nullptr;
|
|
win->on_close = nullptr;
|
|
win->on_poll = nullptr;
|
|
win->app_data = nullptr;
|
|
win->external = true;
|
|
win->ext_win_id = extId;
|
|
win->ext_cursor = extWins[e].cursor;
|
|
win->ext_flags = extWins[e].flags;
|
|
if (desktop_window_fullscreen(win)) {
|
|
win->frame = {0, 0, ds->screen_w, ds->screen_h};
|
|
win->state = WIN_NORMAL;
|
|
}
|
|
|
|
// Unfocus previous window
|
|
if (ds->focused_window >= 0 && ds->focused_window < ds->window_count) {
|
|
ds->windows[ds->focused_window].focused = false;
|
|
}
|
|
ds->focused_window = idx;
|
|
ds->window_count++;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Check for removed external windows (process exited)
|
|
for (int i = ds->window_count - 1; i >= 0; i--) {
|
|
if (!ds->windows[i].external) continue;
|
|
|
|
int extId = ds->windows[i].ext_win_id;
|
|
bool stillExists = false;
|
|
for (int e = 0; e < extCount; e++) {
|
|
if (extWins[e].id == extId) {
|
|
stillExists = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!stillExists) {
|
|
// Window gone — remove without freeing content (shared memory)
|
|
if (ds->windows[i].content != nullptr) {
|
|
montauk::win_unmap(extId);
|
|
}
|
|
ds->windows[i].content = nullptr; // prevent free
|
|
gui::desktop_close_window(ds, i);
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
// Purge closing IDs that the kernel no longer reports (fully destroyed)
|
|
for (int c = ds->closing_ext_count - 1; c >= 0; c--) {
|
|
bool alive = false;
|
|
for (int e = 0; e < extCount; e++) {
|
|
if (extWins[e].id == ds->closing_ext_ids[c]) { alive = true; break; }
|
|
}
|
|
if (!alive) {
|
|
ds->closing_ext_ids[c] = ds->closing_ext_ids[--ds->closing_ext_count];
|
|
}
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Run Loop
|
|
// ============================================================================
|
|
|
|
void gui::desktop_run(DesktopState* ds) {
|
|
uint64_t lastClockToken = 0;
|
|
uint64_t nextClockPollMs = 0;
|
|
uint64_t lastLauncherBlinkToken = ~0ull;
|
|
uint64_t inputSerial = montauk::input_wait(0, 0);
|
|
bool firstFrame = true;
|
|
|
|
for (;;) {
|
|
bool mouseChanged = false;
|
|
bool keyboardChanged = false;
|
|
bool sceneChanged = false;
|
|
|
|
// Poll mouse state
|
|
int prevMouseX = ds->mouse.x;
|
|
int prevMouseY = ds->mouse.y;
|
|
uint8_t prevMouseButtons = ds->mouse.buttons;
|
|
ds->prev_buttons = ds->mouse.buttons;
|
|
montauk::mouse_state(&ds->mouse);
|
|
mouseChanged = ds->mouse.x != prevMouseX
|
|
|| ds->mouse.y != prevMouseY
|
|
|| ds->mouse.buttons != prevMouseButtons
|
|
|| ds->mouse.scrollDelta != 0;
|
|
sceneChanged |= mouseChanged;
|
|
|
|
// Poll keyboard events
|
|
while (montauk::is_key_available()) {
|
|
montauk::abi::KeyEvent key;
|
|
montauk::getkey(&key);
|
|
desktop_handle_keyboard(ds, key);
|
|
keyboardChanged = true;
|
|
}
|
|
sceneChanged |= keyboardChanged;
|
|
|
|
// Poll external windows (discover new, remove dead, update dirty)
|
|
sceneChanged |= desktop_poll_external_windows(ds);
|
|
|
|
// Answer or prompt for pending first-use capability requests.
|
|
if (!ds->screen_locked) sceneChanged |= desktop_consent_poll(ds);
|
|
|
|
if (!ds->screen_locked) {
|
|
// Poll windows that have a poll callback
|
|
for (int i = 0; i < ds->window_count; i++) {
|
|
Window* win = &ds->windows[i];
|
|
if (win->state == WIN_CLOSED) continue;
|
|
if (win->on_poll) {
|
|
win->on_poll(win);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle mouse events
|
|
if (mouseChanged) {
|
|
desktop_handle_mouse(ds);
|
|
}
|
|
|
|
if (!ds->screen_locked && (mouseChanged || keyboardChanged)) {
|
|
// Re-poll external windows so that any killed during mouse/key
|
|
// handling are removed before we touch their pixel buffers.
|
|
sceneChanged |= desktop_poll_external_windows(ds);
|
|
}
|
|
|
|
uint64_t now = montauk::get_milliseconds();
|
|
sceneChanged |= desktop_refresh_keyboard_layouts(ds, false);
|
|
sceneChanged |= desktop_refresh_panel_state(ds, now);
|
|
|
|
// Pick up any wallpaper buffer produced by a background loader.
|
|
if (wallpaper_poll(&ds->settings)) {
|
|
desktop_mark_background_dirty(ds);
|
|
sceneChanged = true;
|
|
}
|
|
|
|
uint64_t launcherBlinkToken = desktop_launcher_blink_token(ds, now);
|
|
if (launcherBlinkToken != lastLauncherBlinkToken) {
|
|
lastLauncherBlinkToken = launcherBlinkToken;
|
|
sceneChanged = true;
|
|
}
|
|
|
|
if (now >= nextClockPollMs) {
|
|
nextClockPollMs = now + 1000;
|
|
|
|
// A power request can be posted by anything in this session that
|
|
// holds CAP_POWER_REQUEST -- the shell's shutdown builtin, run in
|
|
// a terminal window, is the common case. login only reads it once
|
|
// the session leader exits, so ending the session is our job; it
|
|
// is the same handoff desktop_request_power() performs, just
|
|
// reached from a request we did not originate.
|
|
int pendingPower = montauk::power_request_pending();
|
|
if (pendingPower == montauk::abi::POWER_REQ_SHUTDOWN ||
|
|
pendingPower == montauk::abi::POWER_REQ_REBOOT) {
|
|
montauk::exit(0);
|
|
}
|
|
|
|
uint64_t clockToken = desktop_clock_token();
|
|
if (clockToken != lastClockToken) {
|
|
lastClockToken = clockToken;
|
|
sceneChanged = true;
|
|
}
|
|
}
|
|
|
|
sceneChanged |= desktop_has_visible_dirty_window(ds);
|
|
|
|
if (firstFrame || sceneChanged) {
|
|
desktop_compose(ds);
|
|
ds->fb.flip();
|
|
desktop_clear_window_dirty(ds);
|
|
firstFrame = false;
|
|
}
|
|
|
|
uint64_t waitMs = (desktop_has_active_interaction(ds) || mouseChanged || keyboardChanged)
|
|
? 1
|
|
: (sceneChanged ? 4 : 16);
|
|
inputSerial = montauk::input_wait(inputSerial, waitMs);
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Entry Point
|
|
// ============================================================================
|
|
|
|
static DesktopState* g_desktop;
|
|
|
|
static constexpr const char kDesktopBinaryName[] = "desktop.elf";
|
|
static constexpr int DESKTOP_PROC_SCAN_MAX = 256;
|
|
|
|
static bool desktop_proc_alive(const montauk::abi::ProcInfo& proc) {
|
|
return proc.state == 1 || proc.state == 2 || proc.state == 3;
|
|
}
|
|
|
|
static bool proc_name_is_desktop(const char* name) {
|
|
int name_len = montauk::slen(name);
|
|
int binary_len = (int)sizeof(kDesktopBinaryName) - 1;
|
|
if (name_len < binary_len) return false;
|
|
|
|
const char* suffix = name + name_len - binary_len;
|
|
if (!montauk::streq(suffix, kDesktopBinaryName)) return false;
|
|
return name_len == binary_len || suffix[-1] == '/';
|
|
}
|
|
|
|
static bool existing_desktop_running() {
|
|
static montauk::abi::ProcInfo procs[DESKTOP_PROC_SCAN_MAX];
|
|
int count = montauk::proclist(procs, DESKTOP_PROC_SCAN_MAX);
|
|
if (count <= 0) return false;
|
|
|
|
int self_pid = montauk::getpid();
|
|
for (int i = 0; i < count; i++) {
|
|
if (procs[i].pid == self_pid) continue;
|
|
if (!desktop_proc_alive(procs[i])) continue;
|
|
if (proc_name_is_desktop(procs[i].name)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
extern "C" void _start() {
|
|
// External applications and all of their descendants inherit this stable
|
|
// desktop-session ID. login.elf uses it to tear down the complete session
|
|
// after Log Out, Shutdown, or Reboot returns control to the supervisor.
|
|
montauk::setsession();
|
|
|
|
if (existing_desktop_running()) {
|
|
gui::dialogs::message_box(
|
|
"Desktop Already Running",
|
|
"Cannot spawn new desktop process whilst an existing desktop session is active.",
|
|
gui::dialogs::MESSAGE_BOX_OK);
|
|
montauk::exit(0);
|
|
}
|
|
|
|
DesktopState* ds = (DesktopState*)montauk::malloc(sizeof(DesktopState));
|
|
montauk::memset(ds, 0, sizeof(DesktopState));
|
|
|
|
// Placement-new the Framebuffer since it has a constructor
|
|
new (&ds->fb) Framebuffer();
|
|
|
|
// Read username from spawn args
|
|
char username[32] = {};
|
|
montauk::getargs(username, 32);
|
|
if (username[0] == '\0') {
|
|
montauk::strcpy(username, "default");
|
|
}
|
|
montauk::strncpy(ds->current_user, username, 31);
|
|
|
|
// Build user paths
|
|
montauk::user::home_dir(username, ds->home_dir, sizeof(ds->home_dir));
|
|
montauk::user::config_dir(username, ds->user_config_dir, sizeof(ds->user_config_dir));
|
|
|
|
// Check if user is admin
|
|
ds->is_admin = false;
|
|
{
|
|
montauk::user::UserInfo users[16];
|
|
int count = montauk::user::load_users(users, 16);
|
|
for (int i = 0; i < count; i++) {
|
|
if (montauk::streq(users[i].username, username)) {
|
|
ds->is_admin = montauk::streq(users[i].role, "admin");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
g_desktop = ds;
|
|
|
|
desktop_init(ds);
|
|
desktop_run(ds);
|
|
|
|
montauk::exit(0);
|
|
}
|