DVX_GUI/src/libs/kpunch/dvxshell/shellApp.c
2026-10-13 13:49:12 -05:00

1167 lines
42 KiB
C

// The MIT License (MIT)
//
// Copyright (C) 2026 Scott Duensing
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
// IN THE SOFTWARE.
// shellApp.c -- DVX Shell application loading, lifecycle, and reaping
//
// Manages DXE app loading via dlopen/dlsym, resource tracking through
// ctx->currentAppId, and clean teardown of both callback-only and main-loop apps.
#include "shellApp.h"
#include "dvxDlg.h"
#include "dvxPlat.h"
#include "stb_ds_wrap.h"
#include <dlfcn.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/stat.h>
#include <unistd.h>
#include "dvxMem.h"
// File copy chunk size for multi-instance temp copies
#define COPY_BUF_SIZE 32768
// dispatchDepth while a callback dispatched by a top-level dvxUpdate runs
// (e.g. the Task Manager's End Task button). Anything deeper means some
// other callback -- possibly the kill target's own modal loop -- is still
// on the stack.
#define SHELL_TOP_DISPATCH_DEPTH 1
// Root of the per-app config tree and the fallback parent directory
#define SHELL_CONFIG_ROOT "CONFIG"
#define SHELL_CONFIG_APPS "APPS"
// Extension of an app's default help file (<app>.hlp beside <app>.app)
#define SHELL_HELP_EXT ".hlp"
// Shown when an app's descriptor carries another DVX_ABI_VERSION.
#define SHELL_ABI_MISMATCH_MSG "This program was built for a different version of DVX and cannot run."
// ============================================================
// Module state
// ============================================================
// Dynamic app slot index (stb_ds array of pointers). App IDs are array
// indices. Slot 0 is reserved (represents the shell itself); apps use
// slots 1+. New slots are appended as needed; freed slots are recycled.
// Each ShellAppT is heap-allocated once and only its pointer lives here,
// so a slot's address never moves when the index array grows -- held
// ShellAppT pointers (and app->name) stay valid across later app loads.
static ShellAppT **sApps = NULL;
// Ctrl+Esc handler -- set by taskmgr DXE constructor, NULL if not loaded
void (*shellCtrlEscFn)(AppContextT *ctx) = NULL;
// Library cleanup hooks run for every ending app (stb_ds array).
static ShellAppCleanupFnT *sCleanupFns = NULL;
// ============================================================
// Prototypes
// ============================================================
static int32_t allocSlot(void);
static int32_t appCodeDepth(const ShellAppT *app);
static bool appCodeMayBeLive(const AppContextT *ctx, const ShellAppT *app);
static bool appCounted(const ShellAppT *app);
static int32_t *appTaskCodeSlot(ShellAppT *app, uint32_t taskId);
static int32_t appTaskDispatchShare(const ShellAppT *app);
static void appTaskWrapper(void *arg);
static WindowT *appTopWindow(const AppContextT *ctx, int32_t appId);
static void cleanupTempFile(ShellAppT *app);
static int32_t copyFile(const char *src, const char *dst);
static void defaultHelpFile(DxeAppContextT *dxeCtx);
static void deferForceKill(AppContextT *ctx, ShellAppT *app);
static void deliverNewArgs(AppContextT *ctx, ShellAppT *app, const char *args);
static void deriveConfigDir(DxeAppContextT *dxeCtx, const char *path);
static void destroyAppWindows(AppContextT *ctx, int32_t appId);
static ShellAppT *findLoadedPath(const char *path);
static ShellAppT *findRunningPath(const char *path);
static void forceKillNow(AppContextT *ctx, ShellAppT *app);
static void killAppTask(ShellAppT *app);
static bool makeTempPath(const char *origPath, int32_t id, char *out, int32_t outSize);
static int32_t refuseSecondCopy(AppContextT *ctx, const char *path);
static void releaseAppResources(ShellAppT *app);
bool shellActivateApp(AppContextT *ctx, int32_t appId);
void shellAppCallEnter(ShellAppT *app);
void shellAppCallLeave(ShellAppT *app);
void shellAppCodeForgetTask(uint32_t taskId);
ShellAppT *shellAppFindByPath(const char *path);
void shellAppInit(void);
int32_t shellAppSlotCount(void);
#ifdef DVX_HOST_TEST
void shellAppTestReset(void);
#endif
void shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize);
int32_t shellEnsureConfigDir(const DxeAppContextT *ctx);
void shellForceKillApp(AppContextT *ctx, ShellAppT *app);
ShellAppT *shellGetApp(int32_t appId);
int32_t shellLoadApp(AppContextT *ctx, const char *path);
static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const char *args);
int32_t shellLoadAppWithArgs(AppContextT *ctx, const char *path, const char *args);
static int32_t shellLoadFail(void *handle, const char *tempPath);
void shellOpenHelp(AppContextT *ctx, const char *args);
void shellOpenTaskManager(AppContextT *ctx);
void shellReapApp(AppContextT *ctx, ShellAppT *app);
bool shellReapApps(AppContextT *ctx);
void shellRegisterAppCleanup(ShellAppCleanupFnT fn);
int32_t shellRunningAppCount(void);
int32_t shellRunningAppId(int32_t index);
void shellTerminateAllApps(AppContextT *ctx);
void shellUnregisterAppCleanup(ShellAppCleanupFnT fn);
// Find the first free slot, starting at 1 (slot 0 is the shell).
// Returns the slot index which also serves as the app's unique ID, or -1
// if a new slot was needed but could not be allocated.
// If no free slot exists, appends a new heap-allocated one.
static int32_t allocSlot(void) {
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i]->state == AppStateFreeE) {
return i;
}
}
// No free slot -- allocate a new one. The ShellAppT lives on the heap
// and only its pointer is appended to the index, so its address stays
// stable even when the index array reallocs.
ShellAppT *newSlot = (ShellAppT *)calloc(1, sizeof(ShellAppT));
if (!newSlot) {
return -1;
}
int32_t id = (int32_t)arrlen(sApps);
arrput(sApps, newSlot);
return id;
}
// Shell calls into the app still in progress, on any task's stack.
static int32_t appCodeDepth(const ShellAppT *app) {
int32_t depth = 0;
for (int32_t i = 0; i < arrlen(app->taskCodeDepth); i++) {
depth += app->taskCodeDepth[i];
}
return depth;
}
// True when the app's code may be on a live stack, so unmapping its DXE now
// would later return into unmapped code:
// - the shell is inside a direct call into the app (appCodeDepth), e.g.
// End Task clicked in a dialog the app's appMain or appShutdown opened;
// - we are running on the app's own task (its main loop pumps the event
// loop that dispatched the kill);
// - the kill is dispatched from a nested event loop, which may be a modal
// loop inside one of the app's own callbacks. Frames on the app's own
// task do not count: they die with its task (a runaway handler).
// Frames on other suspended tasks do: a task's nested event loop may be
// inside a callback of any app's window.
static bool appCodeMayBeLive(const AppContextT *ctx, const ShellAppT *app) {
if (appCodeDepth(app) > 0) {
return true;
}
if (app->hasMainLoop && app->mainTaskId > 0 && tsCurrentId() == app->mainTaskId) {
return true;
}
return ctx->dispatchDepth - appTaskDispatchShare(app) > SHELL_TOP_DISPATCH_DEPTH;
}
// The single definition of "a running app" that shellRunningAppCount and
// shellRunningAppId count: started or starting, not on its way out.
static bool appCounted(const ShellAppT *app) {
return app->state == AppStateRunningE || app->state == AppStateLoadedE;
}
// Grows the app's per-task call table on demand (zero-filled).
static int32_t *appTaskCodeSlot(ShellAppT *app, uint32_t taskId) {
while (arrlen(app->taskCodeDepth) <= (ptrdiff_t)taskId) {
arrput(app->taskCodeDepth, 0);
}
return &app->taskCodeDepth[taskId];
}
// Dispatch frames open on the app's own task, which tsKill discards along
// with the task (0 for a callback-only app).
static int32_t appTaskDispatchShare(const ShellAppT *app) {
if (!app->hasMainLoop || app->mainTaskId == 0) {
return 0;
}
return shellTaskDepthShare(app->mainTaskId);
}
// Task entry point for main-loop apps. Runs in its own cooperative task.
// Sets ctx->currentAppId before calling the app's entry point so that any
// GUI resources created during the app's lifetime are tagged with the
// correct owner. When the app's main loop returns (normal exit), the
// state is set to Terminating so the shell's main loop will reap it.
//
// If the app crashes (signal), the signal handler longjmps to main and
// this function never completes -- the task is killed externally via
// shellForceKillApp + tsKill.
static void appTaskWrapper(void *arg) {
// The task's arg is the app's slot ID. Resolve it to the app's stable
// heap pointer once -- slots never move, so the pointer stays valid for
// the whole task lifetime (even across other app loads) without any
// need to re-resolve after the app's main loop returns.
int32_t appId = (int32_t)(intptr_t)arg;
ShellAppT *app = sApps[appId];
app->dxeCtx->shellCtx->currentAppId = app->appId;
app->entryFn(app->dxeCtx);
app->dxeCtx->shellCtx->currentAppId = 0;
// App returned from its main loop -- mark for reaping. This task is
// about to self-terminate (taskTrampoline -> tsExit), which frees its
// slot; clear mainTaskId so the deferred reap does not tsKill a recycled
// slot that a later tsCreate may already have handed to a new task.
app->mainTaskId = 0;
app->state = AppStateTerminatingE;
}
// The app's topmost visible window (highest Z first) that is not being
// destroyed, or NULL. Hidden windows (e.g. a hidden tool window) are
// skipped.
static WindowT *appTopWindow(const AppContextT *ctx, int32_t appId) {
for (int32_t i = ctx->stack.count - 1; i >= 0; i--) {
WindowT *win = ctx->stack.windows[i];
if (win->appId == appId && win->visible && !win->destroyPending) {
return win;
}
}
return NULL;
}
// Delete the temp file for a multi-instance app copy, if one exists.
static void cleanupTempFile(ShellAppT *app) {
if (app->tempPath[0] != '\0') {
remove(app->tempPath);
app->tempPath[0] = '\0';
}
}
// Binary file copy. Returns 0 on success, -1 on failure.
static int32_t copyFile(const char *src, const char *dst) {
FILE *in = fopen(src, "rb");
if (!in) {
dvxLog("copyFile: failed to open source '%s' (errno=%d)", src, errno);
return -1;
}
FILE *out = fopen(dst, "wb");
if (!out) {
dvxLog("copyFile: failed to create dest '%s' (errno=%d)", dst, errno);
fclose(in);
return -1;
}
// Static rather than on-stack: shellLoadApp runs on app task stacks as
// small as TS_DEFAULT_STACK_SIZE (32KB), which a 32KB stack frame would
// overflow. The cooperative switcher is single-threaded, so a shared
// copy buffer is safe.
static char buf[COPY_BUF_SIZE];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
if (fwrite(buf, 1, n, out) != n) {
dvxLog("copyFile: write failed '%s' -> '%s' (errno=%d)", src, dst, errno);
fclose(in);
fclose(out);
remove(dst);
return -1;
}
}
fclose(in);
fclose(out);
return 0;
}
// Force kill while the app's code may still be on a stack (see
// appCodeMayBeLive). Its windows are queued for destruction -- hidden at
// once, so modal loops over them see them go, but their memory stays valid
// for the app frames that still have to unwind -- and the slot is marked
// Terminating/killPending. shellReapApps finishes the job (without the
// app's shutdownFn) once no dispatch or shell->app call is in progress.
// Default F1 help: <app>.hlp beside the .app, when that file exists, so
// an app with its own help needs no code for F1. appMain may still set
// helpFile to anything else.
static void defaultHelpFile(DxeAppContextT *dxeCtx) {
char hlpPath[DVX_MAX_PATH];
struct stat st;
if (platformPathSibling(hlpPath, sizeof(hlpPath), dxeCtx->appPath, SHELL_HELP_EXT) && stat(hlpPath, &st) == 0 && S_ISREG(st.st_mode)) {
snprintf(dxeCtx->helpFile, sizeof(dxeCtx->helpFile), "%s", hlpPath);
}
}
static void deferForceKill(AppContextT *ctx, ShellAppT *app) {
// Force the deferred path of dvxDestroyWindow even at depth 0; the
// queued windows are flushed by the next dvxUpdate.
ctx->dispatchDepth++;
for (int32_t i = 0; i < ctx->stack.count; i++) {
if (ctx->stack.windows[i]->appId == app->appId) {
dvxDestroyWindow(ctx, ctx->stack.windows[i]);
}
}
ctx->dispatchDepth--;
app->killPending = true;
app->state = AppStateTerminatingE;
dvxLog("Shell: kill of '%s' deferred until its code unwinds", app->name);
}
// Hands new launch arguments to a running app that takes them
// (onNewArgs) and raises its top window. The callback runs as that app
// (currentAppId) so anything it allocates is attributed to it.
static void deliverNewArgs(AppContextT *ctx, ShellAppT *app, const char *args) {
int32_t prevAppId = ctx->currentAppId;
ctx->currentAppId = app->appId;
SHELL_APP_CALL(app, app->dxeCtx->onNewArgs(app->dxeCtx->newArgsCtx, args));
ctx->currentAppId = prevAppId;
WindowT *top = appTopWindow(ctx, app->appId);
if (top) {
dvxRaiseWindow(ctx, top);
}
}
// Derive the app's writable config directory by mirroring its directory
// under CONFIG/: "APPS/KPUNCH/DVXBASIC" -> "CONFIG/APPS/KPUNCH/DVXBASIC".
// An absolute path (e.g. from the Run... file dialog) is first made
// relative to the DVX root (the working directory). When that is not
// possible -- outside the DVX tree, or a bare file name -- the fallback is
// CONFIG/APPS/<app file name without extension>.
static void deriveConfigDir(DxeAppContextT *dxeCtx, const char *path) {
const char *rel = dxeCtx->appDir;
if (platformPathIsAbsolute(rel)) {
char cwd[DVX_MAX_PATH];
rel = getcwd(cwd, sizeof(cwd)) ? platformPathIsUnder(rel, cwd) : NULL;
}
if (rel && rel[0] && strcmp(rel, ".") != 0) {
snprintf(dxeCtx->configDir, sizeof(dxeCtx->configDir), SHELL_CONFIG_ROOT DVX_PATH_SEP "%s", rel);
platformPathNative(dxeCtx->configDir);
return;
}
const char *base = platformPathBaseName(path);
const char *dot = strrchr(base, '.');
int32_t len = dot ? (int32_t)(dot - base) : (int32_t)strlen(base);
snprintf(dxeCtx->configDir, sizeof(dxeCtx->configDir), SHELL_CONFIG_ROOT DVX_PATH_SEP SHELL_CONFIG_APPS DVX_PATH_SEP "%.*s", (int)len, base);
}
// Destroy every window belonging to an app SYNCHRONOUSLY, before its DXE
// is unmapped -- a deferred destroy would leave live timer/poll/window
// callbacks pointing into unmapped code for the rest of the dispatch pass.
//
// First pass: flag every window of the app destroyPending (and hide it).
// This makes all of them inert at once, so the successor-focus selection
// inside each destroy can only land on OTHER apps' windows -- focus
// callbacks (and BASIC Activate events) can never fire into the app being
// torn down. The flush hint is incremented alongside the flag so
// dvxDestroyWindowNow's decrement stays balanced.
//
// Second pass: rescan-until-none-left rather than a single index walk,
// because callbacks fired mid-destroy (another app's onFocus) can mutate
// the window stack and a plain walk could skip an entry.
static void destroyAppWindows(AppContextT *ctx, int32_t appId) {
for (int32_t i = 0; i < ctx->stack.count; i++) {
WindowT *win = ctx->stack.windows[i];
if (win->appId == appId && !win->destroyPending) {
win->destroyPending = true;
ctx->pendingDestroyCount++;
if (win->visible) {
dirtyListAdd(&ctx->dirty, win->x, win->y, win->w, win->h);
win->visible = false;
}
// Silent focus drop (mirrors deferDestroyWindow): with
// focusedIdx pre-cleared, the wmSetFocus that follows the
// first destroy sees no old window and fires only the
// successor's onFocus -- never onBlur/Deactivate into the
// dying app.
if (win->focused) {
win->focused = false;
ctx->stack.focusedIdx = -1;
}
}
}
bool found = true;
while (found) {
found = false;
for (int32_t i = ctx->stack.count - 1; i >= 0; i--) {
if (ctx->stack.windows[i]->appId == appId) {
dvxDestroyWindowNow(ctx, ctx->stack.windows[i]);
found = true;
break;
}
}
}
}
// Find an active slot with the given DXE path. Returns the slot pointer,
// or NULL if no running app was loaded from this path.
// Paths are compared with platformPathSame, so the same app
// launched as "APPS\KPUNCH\DVXHELP\DVXHELP.APP" and as a directory-scan
// spelling like "apps/kpunch/dvxhelp/dvxhelp.app" is one loaded app.
static ShellAppT *findLoadedPath(const char *path) {
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i]->state != AppStateFreeE && platformPathSame(sApps[i]->path, path)) {
return sApps[i];
}
}
return NULL;
}
// Running app loaded from this path (platformPathSame), or NULL. Unlike
// findLoadedPath it skips an instance whose deferred kill is pending.
static ShellAppT *findRunningPath(const char *path) {
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i]->state == AppStateRunningE && platformPathSame(sApps[i]->path, path)) {
return sApps[i];
}
}
return NULL;
}
// Tear an app down now: purge its shell callbacks, destroy its windows
// synchronously, kill its task, unmap the DXE. Never runs app code.
// Callers guarantee the app's code is not on a live stack.
static void forceKillNow(AppContextT *ctx, ShellAppT *app) {
// Purge every shell-side callback this app registered (desktop-update and
// idle handlers) BEFORE its DXE is unmapped. A force kill never runs the
// app's shutdownFn -- the app is being killed precisely because its own
// code cannot be trusted to run -- so the shell, not the app, is
// responsible for unhooking these; otherwise the dangling pointers crash
// the next shellDesktopUpdate() or idle frame.
shellPurgeAppCallbacks(app->appId);
// Destroy all windows belonging to this app -- synchronously, so no
// timer/poll/window callback can point into the DXE once it unmaps.
// Also reclaims windows the app had already self-deferred.
destroyAppWindows(ctx, app->appId);
// Kill the task, then unmap the DXE and release the app's resources.
killAppTask(app);
releaseAppResources(app);
app->state = AppStateFreeE;
app->killPending = false;
dvxLog("Shell: force-killed app '%s'", app->name);
}
// Kill an app's cooperative task if it has one and it is still alive.
// Shared by shellForceKillApp and shellReapApp.
static void killAppTask(ShellAppT *app) {
if (app->hasMainLoop && app->mainTaskId > 0) {
if (tsGetState(app->mainTaskId) != TaskStateTerminated) {
shellTaskDepthForget(app->mainTaskId);
tsKill(app->mainTaskId);
}
}
}
// Build a temp path for a multi-instance copy. Uses the TEMP or TMP
// environment variable if set, otherwise falls back to the current directory
// (the DVX folder). The slot ID is embedded in the filename to ensure
// uniqueness. False when there is nowhere to put the copy: no TEMP and a
// read-only DVX folder.
// Example: TEMP=C:\TEMP -> "C:\TEMP\_dvx02.app"
static bool makeTempPath(const char *origPath, int32_t id, char *out, int32_t outSize) {
// Find extension from original path
const char *lastSlash = platformPathDirEnd(origPath);
const char *dot = strrchr(origPath, '.');
const char *tmpDir = platformTempDir();
if (!dot || (lastSlash && dot < lastSlash)) {
dot = origPath + strlen(origPath);
}
// The fallback is anchored to the working directory: a bare file name
// would make dlopen search the library path instead of opening it.
char name[DVX_MAX_PATH];
snprintf(name, sizeof(name), "_dvx%02ld%s", (long)id, dot);
if (!tmpDir && platformIsReadOnly()) {
return false;
}
return platformPathJoin(out, outSize, tmpDir ? tmpDir : ".", name);
}
// A program that is already running and cannot run twice: say so.
static int32_t refuseSecondCopy(AppContextT *ctx, const char *path) {
char msg[SHELL_MSG_MAX];
snprintf(msg, sizeof(msg), "%s is already running.", platformPathBaseName(path));
dvxErrorBox(ctx, NULL, msg);
return -1;
}
// Shared teardown tail for both reap and force-kill: release the widget
// classes, system-wide hotkeys and timer-service timers the app registered
// and its hung-callback bookkeeping (a force kill never
// runs the appShutdown that would unregister them, and they point into the
// module), unmap the DXE,
// delete any multi-instance temp copy, release the heap app context, and
// reset the app's memory accounting. Callers handle the parts that differ
// between the two paths (app-code shutdown or its deliberate omission,
// callback purging, window destruction, and the final state/log line).
static void releaseAppResources(ShellAppT *app) {
wgtUnregisterAppClasses(app->appId);
dvxUnregisterAppHotkeys(app->appId);
dvxAppHungForget(app->appId);
platformTimerRemoveOwner(app->appId);
for (int32_t i = 0; i < arrlen(sCleanupFns); i++) {
sCleanupFns[i](app->appId);
}
if (app->dxeHandle) {
dlclose(app->dxeHandle);
app->dxeHandle = NULL;
}
cleanupTempFile(app);
free(app->dxeCtx);
app->dxeCtx = NULL;
arrfree(app->taskCodeDepth);
app->taskCodeDepth = NULL;
dvxMemResetApp(app->appId);
}
// The app's topmost visible window (appTopWindow) is restored if
// minimized, else raised and focused. False when it has none.
bool shellActivateApp(AppContextT *ctx, int32_t appId) {
WindowT *win = appTopWindow(ctx, appId);
if (!win) {
return false;
}
// dvxRestoreWindow would also un-maximize; only un-minimize.
if (win->minimized) {
dvxRestoreWindow(ctx, win);
} else {
dvxRaiseWindow(ctx, win);
}
return true;
}
void shellAppCallEnter(ShellAppT *app) {
(*appTaskCodeSlot(app, tsCurrentId()))++;
}
void shellAppCallLeave(ShellAppT *app) {
(*appTaskCodeSlot(app, tsCurrentId()))--;
}
void shellAppCodeForgetTask(uint32_t taskId) {
for (int32_t i = 1; i < arrlen(sApps); i++) {
if ((ptrdiff_t)taskId < arrlen(sApps[i]->taskCodeDepth)) {
sApps[i]->taskCodeDepth[taskId] = 0;
}
}
}
// Running (or starting) app loaded from exactly this path, or NULL.
ShellAppT *shellAppFindByPath(const char *path) {
return findLoadedPath(path);
}
void shellAppInit(void) {
// Seed slot 0 (reserved for the shell). Slots are heap-allocated so
// their addresses stay stable as the sApps index array grows.
ShellAppT *slot0 = (ShellAppT *)calloc(1, sizeof(ShellAppT));
arrput(sApps, slot0);
}
int32_t shellAppSlotCount(void) {
return arrlen(sApps);
}
#ifdef DVX_HOST_TEST
// Test seam: release every slot (the caller has already terminated the
// apps) so the next shellAppInit starts from an empty table.
void shellAppTestReset(void) {
for (int32_t i = 0; i < arrlen(sApps); i++) {
free(sApps[i]);
}
arrfree(sApps);
sApps = NULL;
}
#endif
void shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize) {
platformPathJoin(outPath, outSize, ctx->configDir, filename);
}
int32_t shellEnsureConfigDir(const DxeAppContextT *ctx) {
return platformMkdirRecursive(ctx->configDir);
}
// Forcible kill -- reclaims an app WITHOUT running any of the app's own code.
// End Task's primary target is a HUNG or FAULTING app: its shutdownFn would
// re-enter the same broken state (re-hang the single-threaded cooperative
// shell, or re-fault and longjmp past all cleanup), so the shutdownFn is
// deliberately NOT called here. Instead the shell itself purges every
// callback the app registered (desktop-update hooks, idle/serial pollers)
// via shellPurgeAppCallbacks, so nothing dangles once the DXE is unmapped.
// Used for crashed apps and "End Task".
//
// Cleanup order matters: purge shell callbacks and destroy windows (removes
// them from the compositor) first, then the task (frees the stack), then the
// DXE handle (unmaps the code). And the DXE is never unmapped while the
// app's own code is still on a stack (see appCodeMayBeLive): that case is
// deferred to shellReapApps.
void shellForceKillApp(AppContextT *ctx, ShellAppT *app) {
if (!app || app->state == AppStateFreeE) {
return;
}
if (!appCodeMayBeLive(ctx, app)) {
forceKillNow(ctx, app);
return;
}
// The callbacks can go at once: nothing on the stack needs them.
shellPurgeAppCallbacks(app->appId);
if (!app->killPending) {
deferForceKill(ctx, app);
}
}
ShellAppT *shellGetApp(int32_t appId) {
if (appId < 1 || appId >= arrlen(sApps)) {
return NULL;
}
if (sApps[appId]->state == AppStateFreeE) {
return NULL;
}
return sApps[appId];
}
// Load a DXE app: dlopen the module, resolve symbols, set up context, launch.
// DXE3 is DJGPP's dynamic linking system -- similar to dlopen/dlsym on Unix.
// Each .app file is a DXE3 shared object that exports _appDescriptor and
// _appMain (and optionally _appShutdown). The leading underscore is the
// COFF symbol convention; DJGPP's dlsym expects it, ELF hosts use the
// bare name -- DVX_SYM() supplies whichever the platform needs.
//
// Multi-instance support: DXE3's dlopen returns the same handle for the
// same path (reference-counted), so two loads of the same .dxe share all
// global/static state -- only one main loop runs and closing one kills both.
// If the app's descriptor sets multiInstance=true, we copy the .dxe to a
// unique temp file before dlopen, giving each instance its own code+data.
// If multiInstance=false (the default), a second load of the same path is
// blocked with an error message.
int32_t shellLoadApp(AppContextT *ctx, const char *path) {
return shellLoadAppInternal(ctx, path, NULL);
}
static int32_t shellLoadAppInternal(AppContextT *ctx, const char *path, const char *args) {
// Check if this DXE is already loaded. If so, check whether the app
// allows multiple instances. We read the descriptor from the existing
// slot directly -- no need to dlopen again. This runs before the
// slot is allocated so a rejected launch never grows the slot index.
ShellAppT *existing = findLoadedPath(path);
if (existing) {
// Read multiInstance from the already-loaded descriptor
AppDescriptorT *existDesc = (AppDescriptorT *)dlsym(existing->dxeHandle, DVX_SYM("appDescriptor"));
// A running single-copy app that takes new arguments gets them
// instead of a second copy
if ((!existDesc || !existDesc->multiInstance) && existing->state == AppStateRunningE && existing->dxeCtx && existing->dxeCtx->onNewArgs) {
deliverNewArgs(ctx, existing, args ? args : "");
return existing->appId;
}
if (!existDesc || !existDesc->multiInstance) {
return refuseSecondCopy(ctx, path);
}
}
// Allocate a slot
int32_t id = allocSlot();
if (id < 0) {
dvxErrorBox(ctx, NULL, "Out of memory: cannot allocate an application slot.");
return -1;
}
const char *loadPath = path;
char tempPath[DVX_MAX_PATH] = {0};
if (existing) {
// Multi-instance allowed: copy to a temp file so dlopen gets
// an independent code+data image.
// Nowhere to put a second copy (read-only media, no TEMP): the
// program runs once, like a single-instance one.
if (!makeTempPath(path, id, tempPath, sizeof(tempPath))) {
dvxLog("Shell: no TEMP folder and a read-only DVX folder: one copy of %s only", path);
return refuseSecondCopy(ctx, path);
}
if (copyFile(path, tempPath) != 0) {
char msg[SHELL_MSG_MAX];
snprintf(msg, sizeof(msg), "Failed to create instance copy of %s.", platformPathBaseName(path));
dvxErrorBox(ctx, NULL, msg);
return -1;
}
loadPath = tempPath;
}
// Show hourglass during the load (dlopen + symbol resolution + appMain)
dvxSetBusy(ctx, true);
// Load the DXE
void *handle = dlopen(loadPath, RTLD_NOW | RTLD_GLOBAL);
if (!handle) {
char msg[SHELL_MSG_MAX];
snprintf(msg, sizeof(msg), "Failed to load %s:\n%s", platformPathBaseName(path), dlerror());
dvxLog("DXE load failed: %s", msg);
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, msg);
return shellLoadFail(handle, tempPath);
}
// Look up required symbols
AppDescriptorT *desc = (AppDescriptorT *)dlsym(handle, DVX_SYM("appDescriptor"));
if (!desc) {
char msg[SHELL_MSG_MAX];
snprintf(msg, sizeof(msg), "%s: missing appDescriptor", platformPathBaseName(path));
dvxLog("DXE symbol error: %s", msg);
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, msg);
return shellLoadFail(handle, tempPath);
}
// Checked before anything else in the descriptor is trusted: an app
// built against other headers may lay it out (and everything else)
// differently.
if (desc->abiVersion != DVX_ABI_VERSION) {
dvxLog("DXE ABI mismatch: %s was built for DVX ABI %lu, this system is ABI %d", path, (unsigned long)desc->abiVersion, DVX_ABI_VERSION);
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, SHELL_ABI_MISMATCH_MSG);
return shellLoadFail(handle, tempPath);
}
int32_t (*entry)(DxeAppContextT *) = (int32_t (*)(DxeAppContextT *))dlsym(handle, DVX_SYM("appMain"));
if (!entry) {
char msg[SHELL_MSG_MAX];
snprintf(msg, sizeof(msg), "%s: missing appMain", platformPathBaseName(path));
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, msg);
return shellLoadFail(handle, tempPath);
}
void (*shutdown)(void) = (void (*)(void))dlsym(handle, DVX_SYM("appShutdown"));
// Fill in the app slot
ShellAppT *app = sApps[id];
memset(app, 0, sizeof(*app));
app->appId = id;
// desc comes from an arbitrary DXE: never read past its name field.
snprintf(app->name, SHELL_APP_NAME_MAX, "%.*s", SHELL_APP_NAME_MAX - 1, desc->name);
snprintf(app->path, sizeof(app->path), "%s", path);
platformPathNative(app->path);
snprintf(app->tempPath, sizeof(app->tempPath), "%s", tempPath);
app->dxeHandle = handle;
app->hasMainLoop = desc->hasMainLoop;
app->entryFn = entry;
app->shutdownFn = shutdown;
app->state = AppStateLoadedE;
// Heap-allocate the DxeAppContextT so its address is stable across
// sApps reallocs. Apps save this pointer in their static globals.
app->dxeCtx = (DxeAppContextT *)calloc(1, sizeof(DxeAppContextT));
if (!app->dxeCtx) {
dvxLog("Shell: failed to allocate app context for %s", app->name);
dvxSetBusy(ctx, false);
app->state = AppStateFreeE;
return shellLoadFail(handle, tempPath);
}
app->dxeCtx->shellCtx = ctx;
app->dxeCtx->appId = id;
// Store the full app path so the app can load its own resources
// (native separators: programs show it, as App.Path and the like).
snprintf(app->dxeCtx->appPath, sizeof(app->dxeCtx->appPath), "%s", app->path);
// Derive app directory from path (everything up to the last separator).
// This lets apps load resources relative to their own location rather
// than the shell's working directory.
platformPathDirName(app->path, app->dxeCtx->appDir, sizeof(app->dxeCtx->appDir));
deriveConfigDir(app->dxeCtx, path);
defaultHelpFile(app->dxeCtx);
// Copy launch arguments (if any)
if (args && args[0]) {
snprintf(app->dxeCtx->args, sizeof(app->dxeCtx->args), "%s", args);
}
// Launch. Set currentAppId before any app code runs so that
// dvxCreateWindow stamps the correct owner on new windows; the program
// that asked for the start (0 for the shell) is "the running program"
// again afterwards.
int32_t callerAppId = ctx->currentAppId;
ctx->currentAppId = id;
if (desc->hasMainLoop) {
uint32_t stackSize = desc->stackSize > 0 ? (uint32_t)desc->stackSize : TS_DEFAULT_STACK_SIZE;
int32_t priority = desc->priority;
int32_t taskId = tsCreate(app->name, appTaskWrapper, (void *)(intptr_t)id, stackSize, priority);
if (taskId < 0) {
ctx->currentAppId = callerAppId;
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, "Failed to create task for application.");
free(app->dxeCtx);
app->dxeCtx = NULL;
app->state = AppStateFreeE;
return shellLoadFail(handle, tempPath);
}
app->mainTaskId = (uint32_t)taskId;
} else {
// Callback-only: call entry directly in task 0 (the shell).
// The app creates its windows and returns. From this point on,
// the app lives entirely through event callbacks dispatched by
// the shell's dvxUpdate loop. No separate task or stack needed.
// A non-zero return is the app declining to start: unload it the
// same way a fault would, without running its shutdown hook.
int32_t entryResult = 0;
SHELL_APP_CALL(app, entryResult = app->entryFn(app->dxeCtx));
// End Task from inside appMain (e.g. from a dialog it opened):
// the kill was deferred and shellReapApps now owns the slot.
if (app->killPending) {
ctx->currentAppId = callerAppId;
dvxSetBusy(ctx, false);
return -1;
}
if (entryResult != 0) {
dvxLog("Shell: '%s' appMain returned %ld, unloading", app->name, (long)entryResult);
ctx->currentAppId = callerAppId;
dvxSetBusy(ctx, false);
forceKillNow(ctx, app);
return -1;
}
}
// The slot pointer is stable across the callback-only entry point above
// (which can pump dvxUpdate and yield to other app loads), so no
// re-resolve is needed.
ctx->currentAppId = callerAppId;
app->state = AppStateRunningE;
dvxLog("Shell: loaded '%s' (id=%ld, mainLoop=%s, entry=0x%08lx, desc=0x%08lx)", app->name, (long)id, app->hasMainLoop ? "yes" : "no", (unsigned long)entry, (unsigned long)desc);
dvxSetBusy(ctx, false);
shellDesktopUpdate();
return id;
}
int32_t shellLoadAppWithArgs(AppContextT *ctx, const char *path, const char *args) {
return shellLoadAppInternal(ctx, path, args);
}
// Shared load-failure cleanup for shellLoadAppInternal: unmap the DXE (if
// dlopen got far enough to return a handle) and delete any multi-instance
// temp copy. Touches only these locals, never the slot, so it is safe from
// every failure branch regardless of how far the load progressed. The busy
// hourglass, the error dialog, and any slot-state reset are handled by the
// caller before this runs. Always returns -1 for the caller to propagate.
static int32_t shellLoadFail(void *handle, const char *tempPath) {
if (handle) {
dlclose(handle);
}
if (tempPath[0]) {
remove(tempPath);
}
return -1;
}
// F1 and application Help menus. Empty args (an app with no help file)
// open the system help, in the running viewer when there is one.
void shellOpenHelp(AppContextT *ctx, const char *args) {
ShellAppT *viewer = findRunningPath(DVX_HELP_VIEWER_APP_LITERAL);
if (!args || !args[0]) {
args = DVX_SYSTEM_HELP_FILE_LITERAL;
}
if (!viewer || !viewer->dxeCtx || !viewer->dxeCtx->onNewArgs) {
shellLoadAppWithArgs(ctx, DVX_HELP_VIEWER_APP_LITERAL, args);
return;
}
deliverNewArgs(ctx, viewer, args);
}
void shellOpenTaskManager(AppContextT *ctx) {
if (shellCtrlEscFn) {
shellCtrlEscFn(ctx);
}
}
// Graceful reap -- called from shellReapApps when an app has reached
// the Terminating state. Unlike forceKill, this calls the app's
// shutdown hook (if provided) giving it a chance to save state, close
// files, etc. The ctx->currentAppId is set during the shutdown call so
// any final resource operations are attributed correctly. An app whose
// force kill was deferred (killPending) is finished as a force kill: its
// shutdown hook does not run.
void shellReapApp(AppContextT *ctx, ShellAppT *app) {
if (!app || app->state == AppStateFreeE) {
return;
}
if (app->killPending) {
forceKillNow(ctx, app);
return;
}
// Call shutdown hook if present
if (app->shutdownFn) {
ctx->currentAppId = app->appId;
SHELL_APP_CALL(app, app->shutdownFn());
ctx->currentAppId = 0;
// End Task from a dialog the shutdown hook opened: finished by a
// later shellReapApps pass once the hook's frames are gone.
if (app->killPending) {
return;
}
}
// Destroy all windows belonging to this app, synchronously (see
// destroyAppWindows) -- they must be gone before dlclose unmaps the
// callback code they point into.
destroyAppWindows(ctx, app->appId);
// Kill the task if it has one and it's still alive.
killAppTask(app);
// Safety net: a well-behaved shutdownFn already unregistered its shell
// callbacks above, but the shell owns teardown regardless of app
// cooperation -- drop any the app forgot before the DXE is unmapped.
shellPurgeAppCallbacks(app->appId);
releaseAppResources(app);
dvxLog("Shell: reaped app '%s'", app->name);
app->state = AppStateFreeE;
}
// Called every frame from the shell's main loop. Scans for apps that have
// entered the Terminating state (either their task returned or their last
// window was closed) and cleans them up. The deferred-reap design avoids
// destroying resources in the middle of a callback chain -- the app marks
// itself for termination, and cleanup happens at a safe top-level point.
bool shellReapApps(AppContextT *ctx) {
bool reaped = false;
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i]->state == AppStateTerminatingE) {
// A deferred kill waits until neither a dispatch nor a shell
// call into the app is left on any stack but the app's own
// task's, which the kill discards (a runaway event handler).
if (sApps[i]->killPending && (appCodeDepth(sApps[i]) > 0 || ctx->dispatchDepth - appTaskDispatchShare(sApps[i]) > 0)) {
continue;
}
shellReapApp(ctx, sApps[i]);
reaped = true;
continue;
}
// Callback-only apps terminate when their last window closes.
// They have no main loop to set AppStateTerminatingE, so we
// detect termination by checking for zero remaining windows.
if (sApps[i]->state == AppStateRunningE && !sApps[i]->hasMainLoop) {
bool hasWindow = false;
for (int32_t w = 0; w < ctx->stack.count; w++) {
if (ctx->stack.windows[w]->appId == sApps[i]->appId) {
hasWindow = true;
break;
}
}
if (!hasWindow) {
shellReapApp(ctx, sApps[i]);
reaped = true;
}
}
}
return reaped;
}
void shellRegisterAppCleanup(ShellAppCleanupFnT fn) {
for (int32_t i = 0; i < arrlen(sCleanupFns); i++) {
if (sCleanupFns[i] == fn) {
return;
}
}
arrput(sCleanupFns, fn);
}
int32_t shellRunningAppCount(void) {
int32_t count = 0;
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (appCounted(sApps[i])) {
count++;
}
}
return count;
}
int32_t shellRunningAppId(int32_t index) {
int32_t seen = 0;
for (int32_t i = 1; index >= 0 && i < arrlen(sApps); i++) {
if (appCounted(sApps[i])) {
if (seen == index) {
return i;
}
seen++;
}
}
return 0;
}
// Shell shutdown: the main loop has exited, so every app is released now.
void shellTerminateAllApps(AppContextT *ctx) {
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i]->state != AppStateFreeE) {
forceKillNow(ctx, sApps[i]);
}
}
}
void shellUnregisterAppCleanup(ShellAppCleanupFnT fn) {
for (int32_t i = arrlen(sCleanupFns) - 1; i >= 0; i--) {
if (sCleanupFns[i] == fn) {
arrdel(sCleanupFns, i);
}
}
}