@tn3270/gui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ /** Types for `launch.mjs`, so its test is typechecked. */
2
+ export const FORWARDED: readonly NodeJS.Signals[];
3
+ export function exitPlan(
4
+ code: number | null,
5
+ signal: NodeJS.Signals | null,
6
+ platform?: NodeJS.Platform,
7
+ ): { exit: number } | { raise: NodeJS.Signals };
8
+ export function electronMissing(err: unknown): string;
9
+ export function bundleMissing(path: string): string;
10
+ export function sandboxHint(chromeSandbox: string): string;
package/bin/launch.mjs ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The launcher's decisions, as pure functions, so they can be tested without Electron.
3
+ * `tn3270-gui.mjs` is the only caller; it does the I/O.
4
+ */
5
+ import { constants } from 'node:os';
6
+
7
+ /** Signals the launcher forwards to Electron. SIGHUP is the terminal closing. */
8
+ export const FORWARDED = Object.freeze(['SIGINT', 'SIGTERM', 'SIGHUP']);
9
+
10
+ /**
11
+ * Signals worth RE-RAISING on the launcher when Electron died of one, so a parent shell or
12
+ * harness sees "killed by SIGTERM" rather than an exit code. Anything else -- SIGTRAP from
13
+ * Chromium's sandbox abort, SIGSEGV -- would make Node dump core if re-raised, so it becomes the
14
+ * shell convention `128 + n` instead.
15
+ */
16
+ const RERAISE = new Set(['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGKILL']);
17
+
18
+ /**
19
+ * What the launcher does once Electron has exited: `{ exit: n }` or `{ raise: signal }`.
20
+ * `platform` is a parameter only for the tests.
21
+ */
22
+ export function exitPlan(code, signal, platform = process.platform) {
23
+ if (signal === null || signal === undefined) return { exit: code ?? 1 };
24
+ // Windows has no signal delivery to itself worth the name: `process.kill(pid, sig)` there
25
+ // terminates outright, so an exit code says more.
26
+ if (platform !== 'win32' && RERAISE.has(signal)) return { raise: signal };
27
+ return { exit: 128 + (constants.signals[signal] ?? 0) };
28
+ }
29
+
30
+ /** The message for `require('electron')` throwing: the binary was never downloaded, or failed. */
31
+ export function electronMissing(err) {
32
+ return 'tn3270-gui: the Electron binary is not installed.\n'
33
+ + ` (${err instanceof Error ? err.message : String(err)})\n`
34
+ + ' Electron 44 downloads its ~100 MB binary on first use, so this means that download failed\n'
35
+ + ' (no network, a proxy, or ELECTRON_MIRROR pointing somewhere wrong). Fix the network and\n'
36
+ + ' run tn3270-gui again, or reinstall: npm install -g @tn3270/gui\n';
37
+ }
38
+
39
+ /** The message for a package whose `app/` bundle is incomplete. */
40
+ export function bundleMissing(path) {
41
+ return `tn3270-gui: reinstall @tn3270/gui (npm install -g @tn3270/gui): ${path} is missing, `
42
+ + 'so this install has no complete app bundle to run.\n';
43
+ }
44
+
45
+ /**
46
+ * Printed after Electron dies of SIGTRAP on Linux, which is how Chromium's "SUID sandbox helper
47
+ * binary was found, but is not configured correctly" abort arrives. Chromium's own FATAL line,
48
+ * just above on stderr, names only the chown/chmod fix; this names the other two.
49
+ */
50
+ export function sandboxHint(chromeSandbox) {
51
+ return '\ntn3270-gui: Electron aborted. If the line above mentions the SUID sandbox helper, this\n'
52
+ + " system blocks unprivileged user namespaces, which Chromium's sandbox needs when\n"
53
+ + ' chrome-sandbox is not setuid root (npm cannot install it setuid). Either, as root:\n'
54
+ + ` chown root:root ${chromeSandbox} && chmod 4755 ${chromeSandbox}\n`
55
+ + ' or run without the sandbox, at your own risk: tn3270-gui --no-sandbox ...\n';
56
+ }
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `tn3270-gui`: runs the bundled app (`../app/main.js`) under the Electron that npm installed
4
+ * beside it, with the user's arguments. The decisions are in `launch.mjs`, tested without Electron.
5
+ *
6
+ * ## WHY NOT ELECTRON'S OWN BIN
7
+ *
8
+ * `electron/cli.js` forwards only SIGINT, SIGTERM and SIGUSR2 and never kills its child when it
9
+ * dies itself, so a harness's SIGKILL in this repo orphaned the real binary to PID 1 (see
10
+ * `scripts/xvfb.mjs`). This one forwards SIGINT/SIGTERM/SIGHUP, kills Electron if the launcher
11
+ * exits first, and exits with Electron's code -- or re-raises its signal. A SIGKILL to the
12
+ * launcher still cannot be forwarded (nothing can catch it); kill Electron's PID for that.
13
+ *
14
+ * ## THE SANDBOX IS LEFT ON -- MEASURED 2026-11-06, Electron 44.10.2, Rocky 9 kernel 5.14
15
+ *
16
+ * npm cannot install `chrome-sandbox` setuid root (here it is `athor 0755`). That does NOT stop
17
+ * Electron: Chromium prefers the user-namespace sandbox and only falls back to the SUID helper
18
+ * when namespaces are blocked. On this box the app ran and drew the golden pixels WITHOUT
19
+ * `--no-sandbox`; forcing the SUID path (`--disable-namespace-sandbox`) reproduced the abort
20
+ * ("SUID sandbox helper binary was found, but is not configured correctly"), exit by SIGTRAP.
21
+ * So adding `--no-sandbox` automatically would switch the sandbox off on every system that can
22
+ * run it, to help only those that block unprivileged namespaces (Ubuntu 23.10+ AppArmor, some
23
+ * hardened kernels). Electron's own npm launcher (`electron/cli.js`, read 2026-11-06) makes the
24
+ * same choice: it never adds `--no-sandbox`. The user passes it, or fixes the helper's ownership
25
+ * (what distro packages of Electron apps do in a postinst). We print both remedies when Electron
26
+ * dies of
27
+ * SIGTRAP on Linux, and `--no-sandbox` on the command line passes straight through to Chromium
28
+ * (main.ts strips it from what our own parser sees).
29
+ *
30
+ * ## WINDOWS -- NOT RUN THERE YET
31
+ *
32
+ * Node emulates SIGINT/SIGHUP delivery only from the console, and `child.kill()` there is
33
+ * TerminateProcess whatever the signal, so forwarding degrades to "kill Electron when asked to
34
+ * stop". Re-raising is replaced by an exit code (`exitPlan`), and `process.kill` is guarded.
35
+ */
36
+ import { spawn } from 'node:child_process';
37
+ import { existsSync } from 'node:fs';
38
+ import { createRequire } from 'node:module';
39
+ import { dirname, join } from 'node:path';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { FORWARDED, bundleMissing, electronMissing, exitPlan, sandboxHint } from './launch.mjs';
42
+
43
+ const app = join(dirname(fileURLToPath(import.meta.url)), '..', 'app');
44
+ const main = join(app, 'main.js');
45
+
46
+ // BOTH, because bundle.mjs writes BUNDLED last: main.js without it is a bundle that failed its
47
+ // own self-check. Without this, Electron given a missing path opens its default window.
48
+ for (const f of [main, join(app, 'BUNDLED')]) {
49
+ if (!existsSync(f)) {
50
+ process.stderr.write(bundleMissing(f));
51
+ process.exit(1);
52
+ }
53
+ }
54
+
55
+ let electron;
56
+ try {
57
+ // In Node, `require('electron')` is the binary's PATH -- and, in Electron 44, downloads the
58
+ // binary first if npm's install never did. It throws if that download fails.
59
+ electron = createRequire(import.meta.url)('electron');
60
+ } catch (err) {
61
+ process.stderr.write(electronMissing(err));
62
+ process.exit(1);
63
+ }
64
+
65
+ const child = spawn(electron, [main, ...process.argv.slice(2)], { stdio: 'inherit', windowsHide: false });
66
+
67
+ for (const sig of FORWARDED) {
68
+ process.on(sig, () => {
69
+ if (child.exitCode === null && child.signalCode === null) child.kill(sig);
70
+ });
71
+ }
72
+ // Any way the launcher leaves before Electron does (an uncaught throw, process.exit elsewhere)
73
+ // takes Electron with it, so no path here orphans it.
74
+ process.on('exit', () => {
75
+ if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM');
76
+ });
77
+
78
+ child.on('error', (err) => {
79
+ process.stderr.write(`tn3270-gui: cannot start Electron (${electron}): ${err.message}\n`);
80
+ process.exit(1);
81
+ });
82
+
83
+ child.on('exit', (code, signal) => {
84
+ if (signal === 'SIGTRAP' && process.platform === 'linux') {
85
+ process.stderr.write(sandboxHint(join(dirname(electron), 'chrome-sandbox')));
86
+ }
87
+ const plan = exitPlan(code, signal);
88
+ if ('exit' in plan) process.exit(plan.exit);
89
+ // Our own handler for that signal would swallow it; drop it, then die of the same signal so the
90
+ // parent sees what Electron died of.
91
+ process.removeAllListeners(plan.raise);
92
+ try {
93
+ process.kill(process.pid, plan.raise);
94
+ } catch {
95
+ process.exit(1);
96
+ }
97
+ });
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@tn3270/gui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "An Electron TN3270 emulator with a canvas renderer that blits glyphs from an atlas baked out of x3270's own 3270 bitmap font.",
5
+ "license": "MIT",
6
+ "author": "Adam Thornton <athornton@gmail.com>",
7
+ "type": "module",
8
+ "main": "./dist/main.js",
9
+ "bin": {
10
+ "tn3270-gui": "./bin/tn3270-gui.mjs"
11
+ },
12
+ "files": ["app", "bin", "README.md"],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/athornton/tn3270.git",
16
+ "directory": "packages/gui"
17
+ },
18
+ "homepage": "https://github.com/athornton/tn3270#readme",
19
+ "engines": { "node": ">=22" },
20
+ "publishConfig": { "access": "public" },
21
+ "dependencies": {
22
+ "@tn3270/canvas": "0.1.0",
23
+ "@tn3270/core": "0.1.0",
24
+ "@tn3270/frontend": "0.1.0",
25
+ "@tn3270/node-files": "0.1.0",
26
+ "electron": "44.10.2"
27
+ },
28
+ "scripts": {
29
+ "build": "tsc --build"
30
+ }
31
+ }