This document defines the Comprehensive Launcher Standard for the hyperpolymath ecosystem. It extends the E-Grade principles with specific implementation patterns, code templates, and troubleshooting guides for creating robust, reliable application launchers.
The reference implementation is provided in comprehensive-launcher-template.sh.
# Standardized structure for all launchers
APP_NAME="MyApp" # Application name
REPO_DIR="/path/to/repo" # Repository directory
COMMAND="command to run" # Startup command
URL="http://localhost:PORT" # Web URL (if applicable)
# PID file in XDG_RUNTIME_DIR (mode 0700, user-scoped) — falls back to
# $TMPDIR (macOS) then /tmp (last resort). See §Best Practices > Security.
PID_FILE="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}/${APP_NAME}-server.pid"
# Log file in XDG_STATE_HOME (defaults to $HOME/.local/state per spec).
LOG_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/${APP_NAME}"
LOG_FILE="${LOG_DIR}/server.log"
mkdir -p "$LOG_DIR"
MODE="${1:---auto}" # Default mode# Start process with nohup to prevent termination
nohup $COMMAND >"$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
# Check if process is still running
is_running() {
[ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null
}
# Clean shutdown
stop_server() {
if is_running; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
rm -f "$PID_FILE"
fi
}wait_for_server() {
# All three values are env-overridable per [runtime] in launcher-standard.a2ml.
local max_wait="${1:-${WAIT_FOR_URL_TIMEOUT_SECONDS:-15}}"
local poll_interval="${WAIT_FOR_URL_POLL_INTERVAL:-1}"
local per_request_timeout=2 # curl --max-time; caps each probe so a
# hung server can't eat the whole budget
local waited=0
while [ "$waited" -lt "$max_wait" ]; do
if curl -fsS --max-time "$per_request_timeout" "$URL" >/dev/null 2>&1; then
return 0 # Success
fi
sleep "$poll_interval"
waited=$((waited + poll_interval))
done
return 1 # Timeout
}
# Usage — pass nothing so the env-var/constant chain applies.
if ! wait_for_server; then
err "Server did not start within ${WAIT_FOR_URL_TIMEOUT_SECONDS:-15}s"
err "Check log: $LOG_FILE"
err "Override with WAIT_FOR_URL_TIMEOUT_SECONDS=<N> if the host needs longer"
return 1
fiResolution order, per [browser-launch] in launcher-standard.a2ml:
-
$BROWSERenv var (de-facto Unix convention; user/operator override) -
Platform-specific ladder, dispatched via
uname -s:-
macOS:
open -
Linux: if WSL (detected via
/proc/versioncontaining "microsoft"), preferwslviewso the URL opens in the Windows-side default browser. Otherwise:xdg-open→firefox→chromium. -
Windows (Git Bash / MSYS / Cygwin):
start(cmd builtin)
-
-
Fallback: print
"Open manually: $URL"so the user is never silently left without a URL.
open_browser() {
if ! is_running; then
err "Server is not running"
return 1
fi
# 1. $BROWSER override (canonical Unix convention)
if [[ -n "${BROWSER:-}" ]]; then
"$BROWSER" "$URL" &
return $?
fi
# 2. Platform-specific ladder
case "$(uname -s)" in
Darwin*)
open "$URL" &
;;
Linux*)
# WSL detection: /proc/version mentions "microsoft" under WSL1/2
if grep -qi microsoft /proc/version 2>/dev/null && \
command -v wslview >/dev/null 2>&1; then
wslview "$URL" &
elif command -v xdg-open >/dev/null 2>&1; then
xdg-open "$URL" &
elif command -v firefox >/dev/null 2>&1; then
firefox "$URL" &
elif command -v chromium >/dev/null 2>&1; then
chromium "$URL" &
else
log "Open manually: $URL"
fi
;;
MINGW*|MSYS*|CYGWIN*)
# Git Bash / MSYS / Cygwin all carry `start` from cmd
start "$URL" &
;;
*)
log "Open manually: $URL"
;;
esac
}Stderr-only err() is the right default for TUI invocation, but when a
launcher is started from a desktop entry there is no terminal for the
user to see — every failure looks identical (a brief flash and nothing
else). The reference helper launcher/gui-error.sh surfaces errors via
the platform’s dialog ladder AND stderr, so the failure is visible no
matter how the launcher was started.
# Always provide actionable feedback
err() {
echo "[$APP_NAME] ERROR: $1" >&2
echo "[$APP_NAME] Try: check $LOG_FILE for details" >&2
}
# Source the shared GUI-error helper (reference impl of [error-visibility]
# from launcher-standard.a2ml). Falls back to err() if not available so
# every launcher works even without the shared helper on PATH.
if [ -r "$(hp_resolve_desktop_tools gui-error.sh 2>/dev/null)" ]; then
# shellcheck disable=SC1090
. "$(hp_resolve_desktop_tools gui-error.sh)"
else
hp_gui_error() { err "$2"; } # graceful degradation
fi
# Example usage — fails LOUDLY whether GUI or TUI
if ! start_server; then
hp_gui_error "$APP_NAME failed to start" \
"Check log: $LOG_FILE\n\nOverride wait timeout: WAIT_FOR_URL_TIMEOUT_SECONDS=<N>"
exit 1
fi|
Note
|
hp_gui_error writes to stderr unconditionally per
[error-visibility].always-also-to-stderr = true. Set NO_GUI_ERROR=1
to suppress the dialog attempt (useful in CI).
|
A "soft-attach" tool is one the launcher calls IF it is installed, and
silently skips otherwise. The estate ships three by default
(feedback-o-tron, hypatia, panic-attack) — see
[soft-attach].tools in launcher-standard.a2ml for the live list.
Downstream launchers SHOULD source launcher/soft-attach.sh rather
than re-implementing the if-installed-then-invoke pattern, so behaviour
stays consistent across the estate.
# Source the shared soft-attach helper. Graceful degradation: if the
# helper is not on the resolution ladder, every soft-attach call
# becomes a silent no-op.
if [ -r "$(hp_resolve_desktop_tools soft-attach.sh 2>/dev/null)" ]; then
# shellcheck disable=SC1090
. "$(hp_resolve_desktop_tools soft-attach.sh)"
else
hp_soft_attach_event() { :; }
hp_soft_attach_run() { :; }
fi
# Call sites: typically wired into start_server() on failure path
on_start_failed() {
hp_soft_attach_event "feedback-o-tron" "launcher:start_failed" \
--app "$APP_NAME" --log "$LOG_FILE"
hp_soft_attach_run "hypatia diagnose --app $APP_NAME --log $LOG_FILE"
hp_soft_attach_run "panic-attack assail $REPO_DIR"
}Note that template substitution ({app-name}, {log-file},
{repo-dir} in the a2ml) is the launcher’s responsibility — interpolate
before passing the command line to hp_soft_attach_run.
| Mode | Purpose |
|---|---|
|
Start the server/application without opening browser |
|
Stop the running server/application |
|
Show current status (running/stopped, URL if applicable) |
|
Start server and open browser (for web apps) |
|
Alias for |
|
System integration. Install the launcher as a first-class application:
Start Menu / Applications folder entry, Desktop shortcut, icon, and the
launcher itself copied to a stable location on |
|
System dis-integration. Undo everything |
|
Print usage text, including a description of every mode, the files the launcher reads/writes, and the detected platform. |
|
Print the launcher’s version on a single machine-greppable first line,
then exit 0. Format: |
|
Note
|
--integ and --disinteg replace the earlier pattern of separate
install.sh / uninstall.sh scripts from LM-LA-LIFECYCLE-STANDARD.
Having install, uninstall, and runtime in a single script means users have
one thing to run and one thing to remember, which is the whole point of the
E-Grade principle. The LM-LA-LIFECYCLE-STANDARD remains authoritative for
what must happen during install and uninstall; this standard specifies
where those operations are triggered from (the launcher itself).
|
The --integ mode installs the launcher as a first-class desktop application
on the host OS. It is the single entry point for "make this a real app on my
system". The --disinteg mode is its exact inverse.
-
Idempotent.
--integrun twice is fine.--disintegon an unintegrated system is a friendly no-op, not an error. -
Cross-platform. The same launcher script works on Linux, macOS, and Windows (via Git Bash or WSL). Platform detection is via
uname -s. -
Detectable.
--integdetects existing installations and prompts before overwriting, unless--forceis passed as a second argument. -
Reversible.
--disintegremoves everything--integinstalled and leaves user config and logs alone so reinstall is seamless. -
No elevated privileges. All paths are user-level. No
sudo, no system-wide directories. Reinstall per user, not per machine. -
Launcher copy, not symlink.
--integcopies the launcher script to a stable location onPATHrather than symlinking, so moving or renaming the source repo does not break the Start Menu entry.
| Platform | Path | Purpose |
|---|---|---|
Linux |
|
Start Menu / app launcher entry |
Linux |
|
Desktop shortcut (marked trusted via |
Linux |
|
Icon used by the Start Menu entry |
Linux |
|
Copy of the launcher script on |
macOS |
|
Launchpad + Spotlight entry. Not codesigned. |
macOS |
|
Double-clickable shortcut |
macOS |
|
Copy of the launcher script on |
Windows (Git Bash / WSL) |
|
Start Menu entry. Created via PowerShell |
Windows (Git Bash / WSL) |
|
Desktop shortcut |
Windows (Git Bash / WSL) |
|
Copy of the launcher script |
Everything --integ created, plus:
-
The PID file (
$XDG_RUNTIME_DIR/<app>-server.pid, or the resolved equivalent — see §Best Practices > Security) -
Any
.batfallback shortcuts written when PowerShell wasn’t available
It deliberately does not remove:
-
~/.config/<app>/— user preferences survive reinstall -
$XDG_STATE_HOME/<app>/(defaults to$HOME/.local/state/<app>/) — logs stay for post-mortem -
The source repository at
REPO_DIR
The removal instructions for those are printed after --disinteg so the user
can run them manually if they really want a clean wipe.
See comprehensive-launcher-template.sh
for the do_integ() / do_disinteg() functions and the platform-dispatch
pattern. Existing launchers should be updated to include these modes; the
pattern is drop-in because the integration paths are all globals set by
platform detection at the top of the script.
Every .desktop file’s primary Exec= line MUST go through keepopen.sh
(see §Fallback Ladder below). This ensures that a user who double-clicks
a desktop icon always lands somewhere useful — even when every upstream
hook is broken — instead of seeing a terminal flash on-and-off with no
feedback.
[Desktop Entry]
Type=Application
Name=Application Name
# The Exec path MUST be the absolute path resolved at install time via
# the [resolution].desktop-tools-search ladder (see §Canonical location).
# The freedesktop spec does NOT expand environment variables in Exec=
# lines, so `--integ` is responsible for picking the host-correct path.
# The /var/mnt/eclipse/... value below is one possible resolution — on
# hosts using $HOME/developer/repos or $HOME/dev/repos the resolved path
# will differ.
Exec=/var/mnt/eclipse/repos/.desktop-tools/keepopen.sh "AppName" "/path/to/repo" "GUI_CMD" "TUI_CMD" "$HOME/.local/state/app/server.log"
Terminal=true # keepopen needs a terminal for its loud banners and shell fallback
Icon=/path/to/icon.png
Categories=Category;
StartupNotify=true
# Actions for additional functionality — these can bypass keepopen because
# they are invoked from an already-open context menu; no fallback needed.
Actions=stop;status;
[Desktop Action stop]
Name=Stop Server
Exec=/path/to/launcher.sh --stop
[Desktop Action status]
Name=Server Status
Exec=/path/to/launcher.sh --status|
Note
|
The old "pure-GUI apps use Terminal=false`" advice is superseded by
`keepopen.sh. A pure-GUI Exec= still flashes a terminal for ~1 second
when the GUI launch succeeds, but in exchange every failure mode becomes
visible instead of silent. The tradeoff favours debuggability.
|
Desktop launchers fail in three predictable ways, and users must be able to see which one happened:
-
The GUI binary is missing, broken, or the URL is unreachable.
-
The TUI/CLI fallback is also broken.
-
The repo itself is missing or unbuildable.
Without a visible fallback ladder, all three failures look identical to
the user: a terminal flashes for half a second, then the desktop is quiet.
keepopen.sh turns each failure into a LOUD, labelled banner and, when
all else fails, drops the user into an interactive shell at the repo
root so they can fix whatever is broken.
The single source of truth is:
developer-ecosystem/standards/launcher/keepopen.sh
For desktop files (which need a stable absolute path) a symlink or copy
is deployed inside a .desktop-tools/ directory whose location varies by
host. Consumers MUST resolve that location via the search ladder declared
in launcher/launcher-standard.a2ml [resolution].desktop-tools-search:
-
$HP_DESKTOP_TOOLS— explicit override -
$HP_ESTATE_ROOT/.desktop-tools— estate-root convention -
$XDG_DATA_HOME/hyperpolymath/.desktop-tools— XDG default -
/var/mnt/eclipse/repos/.desktop-tools— legacy eclipse-mount layout -
$HOME/developer/repos/.desktop-tools— alt: $HOME/developer/repos -
$HOME/dev/repos/.desktop-tools— alt: $HOME/dev/repos
First existing path wins. A reference shell implementation lives at
launcher/resolve-desktop-tools.sh (sourceable; provides
hp_resolve_desktop_tools and hp_resolve_standard). Downstream launchers
SHOULD use the reference impl rather than re-implementing the ladder.
launch-scaffolder copies the same script into its baked-in standards
so regenerated launchers stay in sync. The legacy single hard-coded path
(/var/mnt/eclipse/repos/.desktop-tools/keepopen.sh) remains in the
a2ml as deployed-symlink for compatibility with pre-resolution
consumers; new code MUST use the ladder.
keepopen.sh APP_NAME REPO_DIR "GUI_CMD" "TUI_CMD" [LOG_FILE]-
APP_NAME: short label; used in banners and the[keepopen:${APP_NAME}]prefix. -
REPO_DIR: absolute path to the app’s repository root — where the final shell fallback lands. -
GUI_CMD: shell command for the primary GUI path. Pass""to skip this stage. -
TUI_CMD: shell command for the TUI fallback. Pass""to skip this stage. -
LOG_FILE(optional): log file path, shown in banners so the user knows where to look.
Each *_CMD is executed via bash -c, so pipelines and shell quoting work.
For launchers that daemonise their payload and exit 0 immediately, chain
a tail -f LOG after the launcher so the terminal stays open:
"aerie-launcher.sh --auto && tail -f $HOME/.local/state/aerie/server.log"| Stage | On success | On failure |
|---|---|---|
1. GUI |
|
Yellow banner titled |
2. TUI |
|
Red banner titled |
3. Shell at repo root |
|
If |
The banners are intentionally loud and ugly. Visibility beats aesthetics — the point is that a broken launcher should look broken, not silently fail.
-
Do not use
read -rp 'Press Enter to close…'as a terminal-keepalive. It loses the user’s context. Use `keepopen.sh’s shell fallback instead, which drops them into the repo root where they can actually debug. -
Do not wrap
Exec=inkonsole --hold— the--holdterminal has no working directory and no login-shell environment, so the user can’t easily switch to investigating. -
Do not skip
keepopen.shfor "simple" apps. Simple apps break too, and the symptom is identical to the flashy ones without the wrapper.
# BAD: konsole wrapping (pre-keepopen era)
Exec=konsole -e /path/to/launcher.sh --auto
Terminal=true
# BAD: konsole --hold with no cwd / no login shell
Exec=konsole --background-mode --hold --qwindowtitle Title -e wrapper.sh launcher.sh
# BAD: launcher without keepopen — flashes a terminal on failure with no feedback
Exec=launcher.sh --auto| Symptom | Likely Cause | Solution |
|---|---|---|
Black terminal window |
Terminal wrapping in desktop file |
Remove konsole wrapping, set Terminal=false |
"Cannot connect to server" |
Server not running or wrong URL |
Check |
Browser doesn’t open |
Server not ready when browser launches |
Increase wait_for_server timeout |
Process dies immediately |
Missing |
Add |
Multiple instances running |
No PID file checking |
Implement |
Port already in use |
Previous instance not cleaned up |
Add |
-
Check log file:
tail -f "$LOG_FILE"(resolves to$XDG_STATE_HOME/<app>/server.log) -
Verify process:
ps aux | grep app-name -
Test URL manually:
curl -v http://localhost:PORT -
Check PID file:
cat "$PID_FILE"(resolves to$XDG_RUNTIME_DIR/<app>-server.pid) -
Test browser opening:
xdg-open http://localhost:PORT -
Verify dependencies:
command -v required-command -
Check port availability:
ss -tlnp | grep PORT
# OLD (bad)
Exec=konsole --background-mode --hold --qwindowtitle Title -e wrapper.sh launcher.sh
Terminal=true
# NEW (good)
Exec=/path/to/launcher.sh --auto
Terminal=false[ ] Primary Exec= goes through keepopen.sh with non-empty GUI and/or TUI commands
[ ] GUI_CMD for daemon-style launchers chains && tail -f LOG so the terminal stays open on success
[ ] REPO_DIR argument points to a real, existing repository root
[ ] LOG_FILE argument is passed when the launcher writes one, so banners can cite it
[ ] Remove all pre-keepopen konsole wrapping from desktop files
[ ] Terminal=true on the primary Exec (keepopen needs a terminal for banners + shell fallback)
[ ] Implement nohup for background processes
[ ] Add PID file tracking and cleanup
[ ] Implement wait_for_server() with reasonable timeout
[ ] Add proper error handling and user feedback
[ ] Provide clear success/failure messages
[ ] Log to XDG state dir ($XDG_STATE_HOME/<app>/server.log, defaults to $HOME/.local/state/<app>/server.log); never use /tmp/<app>.log
[ ] Handle browser launch failures gracefully
[ ] Provide manual fallback instructions
[ ] Implement --start, --stop, --status modes
[ ] Test desktop launching without terminal
[ ] Verify browser opens automatically
[ ] Test error conditions (port in use, missing deps)
-
Log to
$XDG_STATE_HOME/<app>/server.log(defaults to$HOME/.local/state/<app>/server.logper XDG spec). Per-user, survives reboot, not world-writable. -
Never log to
/tmp/<app>.log. Predictable names in a world-writable dir are a symlink-attack target on shared hosts — see §Best Practices > Security. -
Include timestamps for long-running processes
-
Rotate logs if they grow large
-
Provide log location in error messages
-
Provide immediate feedback when launching
-
Show progress during server startup
-
Clear success message when ready
-
Actionable error messages with next steps
-
Never leave user wondering what happened
-
Use reasonable timeouts (10-20 seconds typical)
-
Don’t block indefinitely
-
Provide feedback during waiting
-
Optimize startup sequence
-
PID files MUST go in
$XDG_RUNTIME_DIR(Linux) /$TMPDIR(macOS), not/tmp.$XDG_RUNTIME_DIRis mode0700and user-scoped per the XDG Base Directory spec;$TMPDIRon macOS is/var/folders/…/T(per-user)./tmpis world-writable: an attacker on a shared host can pre-create/tmp/<app>-server.pidcontaining their own PID, after which the launcher’sis_running()returns true andstop_server()willkill <attacker-pid>— DoS or signal-handling abuse vector. The fallback ladder${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}exists only as a last resort for hosts that set neither (rare). -
Log files MUST go in
$XDG_STATE_HOMEfor the same reason — never/tmp/<app>.log. -
Use predictable but unique PID file names within the chosen dir (not in
/tmp). -
Clean up PID files on exit
-
Don’t log sensitive information
-
Validate URLs before opening
-
E-Grade Launcher Template - Original specification
-
Comprehensive Template - Full reference implementation
-
Consent-Aware Web ⇗ - Privacy-compliant feedback
-
Groove Protocol - Soft-attach patterns
-
Hypatia Rules - LLM integration guidelines