DVX_GUI/src/libs/kpunch/dvxshell/shellApp.c

628 lines
21 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 "dvxMem.h"
// ============================================================
// Module state
// ============================================================
// Dynamic app slot table (stb_ds array). 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.
static ShellAppT *sApps = NULL;
static int32_t sAppsCap = 0; // number of slots allocated (including slot 0)
// Ctrl+Esc handler -- set by taskmgr DXE constructor, NULL if not loaded
void (*shellCtrlEscFn)(AppContextT *ctx) = NULL;
// ============================================================
// Prototypes
// ============================================================
static int32_t allocSlot(void);
static void appTaskWrapper(void *arg);
static void cleanupTempFile(ShellAppT *app);
static int32_t copyFile(const char *src, const char *dst);
static ShellAppT *findLoadedPath(const char *path);
static int32_t makeTempPath(const char *origPath, int32_t id, char *out, int32_t outSize);
void shellAppInit(void);
int32_t shellAppSlotCount(void);
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);
void shellReapApp(AppContextT *ctx, ShellAppT *app);
bool shellReapApps(AppContextT *ctx);
int32_t shellRunningAppCount(void);
void shellTerminateAllApps(AppContextT *ctx);
// 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.
// If no free slot exists, appends a new 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 -- grow the array
ShellAppT newSlot;
memset(&newSlot, 0, sizeof(newSlot));
arrput(sApps, newSlot);
return arrlen(sApps) - 1;
}
// 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) {
// Look up the app by ID, not by pointer. The sApps array may have
// been reallocated between tsCreate and the first time this task runs
// (e.g. another app was loaded in the meantime), which would
// invalidate a direct ShellAppT pointer.
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
app->state = AppStateTerminatingE;
}
// 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;
}
char buf[32768];
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;
}
// Find an active slot with the given DXE path. Returns the slot pointer,
// or NULL if no running app was loaded from this path.
static ShellAppT *findLoadedPath(const char *path) {
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i].state != AppStateFreeE && strcmp(sApps[i].path, path) == 0) {
return &sApps[i];
}
}
return NULL;
}
// 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 slot ID is embedded in the filename to ensure uniqueness.
// Example: TEMP=C:\TEMP -> "C:\TEMP\_dvx02.app"
static int32_t 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, '.');
if (!dot || (lastSlash && dot < lastSlash)) {
dot = origPath + strlen(origPath);
}
const char *tmpDir = getenv("TEMP");
if (!tmpDir) {
tmpDir = getenv("TMP");
}
if (tmpDir && tmpDir[0]) {
snprintf(out, outSize, "%s/_dvx%02ld%s", tmpDir, (long)id, dot);
} else {
snprintf(out, outSize, "_dvx%02ld%s", (long)id, dot);
}
return 0;
}
void shellAppInit(void) {
// Seed slot 0 (reserved for the shell)
ShellAppT slot0;
memset(&slot0, 0, sizeof(slot0));
arrput(sApps, slot0);
sAppsCap = 1;
}
int32_t shellAppSlotCount(void) {
return arrlen(sApps);
}
void shellConfigPath(const DxeAppContextT *ctx, const char *filename, char *outPath, int32_t outSize) {
snprintf(outPath, outSize, "%s" DVX_PATH_SEP "%s", ctx->configDir, filename);
}
int32_t shellEnsureConfigDir(const DxeAppContextT *ctx) {
return platformMkdirRecursive(ctx->configDir);
}
// Forcible kill -- no shutdown hook is called. Used for crashed apps
// (where running more app code would be unsafe) and for "End Task".
// Cleanup order matters: windows first (removes them from the compositor),
// then the task (frees the stack), then the DXE handle (unmaps the code).
// If we closed the DXE first, destroying windows could call into unmapped
// callback code and crash the shell.
void shellForceKillApp(AppContextT *ctx, ShellAppT *app) {
if (!app || app->state == AppStateFreeE) {
return;
}
// Call shutdown hook so the app can unregister callbacks, close
// handles, etc. Without this, dangling function pointers (e.g.
// shellDesktopUpdate callbacks) cause crashes after dlclose.
if (app->shutdownFn) {
ctx->currentAppId = app->appId;
app->shutdownFn();
ctx->currentAppId = 0;
}
// Destroy all windows belonging to this app. Walk backwards because
// dvxDestroyWindow removes the window from the stack, shifting indices.
for (int32_t i = ctx->stack.count - 1; i >= 0; i--) {
if (ctx->stack.windows[i]->appId == app->appId) {
dvxDestroyWindow(ctx, ctx->stack.windows[i]);
}
}
// Kill the task if it has one
if (app->hasMainLoop && app->mainTaskId > 0) {
if (tsGetState(app->mainTaskId) != TaskStateTerminated) {
tsKill(app->mainTaskId);
}
}
// Close the DXE
if (app->dxeHandle) {
dlclose(app->dxeHandle);
app->dxeHandle = NULL;
}
cleanupTempFile(app);
free(app->dxeCtx);
app->dxeCtx = NULL;
dvxMemResetApp(app->appId);
app->state = AppStateFreeE;
dvxLog("Shell: force-killed app '%s'", app->name);
}
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.
//
// 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) {
// Allocate a slot
int32_t id = allocSlot();
if (id < 0) {
dvxErrorBox(ctx, NULL, "Maximum number of applications reached.");
return -1;
}
// 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.
const char *loadPath = path;
char tempPath[DVX_MAX_PATH] = {0};
ShellAppT *existing = findLoadedPath(path);
if (existing) {
// Read multiInstance from the already-loaded descriptor
AppDescriptorT *existDesc = (AppDescriptorT *)dlsym(existing->dxeHandle, "_appDescriptor");
if (!existDesc || !existDesc->multiInstance) {
char msg[320];
snprintf(msg, sizeof(msg), "%s is already running.", platformPathBaseName(path));
dvxErrorBox(ctx, NULL, msg);
return -1;
}
// Multi-instance allowed: copy to a temp file so dlopen gets
// an independent code+data image.
makeTempPath(path, id, tempPath, sizeof(tempPath));
if (copyFile(path, tempPath) != 0) {
char msg[320];
snprintf(msg, sizeof(msg), "Failed to create instance copy of %s.", platformPathBaseName(path));
dvxErrorBox(ctx, NULL, msg);
return -1;
}
loadPath = tempPath;
}
// Snapshot free memory before loading so we can estimate app usage
dvxMemSnapshotLoad(id);
// Show hourglass during the load (dlopen + symbol resolution + appMain)
dvxSetBusy(ctx, true);
// Load the DXE
void *handle = dlopen(loadPath, RTLD_GLOBAL);
if (!handle) {
char msg[512];
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);
if (tempPath[0]) {
remove(tempPath);
}
return -1;
}
// Look up required symbols
AppDescriptorT *desc = (AppDescriptorT *)dlsym(handle, "_appDescriptor");
if (!desc) {
char msg[256];
snprintf(msg, sizeof(msg), "%s: missing appDescriptor", platformPathBaseName(path));
dvxLog("DXE symbol error: %s", msg);
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, msg);
dlclose(handle);
if (tempPath[0]) {
remove(tempPath);
}
return -1;
}
int32_t (*entry)(DxeAppContextT *) = (int32_t (*)(DxeAppContextT *))dlsym(handle, "_appMain");
if (!entry) {
char msg[256];
snprintf(msg, sizeof(msg), "%s: missing appMain", platformPathBaseName(path));
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, msg);
dlclose(handle);
if (tempPath[0]) {
remove(tempPath);
}
return -1;
}
void (*shutdown)(void) = (void (*)(void))dlsym(handle, "_appShutdown");
// Fill in the app slot
ShellAppT *app = &sApps[id];
memset(app, 0, sizeof(*app));
app->appId = id;
snprintf(app->name, SHELL_APP_NAME_MAX, "%s", desc->name);
snprintf(app->path, sizeof(app->path), "%s", 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);
ctx->currentAppId = 0;
dlclose(handle);
app->state = AppStateFreeE;
return -1;
}
app->dxeCtx->shellCtx = ctx;
app->dxeCtx->appId = id;
// Store the full app path so the app can load its own resources.
snprintf(app->dxeCtx->appPath, sizeof(app->dxeCtx->appPath), "%s", 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.
snprintf(app->dxeCtx->appDir, sizeof(app->dxeCtx->appDir), "%s", path);
char *sep = platformPathDirEnd(app->dxeCtx->appDir);
if (sep) {
*sep = '\0';
} else {
app->dxeCtx->appDir[0] = '.';
app->dxeCtx->appDir[1] = '\0';
}
// Derive config directory: mirror the app directory under CONFIG/.
// e.g. "APPS/KPUNCH/DVXBASIC" -> "CONFIG/APPS/KPUNCH/DVXBASIC"
// If the path doesn't start with a recognized prefix, fall back to
// "CONFIG/APPS/<appname>" using the descriptor name.
const char *appDirStr = app->dxeCtx->appDir;
if (appDirStr[0]) {
snprintf(app->dxeCtx->configDir, sizeof(app->dxeCtx->configDir), "CONFIG/%s", appDirStr);
} else {
snprintf(app->dxeCtx->configDir, sizeof(app->dxeCtx->configDir), "CONFIG/APPS/%s", desc->name);
}
// 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.
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(desc->name, appTaskWrapper, (void *)(intptr_t)id, stackSize, priority);
if (taskId < 0) {
ctx->currentAppId = 0;
dvxSetBusy(ctx, false);
dvxErrorBox(ctx, NULL, "Failed to create task for application.");
dlclose(handle);
free(app->dxeCtx);
app->dxeCtx = NULL;
app->state = AppStateFreeE;
return -1;
}
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.
app->entryFn(app->dxeCtx);
}
ctx->currentAppId = 0;
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);
}
// 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.
void shellReapApp(AppContextT *ctx, ShellAppT *app) {
if (!app || app->state == AppStateFreeE) {
return;
}
// Call shutdown hook if present
if (app->shutdownFn) {
ctx->currentAppId = app->appId;
app->shutdownFn();
ctx->currentAppId = 0;
ctx->currentAppId = 0;
}
// Destroy all windows belonging to this app
for (int32_t i = ctx->stack.count - 1; i >= 0; i--) {
if (ctx->stack.windows[i]->appId == app->appId) {
dvxDestroyWindow(ctx, ctx->stack.windows[i]);
}
}
// Kill the task if it has one and it's still alive
if (app->hasMainLoop && app->mainTaskId > 0) {
if (tsGetState(app->mainTaskId) != TaskStateTerminated) {
tsKill(app->mainTaskId);
}
}
// Close the DXE
if (app->dxeHandle) {
dlclose(app->dxeHandle);
app->dxeHandle = NULL;
}
cleanupTempFile(app);
free(app->dxeCtx);
app->dxeCtx = NULL;
dvxMemResetApp(app->appId);
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) {
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;
}
int32_t shellRunningAppCount(void) {
int32_t count = 0;
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i].state == AppStateRunningE || sApps[i].state == AppStateLoadedE) {
count++;
}
}
return count;
}
void shellTerminateAllApps(AppContextT *ctx) {
for (int32_t i = 1; i < arrlen(sApps); i++) {
if (sApps[i].state != AppStateFreeE) {
shellForceKillApp(ctx, &sApps[i]);
}
}
}