// 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 #include #include #include #include #include #include #include #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 (.hlp beside .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: .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/. 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); } } }